first version
This commit is contained in:
Generated
+83
@@ -14,6 +14,7 @@
|
||||
"@koa/router": "^10.1.1",
|
||||
"commander": "^8.3.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"formidable": "^2.0.1",
|
||||
"inquirer": "^8.2.0",
|
||||
"koa": "^2.13.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
@@ -233,6 +234,11 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -597,6 +603,15 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz",
|
||||
"integrity": "sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -736,6 +751,20 @@
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
|
||||
"integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==",
|
||||
"dependencies": {
|
||||
"dezalgo": "1.0.3",
|
||||
"hexoid": "1.0.0",
|
||||
"once": "1.4.0",
|
||||
"qs": "6.9.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -836,6 +865,14 @@
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
|
||||
},
|
||||
"node_modules/hexoid": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||
"integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-assert": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz",
|
||||
@@ -1503,6 +1540,17 @@
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.9.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz",
|
||||
"integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
|
||||
@@ -2094,6 +2142,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
|
||||
},
|
||||
"asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -2357,6 +2410,15 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
|
||||
},
|
||||
"dezalgo": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz",
|
||||
"integrity": "sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=",
|
||||
"requires": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2463,6 +2525,17 @@
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"formidable": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
|
||||
"integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==",
|
||||
"requires": {
|
||||
"dezalgo": "1.0.3",
|
||||
"hexoid": "1.0.0",
|
||||
"once": "1.4.0",
|
||||
"qs": "6.9.3"
|
||||
}
|
||||
},
|
||||
"fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -2533,6 +2606,11 @@
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
|
||||
},
|
||||
"hexoid": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||
"integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g=="
|
||||
},
|
||||
"http-assert": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz",
|
||||
@@ -3018,6 +3096,11 @@
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.9.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz",
|
||||
"integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw=="
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@koa/router": "^10.1.1",
|
||||
"commander": "^8.3.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"formidable": "^2.0.1",
|
||||
"inquirer": "^8.2.0",
|
||||
"koa": "^2.13.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
|
||||
@@ -50,9 +50,12 @@ function getNickName(usernameOrContact) {
|
||||
|
||||
/** 获取群聊消息的发言人 */
|
||||
function getGroupMsgSpeaker(msg) {
|
||||
if (!isGroupMessage(msg) || msg.isSendBySelf) {
|
||||
if (!isGroupMessage(msg)) {
|
||||
return null;
|
||||
}
|
||||
if (msg.isSendBySelf) {
|
||||
return bot.contacts[bot.user.UserName];
|
||||
}
|
||||
const speakerID = msg["OriginalContent"].split(":", 1)[0];
|
||||
return bot.contacts[speakerID];
|
||||
}
|
||||
|
||||
@@ -75,10 +75,22 @@ async function initDB(dbFile) {
|
||||
|
||||
/** 将消息存储到数据库 */
|
||||
async function pushMessage(message) {
|
||||
const m = {};
|
||||
for (const key of Object.keys(message)) {
|
||||
m["$" + key] = message[key];
|
||||
}
|
||||
// NOTICE: sql的参数对象必须和sql语句中的占位符一一对应,不能多也不能少
|
||||
const m = {
|
||||
$messageID: message.messageID,
|
||||
$createTimestamp: message.createTimestamp,
|
||||
$type: message.type,
|
||||
$messageType: message.messageType,
|
||||
$fromName: message.fromName,
|
||||
$fromNickName: message.fromNickName,
|
||||
$toName: message.toName,
|
||||
$toNickName: message.toNickName,
|
||||
$isGroupMessage: message.isGroupMessage,
|
||||
$speakingGroupMemberName: message.speakingGroupMemberName,
|
||||
$content: message.content,
|
||||
$filename: message.filename,
|
||||
$displayFilename: message.displayFilename,
|
||||
};
|
||||
await pushStatement.run(m);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,3 +61,34 @@ async function start() {
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
// let sessionData = JSON.parse(
|
||||
// fs.readFileSync("/home/master/desktop/session.json"),
|
||||
// {
|
||||
// encoding: "utf-8",
|
||||
// }
|
||||
// );
|
||||
// const W = require("wechat4u");
|
||||
// const bot = new W(sessionData);
|
||||
// bot.on("uuid", (uuid) => {
|
||||
// qrCode.generate("https://login.weixin.qq.com/l/" + uuid, {
|
||||
// small: true,
|
||||
// });
|
||||
// });
|
||||
|
||||
// bot.on("login", async () => {
|
||||
// console.log("logged in");
|
||||
// fs.writeFileSync(
|
||||
// "/home/master/desktop/session.json",
|
||||
// JSON.stringify(bot.botData)
|
||||
// );
|
||||
// const { mediaId } = await bot.uploadMedia(
|
||||
// fs.createReadStream("/home/master/desktop/q.jpg"),
|
||||
// undefined,
|
||||
// "filehelper"
|
||||
// );
|
||||
// bot.sendPic(mediaId, "filehelper").then((rse) => {
|
||||
// console.log(rse);
|
||||
// });
|
||||
// });
|
||||
// bot.restart();
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
bot,
|
||||
getDisplayName,
|
||||
getGroupMsgSpeaker,
|
||||
getGroupMsgContent,
|
||||
getNickName,
|
||||
} = require("./bot.js");
|
||||
const {
|
||||
@@ -58,7 +59,9 @@ async function processMessage(msg) {
|
||||
speakingGroupMember: null,
|
||||
filename: null,
|
||||
displayFilename: msg["FileName"] || "",
|
||||
fileSize: msg.FileSize | 0,
|
||||
isGroupMessage: bot.Contact.isRoomContact(peerContact),
|
||||
isSentBySelf: msg.isSendBySelf,
|
||||
};
|
||||
|
||||
if (m.isGroupMessage) {
|
||||
@@ -71,18 +74,18 @@ async function processMessage(msg) {
|
||||
case bot.CONF.MSGTYPE_TEXT:
|
||||
// 文本消息
|
||||
m.type = "text";
|
||||
m.content = msg["Content"];
|
||||
m.content = m.isGroupMessage ? getGroupMsgContent(msg) : msg["Content"];
|
||||
break;
|
||||
case bot.CONF.MSGTYPE_EMOTICON:
|
||||
case bot.CONF.MSGTYPE_IMAGE: {
|
||||
const suffix = msgType === bot.CONF.MSGTYPE_IMAGE ? ".jpg" : ".gif";
|
||||
m.type = "img";
|
||||
m.content =
|
||||
`【${msgType === bot.CONF.MSGTYPE_IMAGE ? "图片" : "表情"}】` +
|
||||
`[${msgType === bot.CONF.MSGTYPE_IMAGE ? "图片" : "表情"}]` +
|
||||
(msg["FileName"] || "");
|
||||
if (msg["HasProductId"]) {
|
||||
m.type = "unknown";
|
||||
m.content = "【表情贴图】请在手机上查看";
|
||||
m.content = "[表情贴图]请在手机上查看";
|
||||
break;
|
||||
}
|
||||
const cachePath = composeCacheFilePath(msg, suffix);
|
||||
@@ -101,7 +104,7 @@ async function processMessage(msg) {
|
||||
}
|
||||
case bot.CONF.MSGTYPE_VOICE: {
|
||||
m.type = "voice";
|
||||
m.content = "【语音消息】";
|
||||
m.content = "[语音消息]";
|
||||
m.filename = composeFilename(msg, ".mp3");
|
||||
const { data } = await bot.getVoice(msg.MsgId);
|
||||
fs.writeFileSync(composeMediaFilePath(msg, m.filename), data);
|
||||
@@ -110,6 +113,7 @@ async function processMessage(msg) {
|
||||
case bot.CONF.MSGTYPE_VIDEO:
|
||||
case bot.CONF.MSGTYPE_MICROVIDEO: {
|
||||
m.type = "video";
|
||||
m.content = "[视频]";
|
||||
const suffix = ".mp4";
|
||||
const cachePath = composeCacheFilePath(msg, suffix);
|
||||
const res = await bot.getVideo(msg.MsgId);
|
||||
@@ -125,16 +129,16 @@ async function processMessage(msg) {
|
||||
break;
|
||||
}
|
||||
case bot.CONF.MSGTYPE_APP:
|
||||
m.type = "file";
|
||||
if (msg.AppMsgType === 5) {
|
||||
m.type = "text";
|
||||
m.content = "【链接】" + msg["FileName"] + ": " + msg["Url"];
|
||||
m.content = "[链接]" + msg["FileName"] + ": " + msg["Url"];
|
||||
break;
|
||||
} else if (msg.AppMsgType == 6) {
|
||||
m.type = "file";
|
||||
m.content = "[文件]" + m.displayFilename;
|
||||
if (!(msg.FileSize < 10 * 1024 * 1024)) {
|
||||
// 忽略10M以上的大文件
|
||||
m.type = "unknown";
|
||||
m.content = "【文件】" + m.displayFilename;
|
||||
m.filename = null;
|
||||
break;
|
||||
}
|
||||
@@ -178,11 +182,11 @@ async function processMessage(msg) {
|
||||
(m.speakingGroupMember && getDisplayName(m.speakingGroupMember)) || null,
|
||||
content: m.content,
|
||||
filename: m.filename,
|
||||
fileSize: m.fileSize,
|
||||
displayFilename: m.displayFilename,
|
||||
};
|
||||
const clientMsg = {
|
||||
msgID: m.messageID,
|
||||
composedID: m.composedID,
|
||||
messageID: m.composedID,
|
||||
createTimestamp: m.createTimestamp,
|
||||
type: m.type,
|
||||
fromID: m.fromID,
|
||||
@@ -192,15 +196,19 @@ async function processMessage(msg) {
|
||||
toName: m.toName,
|
||||
toNickName: m.toNickName,
|
||||
isGroupMessage: m.isGroupMessage,
|
||||
isSentBySelf: m.isSentBySelf,
|
||||
speakingGroupMemberID:
|
||||
m.speakingGroupMember && m.speakingGroupMember.UserName,
|
||||
speakingGroupMemberName:
|
||||
(m.speakingGroupMember && getDisplayName(m.speakingGroupMember)) || null,
|
||||
content: m.content,
|
||||
filename: m.filename,
|
||||
fileSize: m.fileSize,
|
||||
displayFilename: m.displayFilename,
|
||||
};
|
||||
await db.pushMessage(dbMsg);
|
||||
if (!bot.Contact.isSpContact(peerContact)) {
|
||||
await db.pushMessage(dbMsg);
|
||||
}
|
||||
socket.pushMessage(clientMsg);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"dbFile": "/home/master/desktop/wxbot.db",
|
||||
"dataDir": "/home/master/desktop",
|
||||
"serve": true,
|
||||
"httpHost": "127.0.0.1",
|
||||
"httpHost": "localhost",
|
||||
"httpPort": 9200,
|
||||
"debugMode": false,
|
||||
"baseUrl": "http:127.0.0.1",
|
||||
"baseUrl": "http://localhost:9200",
|
||||
"appCode": {
|
||||
"mattuy-laptop": "iasdjfkwe3ds-qw3rjkwjfda8"
|
||||
},
|
||||
|
||||
+155
-83
@@ -1,19 +1,21 @@
|
||||
const Koa = require("koa");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { promisify } = require("util");
|
||||
const Router = require("@koa/router");
|
||||
const cors = require("@koa/cors");
|
||||
const formidable = require("formidable");
|
||||
const dayjs = require("dayjs");
|
||||
const { Server } = require("socket.io");
|
||||
const { createServer } = require("http");
|
||||
const { bot, getDisplayName } = require("./bot.js");
|
||||
const { bot, getDisplayName, cacheAvatars } = require("./bot.js");
|
||||
const { config } = require("./config.js");
|
||||
const {
|
||||
textSHA1,
|
||||
ensureAvatarDir,
|
||||
ensureUploadDir,
|
||||
composeAvatarFileName,
|
||||
composeMediaFilePathFromID,
|
||||
getOriginalMsgID,
|
||||
} = require("./util.js");
|
||||
|
||||
const DEFAULT_ROOM = "default_room";
|
||||
@@ -26,67 +28,65 @@ const io = new Server(httpServer, {
|
||||
path: "/socket.io",
|
||||
});
|
||||
const activeSessions = new Set();
|
||||
const socketIDToSessionID = new Map();
|
||||
|
||||
io.use((socket, next) => {
|
||||
const auth = socket.handshake.auth;
|
||||
if (!auth) {
|
||||
next(Error("认证失败"));
|
||||
return;
|
||||
return next(Error("认证失败"));
|
||||
}
|
||||
const appName = auth.appName;
|
||||
const appCode = config.appCode[appName];
|
||||
if (!appCode || appCode !== auth.appCode) {
|
||||
next(Error("认证失败"));
|
||||
return;
|
||||
return next(Error("认证失败"));
|
||||
}
|
||||
if (textSHA1(auth.passphrase) !== config.appAuthHash[appName]) {
|
||||
next(Error("口令错误"));
|
||||
return;
|
||||
return next(Error("口令错误"));
|
||||
}
|
||||
if (activeSessions.has(socket.handshake.auth.sessionID)) {
|
||||
return next(Error("会话已存在"));
|
||||
}
|
||||
if (bot?.state !== bot.CONF.STATE.login) {
|
||||
next(Error("服务不可用"));
|
||||
return;
|
||||
return next(Error("服务不可用"));
|
||||
}
|
||||
next().then(() => {
|
||||
bot.sendText(
|
||||
`【上线通知】
|
||||
DATETIME: ${dayjs().format("YYYY-MM-DDTHH:MM:ss")}
|
||||
APP_NAME: ${appName}
|
||||
PLATFORM: ${auth.platform || ""}`,
|
||||
"filehelper"
|
||||
);
|
||||
next();
|
||||
const text = `【上线通知】${"\n"}⌜${appName}⌟ on ${
|
||||
auth.platform
|
||||
} connected at ${dayjs().format("YYYY-MM-DDTHH:MM:ss")}`;
|
||||
bot.sendText(text, "filehelper");
|
||||
pushMessage({
|
||||
messageID: "notice_" + (Date.now() + Math.random()),
|
||||
createTimestamp: Math.trunc(Date.now() / 1000),
|
||||
type: "text",
|
||||
fromID: "filehelper",
|
||||
fromName: "你自己",
|
||||
fromNickName: "你自己",
|
||||
toID: bot.user.UserName,
|
||||
toName: "你自己",
|
||||
toNickName: bot.user.NickName,
|
||||
isGroupMessage: false,
|
||||
isSentBySelf: false,
|
||||
content: text,
|
||||
});
|
||||
});
|
||||
|
||||
function deactivateSession() {
|
||||
activeSessions.delete(socket.id);
|
||||
function deactivateSession(socket) {
|
||||
activeSessions.delete(socket.handshake.auth.sessionID);
|
||||
socketIDToSessionID.delete(socket.id);
|
||||
}
|
||||
|
||||
async function pushMessage(message) {
|
||||
if (message.filename) {
|
||||
for (const sid of await io.allSockets()) {
|
||||
const msg = {
|
||||
...message,
|
||||
fileUrl: `${config.baseUrl}/session/${sid}/media/${message.composedID}/${message.filename}`,
|
||||
};
|
||||
io.to(sid).emit("message", msg);
|
||||
}
|
||||
} else {
|
||||
io.in(DEFAULT_ROOM).emit("message", message);
|
||||
}
|
||||
}
|
||||
|
||||
function login(sessionID) {
|
||||
function login(socket) {
|
||||
const u = bot.user;
|
||||
return {
|
||||
id: u.UserName,
|
||||
nickname: u.NickName,
|
||||
displayName: u.NickName,
|
||||
signature: u.Signature,
|
||||
avatar: `${config.baseUrl}/session/${sessionID}/avatar/${u.UserName}`,
|
||||
avatar: `${config.baseUrl}/session/${socketIDToSessionID.get(
|
||||
socket.id
|
||||
)}/avatar/${u.UserName}`,
|
||||
};
|
||||
}
|
||||
function getContacts(sessionID) {
|
||||
function getContacts(socket) {
|
||||
function wrapContact(contact) {
|
||||
return {
|
||||
id: contact.UserName,
|
||||
@@ -95,54 +95,73 @@ function getContacts(sessionID) {
|
||||
signature: contact.Signature,
|
||||
nameInitial: contact.PYInitial || contact.RemarkPYInitial,
|
||||
nameQuanPin: contact.PYQuanPin || contact.RemarkPYQuanPin,
|
||||
avatar: `${config.baseUrl}/session/${sessionID}/avatar/${contact.UserName}`,
|
||||
isFriend: contact.ContactFlag === 1,
|
||||
avatar: `${config.baseUrl}/session/${socketIDToSessionID.get(
|
||||
socket.id
|
||||
)}/avatar/${contact.UserName}`,
|
||||
type: null,
|
||||
};
|
||||
}
|
||||
const data = { contacts: [], specialContacts: [], groups: [] };
|
||||
for (const uid of Object.keys(bot.contacts)) {
|
||||
const contact = bot.contacts[uid];
|
||||
const wrapped = wrapContact(contact);
|
||||
if (bot.Contact.isPublicContact(contact)) {
|
||||
continue;
|
||||
} else if (bot.Contact.isSpContact(contact)) {
|
||||
if (["filehelper"].includes(contact.UserName)) {
|
||||
data.specialContacts.push(wrapContact(contact));
|
||||
wrapped.type = "special";
|
||||
data.specialContacts.push(wrapped);
|
||||
}
|
||||
continue;
|
||||
} else if (bot.Contact.isRoomContact(contact)) {
|
||||
wrapped.type = "group";
|
||||
data.groups.push(wrapContact(contact));
|
||||
} else if (!contact.isSelf) {
|
||||
wrapped.type = "friend";
|
||||
data.contacts.push(wrapContact(contact));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
async function sendMessage({ type, target, filename, fileUri, data }) {
|
||||
async function sendMessage(_, { type, target, data, filename }) {
|
||||
const targetContact = bot.contacts[target];
|
||||
if (!targetContact) {
|
||||
return "联系人不存在";
|
||||
}
|
||||
const to = targetContact.UserName;
|
||||
let resp;
|
||||
switch (type) {
|
||||
case "text":
|
||||
await bot.sendText(data, target);
|
||||
resp = await bot.sendText(data, target);
|
||||
break;
|
||||
case "emotion":
|
||||
await bot.sendEmoticon(data, to);
|
||||
resp = await bot.sendEmoticon(data, target);
|
||||
break;
|
||||
case "img":
|
||||
case "video":
|
||||
case "file": {
|
||||
const filepath = path.join(ensureUploadDir(), fileUri);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
const defaultFilenameMap = {
|
||||
img: "pic.jpg",
|
||||
video: "video.mp4",
|
||||
emotion: "pic.gif",
|
||||
file: "file",
|
||||
};
|
||||
const fileUri = data;
|
||||
const oldFilename = path.join(ensureUploadDir(), fileUri);
|
||||
const newFilename = path.join(
|
||||
ensureUploadDir(),
|
||||
filename || Date.now() + Math.random() + defaultFilenameMap[type]
|
||||
);
|
||||
if (!fs.existsSync(oldFilename)) {
|
||||
return "图片不存在";
|
||||
}
|
||||
let mediaId;
|
||||
try {
|
||||
fs.renameSync(oldFilename, newFilename);
|
||||
// NOTICE: uploadMedia函数会优先从file.path中取文件名
|
||||
const { mediaId: id } = await bot.uploadMedia(
|
||||
fs.createReadStream(filepath),
|
||||
filename,
|
||||
targetContact.UserName
|
||||
fs.createReadStream(newFilename),
|
||||
undefined,
|
||||
target
|
||||
);
|
||||
mediaId = id;
|
||||
} catch {
|
||||
@@ -150,56 +169,56 @@ async function sendMessage({ type, target, filename, fileUri, data }) {
|
||||
}
|
||||
try {
|
||||
if (type === "img") {
|
||||
await bot.sendPic(mediaId, to);
|
||||
resp = await bot.sendPic(mediaId, target);
|
||||
} else if (type === "video") {
|
||||
await bot.sendVideo(mediaId, to);
|
||||
resp = await bot.sendVideo(mediaId, target);
|
||||
} else {
|
||||
await bot.sendDoc(
|
||||
resp = await bot.sendDoc(
|
||||
mediaId,
|
||||
filename,
|
||||
fs.statSync(filepath).size,
|
||||
path.extname(filepath),
|
||||
to
|
||||
fs.statSync(newFilename).size,
|
||||
path.extname(newFilename),
|
||||
target
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return e?.tips || e?.message || "消息发送失败";
|
||||
}
|
||||
// 上传后删除
|
||||
fs.rm(newFilename, () => {});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return "不支持的文件类型";
|
||||
}
|
||||
return {
|
||||
messageID: resp.MsgID,
|
||||
};
|
||||
}
|
||||
async function revokeMessage({ msgID, target }) {
|
||||
await bot.revokeMsg(msgID, target);
|
||||
}
|
||||
async function uploadFile({ data }) {
|
||||
const uploadDir = ensureUploadDir();
|
||||
const filename = dayjs().format("YYYYMMDD_") + String(Math.random()).slice(1);
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
await promisify(fs.writeFile)(filepath, data);
|
||||
// 10分钟后删除上传的临时文件
|
||||
setTimeout(() => {
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.rmSync(filepath);
|
||||
}
|
||||
}, 600000);
|
||||
return { localFileName: filename };
|
||||
}
|
||||
function handleNotImplemented(_, cb) {
|
||||
cb?.call(null, "Not Implemented");
|
||||
async function revokeMessage(_, { composedID, target }) {
|
||||
await bot.revokeMsg(getOriginalMsgID(composedID), target);
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("connected");
|
||||
activeSessions.add(socket.id);
|
||||
const sid = socket.handshake.auth.sessionID;
|
||||
activeSessions.add(sid);
|
||||
socketIDToSessionID.set(socket.id, sid);
|
||||
socket.join(DEFAULT_ROOM);
|
||||
socket.on("disconnect", deactivateSession);
|
||||
socket.on("login", login.bind(null, socket.id));
|
||||
socket.on("get_contacts", getContacts.bind(null, socket.id));
|
||||
socket.on("send_message", sendMessage);
|
||||
socket.on("revoke_message", revokeMessage);
|
||||
socket.on("upload_file", uploadFile);
|
||||
socket.onAny(handleNotImplemented);
|
||||
function wrapWithCallback(func) {
|
||||
return async function (data, callback) {
|
||||
const res = func.call(this, socket, data);
|
||||
if (res instanceof Promise) {
|
||||
callback && callback(await res);
|
||||
} else {
|
||||
callback && callback(res);
|
||||
}
|
||||
};
|
||||
}
|
||||
socket.on("disconnect", wrapWithCallback(deactivateSession));
|
||||
socket.on("login", wrapWithCallback(login));
|
||||
socket.on("get_contacts", wrapWithCallback(getContacts));
|
||||
socket.on("send_message", wrapWithCallback(sendMessage));
|
||||
socket.on("revoke_message", wrapWithCallback(revokeMessage));
|
||||
socket.use((_, next) => {
|
||||
if (bot?.state !== bot.CONF.STATE.login) {
|
||||
return next(Error("服务不可用"));
|
||||
@@ -220,20 +239,26 @@ router.get("/session/:sessionID/avatar/:userID", (ctx, next) => {
|
||||
const filename = composeAvatarFileName(contact);
|
||||
const filepath = filename && path.join(ensureAvatarDir(), filename);
|
||||
if (filepath && fs.existsSync(filepath)) {
|
||||
ctx.response.headers["Cache-Control"] = "max-age=3600";
|
||||
ctx.response.headers["Content-Type"] = "image/jpeg";
|
||||
ctx.response.body = fs.createReadStream(filepath);
|
||||
} else {
|
||||
cacheAvatars();
|
||||
ctx.throw(404);
|
||||
}
|
||||
next();
|
||||
});
|
||||
/** 获取消息文件,包含图片 */
|
||||
router.get("/session/:sessionID/media/:composedID/:filename", (ctx, next) => {
|
||||
if (!activeSessions.has(ctx.params.sessionID)) {
|
||||
ctx.throw(401);
|
||||
}
|
||||
const filename = composeMediaFilePathFromID(
|
||||
ctx.params.composedID,
|
||||
ctx.params.filename
|
||||
);
|
||||
if (fs.existsSync(filename)) {
|
||||
ctx.response.headers["Cache-Control"] = "max-age=3600";
|
||||
ctx.response.body = fs.createReadStream(filename);
|
||||
} else {
|
||||
ctx.throw(404);
|
||||
@@ -241,10 +266,57 @@ router.get("/session/:sessionID/media/:composedID/:filename", (ctx, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
/** 上传文件 */
|
||||
router.post("/upload_file", async (ctx, next) => {
|
||||
if (!activeSessions.has(ctx.request.headers.session_id)) {
|
||||
ctx.throw(401);
|
||||
}
|
||||
const filename = dayjs().format("YYYYMMDD_") + String(Math.random()).slice(2);
|
||||
const filepath = path.join(ensureUploadDir(), filename);
|
||||
const form = formidable({ multiples: false });
|
||||
form.on("fileBegin", (_, file) => {
|
||||
file.filepath = filepath;
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
form.parse(ctx.req, (err, _, files) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.type = "application/json";
|
||||
ctx.response.body = JSON.stringify({ tempFileName: filename });
|
||||
// 10分钟后删除上传的临时文件
|
||||
setTimeout(() => {
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.rmSync(filepath);
|
||||
}
|
||||
}, 600000);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(router.routes());
|
||||
httpServer.listen(config.httpPort, config.httpHost);
|
||||
|
||||
async function pushMessage(message) {
|
||||
if (message.filename) {
|
||||
for (const sid of await io.allSockets()) {
|
||||
const sessionID = socketIDToSessionID.get(sid);
|
||||
const msg = {
|
||||
...message,
|
||||
fileUrl: `${config.baseUrl}/session/${sessionID}/media/${message.messageID}/${message.filename}`,
|
||||
};
|
||||
io.to(DEFAULT_ROOM).emit("message", msg);
|
||||
}
|
||||
} else {
|
||||
io.in(DEFAULT_ROOM).emit("message", message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pushMessage,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ const { config } = require("./config");
|
||||
function composeMessageID(msg) {
|
||||
return dayjs.unix(msg["CreateTime"]).format("YYYYMMDD") + msg["MsgId"];
|
||||
}
|
||||
function getOriginalMsgID(composedID) {
|
||||
return composedID.slice(8);
|
||||
}
|
||||
function composeFilename(msg, suffix = "") {
|
||||
const dt = dayjs.unix(msg["CreateTime"]);
|
||||
const datetime = dt.format("YYYYMMDD");
|
||||
@@ -68,6 +71,7 @@ function textSHA1(text) {
|
||||
|
||||
module.exports = {
|
||||
composeMessageID,
|
||||
getOriginalMsgID,
|
||||
composeFilename,
|
||||
composeMediaFilePath,
|
||||
composeMediaFilePathFromID,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": "> 0.25%, not dead",
|
||||
"useBuiltIns": "entry",
|
||||
"corejs": 3,
|
||||
"modules": false
|
||||
}
|
||||
],
|
||||
"@babel/preset-react",
|
||||
"linaria/babel"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"prismjs",
|
||||
{
|
||||
"languages": [
|
||||
"clike",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"python",
|
||||
"ruby",
|
||||
"markup",
|
||||
"markup-templating",
|
||||
"php",
|
||||
"go",
|
||||
"csharp",
|
||||
"css",
|
||||
"sql",
|
||||
"json"
|
||||
],
|
||||
"plugins": ["line-numbers", "copy-to-clipboard", "show-language"],
|
||||
"theme": "default",
|
||||
"css": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/
|
||||
build/
|
||||
docs/
|
||||
*.d.ts
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"extends": ["eslint-config-airbnb", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json",
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-a11y", "import", "jest"],
|
||||
"globals": {
|
||||
"importScripts": true,
|
||||
"workbox": true,
|
||||
"__TEST__": true
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": 2,
|
||||
"global-require": 0,
|
||||
"implicit-arrow-linebreak": 0,
|
||||
"import/extensions": [
|
||||
2,
|
||||
{
|
||||
"ts": "never",
|
||||
"tsx": "never",
|
||||
"js": "never",
|
||||
"jsx": "never"
|
||||
}
|
||||
],
|
||||
"indent": [
|
||||
2,
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
"jsx-a11y/interactive-supports-focus": 0,
|
||||
"jsx-a11y/no-noninteractive-element-interactions": 0,
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": 0,
|
||||
"no-param-reassign": 0,
|
||||
"no-plusplus": 0,
|
||||
"no-script-url": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"object-curly-newline": 0,
|
||||
"react/jsx-filename-extension": [
|
||||
2,
|
||||
{
|
||||
"extensions": [".js", ".jsx", ".tsx"]
|
||||
}
|
||||
],
|
||||
"react/jsx-indent": [2, 4],
|
||||
"react/jsx-indent-props": [2, 4],
|
||||
"react/jsx-props-no-spreading": 0,
|
||||
"react/jsx-one-expression-per-line": 0,
|
||||
"react/static-property-placement": 0,
|
||||
"react-hooks/rules-of-hooks": 2,
|
||||
"react-hooks/exhaustive-deps": 0,
|
||||
"react/require-default-props": [2, { "ignoreFunctionalComponents": true }],
|
||||
"import/prefer-default-export": 0,
|
||||
"prefer-promise-reject-errors": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# fiora-chat
|
||||
wxbot的客户端,UI架子fork自[fiora](https://github.com/yinxin630/fiora)。
|
||||
|
||||
|
||||
## 配置
|
||||
编译前修改`src/@fiora/config/client.ts`中的`app_name`和`app_code`字段,并在服务端做相应配置允许该`app`登录。
|
||||
@@ -0,0 +1,131 @@
|
||||
const path = require('path');
|
||||
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const LessPluginAutoPrefix = require('less-plugin-autoprefix');
|
||||
const Dotenv = require('dotenv-webpack');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: path.resolve(__dirname, '../src/main.tsx'),
|
||||
},
|
||||
output: {
|
||||
filename: 'js/[name].[chunkhash:8].js',
|
||||
path: path.resolve(__dirname, '../dist/fiora'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
|
||||
alias: {
|
||||
'@fiora': path.resolve(__dirname, '../src/@fiora/'),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
plugins: [
|
||||
...(process.env.NODE_ENV === 'development'
|
||||
? [require.resolve('react-refresh/babel')]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'linaria/loader',
|
||||
options: {
|
||||
sourceMap: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.less$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
importLoaders: 2,
|
||||
modules: {
|
||||
localIdentName: '[local]--[hash:base64:5]',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'less-loader',
|
||||
options: {
|
||||
lessOptions: {
|
||||
plugins: [
|
||||
new LessPluginAutoPrefix({
|
||||
enable: true,
|
||||
options: {
|
||||
browsers: ['last 3 versions'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
sourceMap: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 4096,
|
||||
name: 'images/[name].[ext]',
|
||||
esModule: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 4096,
|
||||
name: 'fonts/[name].[hash:8].[ext]',
|
||||
esModule: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(mp3)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 4096,
|
||||
name: 'audios/[name].[hash:8].[ext]',
|
||||
esModule: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.resolve(__dirname, '../src/template.html'),
|
||||
inject: true,
|
||||
}),
|
||||
new CleanWebpackPlugin(),
|
||||
new Dotenv({
|
||||
silent: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const path = require('path');
|
||||
const { merge } = require('webpack-merge');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
const common = require('./webpack.common.js');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'development',
|
||||
output: {
|
||||
publicPath: '/',
|
||||
},
|
||||
devtool: 'inline-source-map',
|
||||
devServer: {
|
||||
hot: true,
|
||||
contentBase: ['./dist'],
|
||||
historyApiFallback: {
|
||||
rewrites: [{ from: /\/invite\/group\/[\w\d]+/, to: '/index.html' }],
|
||||
},
|
||||
proxy: {
|
||||
'/avatar': 'http://localhost:9200',
|
||||
'/GroupAvatar': 'http://localhost:9200',
|
||||
'/Avatar': {
|
||||
target: 'http://localhost:9200',
|
||||
pathRewrite: { '^/Avatar': '/avatar' },
|
||||
},
|
||||
'/favicon-*.png': 'http://localhost:9200',
|
||||
},
|
||||
},
|
||||
plugins: [new ReactRefreshWebpackPlugin()],
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { merge } = require('webpack-merge');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');
|
||||
const WorkboxPlugin = require('workbox-webpack-plugin');
|
||||
const WebpackBar = require('webpackbar');
|
||||
const common = require('./webpack.common.js');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'production',
|
||||
output: {
|
||||
publicPath: process.env.PublicPath || '/',
|
||||
},
|
||||
devtool: false,
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
format: {
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
extractComments: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ScriptExtHtmlPlugin({
|
||||
custom: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
attribute: 'crossorigin',
|
||||
value: 'anonymous',
|
||||
},
|
||||
],
|
||||
}),
|
||||
new WorkboxPlugin.GenerateSW({
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
}),
|
||||
new WebpackBar(),
|
||||
],
|
||||
});
|
||||
Generated
+21261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@fiora/web",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development webpack serve --config build/webpack.dev.js",
|
||||
"build": "rm -rf dist && cross-env NODE_ENV=production webpack --config build/webpack.prod.js",
|
||||
"build-relative": "rm -rf dist && cross-env NODE_ENV=production PublicPath=./ webpack --config build/webpack.prod.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@loadable/component": "^5.15.0",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"brace": "^0.11.1",
|
||||
"core-js": "^3.15.2",
|
||||
"cropperjs": "^1.5.5",
|
||||
"dayjs": "^1.10.7",
|
||||
"filesize": "^7.0.0",
|
||||
"linaria": "^2.1.0",
|
||||
"log4js": "^6.3.0",
|
||||
"multer": "^1.4.3",
|
||||
"normalize.css": "^8.0.1",
|
||||
"platform": "^1.3.6",
|
||||
"prismjs": "^1.24.1",
|
||||
"pure-render-decorator": "^1.2.1",
|
||||
"qrcode.react": "^1.0.1",
|
||||
"randomcolor": "^0.6.2",
|
||||
"rc-dialog": "^8.5.2",
|
||||
"rc-dropdown": "^3.2.0",
|
||||
"rc-menu": "^9.0.12",
|
||||
"rc-notification": "^3.3.1",
|
||||
"rc-progress": "^2.5.2",
|
||||
"rc-select": "^9.2.1",
|
||||
"rc-tabs": "^9.6.4",
|
||||
"rc-tooltip": "^5.1.1",
|
||||
"react": "^17.0.2",
|
||||
"react-ace": "^9.4.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-copy-to-clipboard": "^5.0.3",
|
||||
"react-cropper": "^1.3.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-loading": "^2.0.3",
|
||||
"react-radio-buttons": "^1.2.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-switch": "^6.0.0",
|
||||
"react-viewer": "^3.2.2",
|
||||
"redux": "^4.1.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"wpk-reporter": "^0.9.3",
|
||||
"xss": "^1.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/preset-env": "^7.14.7",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@types/ali-oss": "^6.0.10",
|
||||
"@types/loadable__component": "^5.13.4",
|
||||
"@types/platform": "^1.3.4",
|
||||
"@types/prismjs": "^1.16.6",
|
||||
"@types/pure-render-decorator": "^0.2.28",
|
||||
"@types/qrcode.react": "^1.0.2",
|
||||
"@types/react": "^17.0.14",
|
||||
"@types/react-color": "^3.0.5",
|
||||
"@types/react-copy-to-clipboard": "^5.0.1",
|
||||
"@types/react-cropper": "^0.10.7",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-redux": "^7.1.18",
|
||||
"@types/redux": "^3.6.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-prismjs": "^2.1.0",
|
||||
"clean-webpack-plugin": "^4.0.0-alpha.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^5.0.0",
|
||||
"dotenv-webpack": "^7.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"less": "^3.12.2",
|
||||
"less-loader": "^7.0.2",
|
||||
"less-plugin-autoprefix": "^2.0.0",
|
||||
"react-refresh": "^0.10.0",
|
||||
"script-ext-html-webpack-plugin": "^2.1.5",
|
||||
"style-loader": "^2.0.0",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"ts-loader": "^9.2.3",
|
||||
"typescript": "^4.4.4",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.45.1",
|
||||
"webpack-cli": "^4.7.2",
|
||||
"webpack-dev-server": "^3.11.2",
|
||||
"webpack-merge": "^5.8.0",
|
||||
"webpackbar": "^5.0.0-3",
|
||||
"workbox-webpack-plugin": "^6.1.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,39 @@
|
||||
import { MB } from '../utils/const';
|
||||
|
||||
export default {
|
||||
app_name: 'mattuy-laptop',
|
||||
app_code: 'iasdjfkwe3ds-qw3rjkwjfda8',
|
||||
server:
|
||||
process.env.Server ||
|
||||
(process.env.NODE_ENV === 'development' ? '//localhost:9200/' : '/'),
|
||||
|
||||
maxImageSize: process.env.MaxImageSize
|
||||
? parseInt(process.env.MaxImageSize, 10)
|
||||
: MB * 5,
|
||||
maxBackgroundImageSize: process.env.MaxBackgroundImageSize
|
||||
? parseInt(process.env.MaxBackgroundImageSize, 10)
|
||||
: MB * 5,
|
||||
maxAvatarSize: process.env.MaxAvatarSize
|
||||
? parseInt(process.env.MaxAvatarSize, 10)
|
||||
: MB * 1.5,
|
||||
maxFileSize: process.env.MaxFileSize
|
||||
? parseInt(process.env.MaxFileSize, 10)
|
||||
: MB * 10,
|
||||
|
||||
// client default system setting
|
||||
defaultTheme: process.env.DefaultTheme || 'cool',
|
||||
sound: process.env.Sound || 'default',
|
||||
tagColorMode: process.env.TagColorMode || 'fixedColor',
|
||||
|
||||
/**
|
||||
* 前端监控: https://yueying.effirst.com/index
|
||||
* 值为监控应用id, 为空则不启用监控
|
||||
*/
|
||||
frontendMonitorAppId: process.env.FrontendMonitorAppId || '',
|
||||
|
||||
// 禁止用户撤回消息, 不包括管理员, 管理员始终能撤回任何消息
|
||||
// 默认是禁止的
|
||||
disableDeleteMessage: process.env.DisableDeleteMessage
|
||||
? process.env.DisableDeleteMessage === 'true'
|
||||
: false,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 压缩图片
|
||||
* @param image 要压缩的图片
|
||||
* @param mimeType mime类型
|
||||
* @param quality 质量
|
||||
*/
|
||||
export default function compressImage(
|
||||
image: HTMLImageElement,
|
||||
mimeType: string,
|
||||
quality = 1,
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
canvas.toBlob(resolve, mimeType, quality);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** 封禁后提示文案 */
|
||||
export const SEAL_TEXT = '你已经被关进小黑屋中, 请反思后再试';
|
||||
|
||||
/** 封禁用户释放时间 */
|
||||
export const SEAL_USER_TIMEOUT = 1000 * 60 * 10; // 10分钟
|
||||
|
||||
/** 封禁ip释放时间 */
|
||||
export const SEAL_IP_TIMEOUT = 1000 * 60 * 60 * 6; // 6小时
|
||||
|
||||
/** 透明图 */
|
||||
export const TRANSPARENT_IMAGE =
|
||||
'data:image/png;base64,R0lGODlhFAAUAIAAAP///wAAACH5BAEAAAAALAAAAAAUABQAAAIRhI+py+0Po5y02ouz3rz7rxUAOw==';
|
||||
|
||||
/** 加密salt位数 */
|
||||
export const SALT_ROUNDS = 10;
|
||||
|
||||
export const MB = 1024 * 1024;
|
||||
|
||||
export const NAME_REGEXP = /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/;
|
||||
@@ -0,0 +1,54 @@
|
||||
export default {
|
||||
default: [
|
||||
'呵呵',
|
||||
'哈哈',
|
||||
'吐舌',
|
||||
'啊',
|
||||
'酷',
|
||||
'怒',
|
||||
'开心',
|
||||
'汗',
|
||||
'泪',
|
||||
'黑线',
|
||||
'鄙视',
|
||||
'不高兴',
|
||||
'真棒',
|
||||
'钱',
|
||||
'疑问',
|
||||
'阴险',
|
||||
'吐',
|
||||
'咦',
|
||||
'委屈',
|
||||
'花心',
|
||||
'呼',
|
||||
'笑眼',
|
||||
'冷',
|
||||
'太开心',
|
||||
'滑稽',
|
||||
'勉强',
|
||||
'狂汗',
|
||||
'乖',
|
||||
'睡觉',
|
||||
'惊哭',
|
||||
'升起',
|
||||
'惊讶',
|
||||
'喷',
|
||||
'爱心',
|
||||
'心碎',
|
||||
'玫瑰',
|
||||
'礼物',
|
||||
'星星月亮',
|
||||
'太阳',
|
||||
'音乐',
|
||||
'灯泡',
|
||||
'蛋糕',
|
||||
'彩虹',
|
||||
'钱币',
|
||||
'咖啡',
|
||||
'haha',
|
||||
'胜利',
|
||||
'大拇指',
|
||||
'弱',
|
||||
'ok',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Combina two users id as frind id
|
||||
* The result has nothing to do with the order of the parameters
|
||||
* @param userId1 user id
|
||||
* @param userId2 user id
|
||||
*/
|
||||
export default function getFriendId(userId1: string, userId2: string) {
|
||||
if (userId1 < userId2) {
|
||||
return userId1 + userId2;
|
||||
}
|
||||
return userId2 + userId1;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const AvatarCount = 15;
|
||||
const publicPath = process.env.PublicPath || '/';
|
||||
|
||||
/**
|
||||
* 获取随机头像
|
||||
*/
|
||||
export default function getRandomAvatar() {
|
||||
const number = Math.floor(Math.random() * AvatarCount);
|
||||
return `${publicPath}avatar/${number}.jpg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认头像
|
||||
*/
|
||||
export function getDefaultAvatar() {
|
||||
return `${publicPath}avatar/0.jpg`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import randomColor from 'randomcolor';
|
||||
|
||||
type ColorMode = 'dark' | 'bright' | 'light' | 'random';
|
||||
|
||||
/**
|
||||
* 获取随机颜色, 刷新页面不变
|
||||
* @param seed when passed will cause randomColor to return the same color each time
|
||||
*/
|
||||
export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') {
|
||||
return randomColor({
|
||||
luminosity,
|
||||
seed,
|
||||
});
|
||||
}
|
||||
|
||||
type Cache = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
const cache: Cache = {};
|
||||
|
||||
/**
|
||||
* 获取随机颜色, 刷新页面后重新随机
|
||||
* @param seed 随机种子
|
||||
* @param luminosity 亮度
|
||||
*/
|
||||
export function getPerRandomColor(
|
||||
seed: string,
|
||||
luminosity: ColorMode = 'dark',
|
||||
) {
|
||||
if (cache[seed]) {
|
||||
return cache[seed];
|
||||
}
|
||||
cache[seed] = randomColor({ luminosity });
|
||||
return cache[seed];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getLogger } from 'log4js';
|
||||
|
||||
const logger = getLogger();
|
||||
logger.level = process.env.NODE_ENV === 'development' ? 'trace' : 'info';
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function sleep(duration = 200) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, duration);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
export function getSocketIp(socket: Socket) {
|
||||
return (
|
||||
(socket.handshake.headers['x-real-ip'] as string) ||
|
||||
socket.request.connection.remoteAddress ||
|
||||
''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import getFriendId from '../getFriendId';
|
||||
|
||||
describe('utils/getFriendId.ts', () => {
|
||||
it('should combina two users id as friend id', () => {
|
||||
const user1 = '111';
|
||||
const user2 = '222';
|
||||
expect(getFriendId(user1, user2)).toBe('111222');
|
||||
expect(getFriendId(user2, user1)).toBe('111222');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { addParam } from '../url';
|
||||
|
||||
describe('utils/url.ts', () => {
|
||||
it('should add ?key=value into url', () => {
|
||||
const url = 'https://fiora.suisuijiang.com';
|
||||
const key = 'key';
|
||||
const value = 'value';
|
||||
const params = {
|
||||
[key]: value,
|
||||
};
|
||||
expect(addParam(url, params)).toBe(`${url}?${key}=${value}`);
|
||||
});
|
||||
|
||||
it('should add &key=value into url', () => {
|
||||
const url = 'https://fiora.suisuijiang.com?a=a';
|
||||
const key = 'key';
|
||||
const value = 'value';
|
||||
const params = {
|
||||
[key]: value,
|
||||
};
|
||||
expect(addParam(url, params)).toBe(`${url}&${key}=${value}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export default {
|
||||
isToday(time1: Date, time2: Date) {
|
||||
return (
|
||||
time1.getFullYear() === time2.getFullYear() &&
|
||||
time1.getMonth() === time2.getMonth() &&
|
||||
time1.getDate() === time2.getDate()
|
||||
);
|
||||
},
|
||||
isYesterday(time1: Date, time2: Date) {
|
||||
const prevDate = new Date(time1);
|
||||
prevDate.setDate(time1.getDate() - 1);
|
||||
return (
|
||||
prevDate.getFullYear() === time2.getFullYear() &&
|
||||
prevDate.getMonth() === time2.getMonth() &&
|
||||
prevDate.getDate() === time2.getDate()
|
||||
);
|
||||
},
|
||||
getHourMinute(time: Date) {
|
||||
const hours = time.getHours();
|
||||
const minutes = time.getMinutes();
|
||||
return `${hours < 10 ? `0${hours}` : hours}:${
|
||||
minutes < 10 ? `0${minutes}` : minutes
|
||||
}`;
|
||||
},
|
||||
getMonthDate(time: Date) {
|
||||
return `${time.getMonth() + 1}/${time.getDate()}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const UA = window.navigator.userAgent;
|
||||
|
||||
export const isiOS = /iPhone/i.test(UA);
|
||||
|
||||
export const isAndroid = /android/i.test(UA);
|
||||
|
||||
export const isMobile = isiOS || isAndroid;
|
||||
@@ -0,0 +1,16 @@
|
||||
interface UrlParams {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function addParam(url: string, params: UrlParams) {
|
||||
let result = url;
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (result.indexOf('?') === -1) {
|
||||
result += `?${key}=${params[key]}`;
|
||||
} else {
|
||||
result += `&${key}=${params[key]}`;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import xss from 'xss';
|
||||
|
||||
/**
|
||||
* xss防护
|
||||
* @param text 要处理的文字
|
||||
*/
|
||||
export default function processXss(text: string) {
|
||||
return xss(text);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@import "./styles/variable.less";
|
||||
|
||||
.app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blur, .child {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.blur {
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.child {
|
||||
display: flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 0px 60px rgba(0, 0, 0, 0.5);
|
||||
|
||||
@media @mobile {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { isMobile } from '@fiora/utils/ua';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ActiveContactList from './modules/ActiveContactList/ActiveContactList';
|
||||
import Chat from './modules/Chat/Chat';
|
||||
import FunctionBarAndContactList from './modules/FunctionBarAndContactList/FunctionBarAndContactList';
|
||||
import Login from './modules/Login/Login';
|
||||
import Sidebar from './modules/Sidebar/Sidebar';
|
||||
import Style from './App.less';
|
||||
import globalStyles from './globalStyles';
|
||||
import inobounce from './utils/inobounce';
|
||||
import { ShowUserOrGroupInfoContext } from './context';
|
||||
import { State } from './state/reducer';
|
||||
import './styles/iconfont.less';
|
||||
import './styles/normalize.less';
|
||||
|
||||
/**
|
||||
* 获取窗口宽度百分比
|
||||
*/
|
||||
function getWidthPercent() {
|
||||
let width = 0.6;
|
||||
if (isMobile) {
|
||||
width = 1;
|
||||
} else if (window.innerWidth < 1000) {
|
||||
width = 0.9;
|
||||
} else if (window.innerWidth < 1300) {
|
||||
width = 0.8;
|
||||
} else if (window.innerWidth < 1600) {
|
||||
width = 0.7;
|
||||
} else {
|
||||
width = 0.6;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取窗口高度百分比
|
||||
*/
|
||||
function getHeightPercent() {
|
||||
let height = 0.8;
|
||||
if (isMobile) {
|
||||
height = 1;
|
||||
} else if (window.innerHeight < 1000) {
|
||||
height = 0.9;
|
||||
} else {
|
||||
height = 0.8;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const backgroundImageUrl = useSelector(
|
||||
(state: State) => state.status.backgroundImage,
|
||||
);
|
||||
const sidebarTab = useSelector((state: State) => state.sideBarTabState);
|
||||
const backgroundImage = backgroundImageUrl;
|
||||
const $app = useRef(null);
|
||||
|
||||
// 计算窗口高度/宽度百分比
|
||||
const [width, setWidth] = useState(getWidthPercent());
|
||||
const [height, setHeight] = useState(getHeightPercent());
|
||||
useEffect(() => {
|
||||
window.onresize = () => {
|
||||
setWidth(getWidthPercent());
|
||||
setHeight(getHeightPercent());
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
inobounce($app.current);
|
||||
}, []);
|
||||
|
||||
// 获取底图尺寸
|
||||
const [backgroundWidth, setBackgroundWidth] = useState(window.innerWidth);
|
||||
const [backgroundHeight, setBackgroundHeight] = useState(
|
||||
window.innerHeight,
|
||||
);
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setBackgroundWidth(Math.max(img.width, window.innerWidth));
|
||||
setBackgroundHeight(Math.max(img.height, window.innerHeight));
|
||||
};
|
||||
img.src = backgroundImage;
|
||||
}, [backgroundImage]);
|
||||
|
||||
// 主体样式
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundSize: `${backgroundWidth}px ${backgroundHeight}px`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}),
|
||||
[backgroundImage, backgroundWidth, backgroundHeight],
|
||||
);
|
||||
|
||||
// 聊天窗口样式
|
||||
const childStyle = useMemo(
|
||||
() => ({
|
||||
width: `${width * 100}%`,
|
||||
height: `${height * 100}%`,
|
||||
left: `${((1 - width) / 2) * 100}%`,
|
||||
top: `${((1 - height) / 2) * 100}%`,
|
||||
}),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
// 模糊背景样式
|
||||
const blurStyle = useMemo(
|
||||
() => ({
|
||||
backgroundPosition: `${(-(1 - width) * window.innerWidth) / 2}px ${
|
||||
(-(1 - height) * window.innerHeight) / 2
|
||||
}px`,
|
||||
...style,
|
||||
...childStyle,
|
||||
}),
|
||||
[width, height, style, childStyle],
|
||||
);
|
||||
|
||||
const [userInfoDialog, toggleUserInfoDialog] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState(null);
|
||||
|
||||
const [groupInfoDialog, toggleGroupInfoDialog] = useState(false);
|
||||
const [groupInfo, setGroupInfo] = useState(null);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
showUserInfo(user: any) {
|
||||
setUserInfo(user);
|
||||
toggleUserInfoDialog(true);
|
||||
},
|
||||
showGroupInfo(group: any) {
|
||||
setGroupInfo(group);
|
||||
toggleGroupInfoDialog(true);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${Style.app} ${globalStyles}`}
|
||||
style={style}
|
||||
ref={$app}
|
||||
>
|
||||
<div className={Style.blur} style={blurStyle} />
|
||||
<div className={Style.child} style={childStyle}>
|
||||
<ShowUserOrGroupInfoContext.Provider
|
||||
value={(contextValue as unknown) as null}
|
||||
>
|
||||
<Sidebar />
|
||||
{sidebarTab === 'message' ? (
|
||||
<ActiveContactList />
|
||||
) : sidebarTab === 'contact' ? (
|
||||
<FunctionBarAndContactList />
|
||||
) : null}
|
||||
<Chat />
|
||||
</ShowUserOrGroupInfoContext.Provider>
|
||||
</div>
|
||||
<Login />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { SyntheticEvent, useMemo, useState } from 'react';
|
||||
|
||||
export const avatarFailback = '/avatar/0.jpg';
|
||||
|
||||
type Props = {
|
||||
/** 头像链接 */
|
||||
src: string;
|
||||
/** 展示大小 */
|
||||
size?: number;
|
||||
/** 额外类名 */
|
||||
className?: string;
|
||||
/** 点击事件 */
|
||||
onClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
};
|
||||
|
||||
function Avatar({
|
||||
src,
|
||||
size = 60,
|
||||
className = '',
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: Props) {
|
||||
const [failTimes, updateFailTimes] = useState(0);
|
||||
|
||||
/**
|
||||
* Handle avatar load fail event. Use faillback avatar instead
|
||||
* If still fail then ignore error event
|
||||
*/
|
||||
function handleError(e: SyntheticEvent<HTMLImageElement>) {
|
||||
if (failTimes >= 2) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.src = avatarFailback;
|
||||
updateFailTimes(failTimes + 1);
|
||||
}
|
||||
|
||||
const url = src;
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
style={{ width: size, height: size, borderRadius: 4 }}
|
||||
src={url}
|
||||
alt=""
|
||||
onClick={onClick}
|
||||
onError={handleError}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
import { css } from 'linaria';
|
||||
|
||||
const button = css`
|
||||
border: none;
|
||||
background-color: var(--primary-color-8_5);
|
||||
color: var(--primary-text-color-10);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.4s;
|
||||
user-select: none !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color-10);
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
/** 类型: primary / danger */
|
||||
type?: string;
|
||||
/** 按钮文本 */
|
||||
children: string;
|
||||
className?: string;
|
||||
/** 点击事件 */
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function Button({
|
||||
type = 'primary',
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
className={`${button} ${type} ${className}`}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,47 @@
|
||||
@import '../styles/variable.less';
|
||||
|
||||
:global {
|
||||
.rc-dialog {
|
||||
width: 450px;
|
||||
top: 45% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
.rc-dialog-content {
|
||||
width: 100%;
|
||||
}
|
||||
.rc-dialog-title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.rc-dialog-wrap {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.rc-dialog-close {
|
||||
top: 0 !important;
|
||||
right: 10px !important;
|
||||
z-index: 9999;
|
||||
|
||||
.rc-dialog-close-x {
|
||||
font-size: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.rc-dialog-body {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
.rc-dialog {
|
||||
width: 94% !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.rc-dialog-body {
|
||||
padding: 10px 10px;
|
||||
// max-height: 88%;
|
||||
}
|
||||
.rc-dialog-content {
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Dialog from 'rc-dialog';
|
||||
import 'rc-dialog/assets/index.css';
|
||||
|
||||
import './Dialog.less';
|
||||
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,9 @@
|
||||
:global {
|
||||
.rc-dropdown {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rc-select-dropdown {
|
||||
z-index: 1500 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Dropdown from 'rc-dropdown';
|
||||
import 'rc-dropdown/assets/index.css';
|
||||
|
||||
import './Dropdown.less';
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,9 @@
|
||||
.iconButton {
|
||||
text-align: center;
|
||||
color: rgba(165, 181, 192, 1);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: rgba(247, 247, 247, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
import Style from './IconButton.less';
|
||||
|
||||
type Props = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
icon: string;
|
||||
iconSize: number;
|
||||
className?: string;
|
||||
style?: Object;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function IconButton({
|
||||
width,
|
||||
height,
|
||||
icon,
|
||||
iconSize,
|
||||
onClick = () => {},
|
||||
className = '',
|
||||
style = {},
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`${Style.iconButton} ${className}`}
|
||||
style={{ width, height, ...style }}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
>
|
||||
<i
|
||||
className={`iconfont icon-${icon}`}
|
||||
style={{ fontSize: iconSize, lineHeight: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconButton;
|
||||
@@ -0,0 +1,31 @@
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
padding: 0 34px 0 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
box-sizing: border-box;
|
||||
user-select: auto;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color-10);
|
||||
}
|
||||
}
|
||||
|
||||
.inputIconButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
right: 5px;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, {
|
||||
Ref,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import IconButton from './IconButton';
|
||||
import Style from './Input.less';
|
||||
|
||||
interface InputProps {
|
||||
value?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
clearable?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onEnter?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
const Input = forwardRef((props: InputProps, ref) => {
|
||||
const $input = useRef(null);
|
||||
const {
|
||||
value,
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
className = '',
|
||||
onChange,
|
||||
clearable,
|
||||
autoFocus,
|
||||
onEnter = () => {},
|
||||
onFocus = () => {},
|
||||
} = props;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
// @ts-ignore
|
||||
focus: () => $input.current.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
function handleInput(e: any) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
|
||||
const [lockEnter, setLockEnter] = useState(false);
|
||||
function handleIMEStart() {
|
||||
setLockEnter(true);
|
||||
}
|
||||
function handleIMEEnd() {
|
||||
setLockEnter(false);
|
||||
}
|
||||
function handleKeyDown(e: any) {
|
||||
if (lockEnter) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
onEnter(value as string);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickClear() {
|
||||
onChange('');
|
||||
// @ts-ignore
|
||||
$input.current.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${Style.inputContainer} ${className}`}>
|
||||
<input
|
||||
className={Style.input}
|
||||
type={type}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
onInput={handleInput}
|
||||
placeholder={placeholder}
|
||||
ref={$input}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={handleIMEStart}
|
||||
onCompositionEnd={handleIMEEnd}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
{clearable !== false && (
|
||||
<IconButton
|
||||
className={Style.inputIconButton}
|
||||
width={32}
|
||||
height={32}
|
||||
iconSize={18}
|
||||
icon="clear"
|
||||
onClick={handleClickClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Loading from 'react-loading';
|
||||
|
||||
export default Loading;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Menu, { SubMenu, MenuItem } from 'rc-menu';
|
||||
import 'rc-menu/assets/index.css';
|
||||
|
||||
export { Menu, MenuItem, SubMenu };
|
||||
@@ -0,0 +1,37 @@
|
||||
:global {
|
||||
.rc-notification {
|
||||
top: 5px !important;
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.componentMessage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1px;
|
||||
|
||||
:global {
|
||||
.iconfont {
|
||||
font-size: 22px;
|
||||
|
||||
&.icon-success {
|
||||
color: green;
|
||||
}
|
||||
&.icon-error {
|
||||
color: #d82e2e;
|
||||
}
|
||||
&.icon-warning {
|
||||
color: orange;
|
||||
}
|
||||
&.icon-info {
|
||||
color: #2773ef;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin-left: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Notification from 'rc-notification';
|
||||
|
||||
import 'rc-notification/dist/rc-notification.min.css';
|
||||
import Style from './Message.less';
|
||||
|
||||
function showMessage(text: string, duration = 1500, type = 'success') {
|
||||
Notification.newInstance({}, (notification: any) => {
|
||||
notification.notice({
|
||||
content: (
|
||||
<div className={Style.componentMessage}>
|
||||
<i className={`iconfont icon-${type}`} />
|
||||
<span className={Style.messageText}>{text}</span>
|
||||
</div>
|
||||
),
|
||||
duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
success(text: string, duration = 1.5) {
|
||||
showMessage(text, duration, 'success');
|
||||
},
|
||||
error(text: string, duration = 1.5) {
|
||||
showMessage(text, duration, 'error');
|
||||
},
|
||||
warning(text: string, duration = 1.5) {
|
||||
showMessage(text, duration, 'warning');
|
||||
},
|
||||
info(text: string, duration = 1.5) {
|
||||
showMessage(text, duration, 'info');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Line as LineProgress, Circle as CircleProgress } from 'rc-progress';
|
||||
import 'rc-progress/assets/index.css';
|
||||
|
||||
export { LineProgress, CircleProgress };
|
||||
@@ -0,0 +1,4 @@
|
||||
import Select, { Option, OptGroup } from 'rc-select';
|
||||
import 'rc-select/assets/index.css';
|
||||
|
||||
export { Select, Option, OptGroup };
|
||||
@@ -0,0 +1,6 @@
|
||||
import Tabs, { TabPane } from 'rc-tabs';
|
||||
import TabContent from 'rc-tabs/lib/TabContent';
|
||||
import ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar';
|
||||
import 'rc-tabs/assets/index.css';
|
||||
|
||||
export { Tabs, TabPane, TabContent, ScrollableInkTabBar };
|
||||
@@ -0,0 +1,13 @@
|
||||
:global {
|
||||
.rc-tooltip {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.rc-tooltip-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.rc-tooltip-inner {
|
||||
span {
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Tooltip from 'rc-tooltip';
|
||||
import 'rc-tooltip/assets/bootstrap.css';
|
||||
|
||||
import './Tooltip.less';
|
||||
|
||||
export default Tooltip;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const ShowUserOrGroupInfoContext = createContext(null);
|
||||
@@ -0,0 +1,16 @@
|
||||
// @ts-ignore
|
||||
import { css } from 'linaria';
|
||||
|
||||
const globalStyles = css`
|
||||
:global() {
|
||||
.danger {
|
||||
background-color: #dd514c !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #d7342e !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default globalStyles;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { ActionTypes } from '../state/action';
|
||||
import { Message } from '../state/reducer';
|
||||
|
||||
/**
|
||||
* 获取 redux action
|
||||
*/
|
||||
export default function useAction() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return {
|
||||
logout() {
|
||||
dispatch({
|
||||
type: ActionTypes.Logout,
|
||||
});
|
||||
},
|
||||
|
||||
setFocus(contactID: string) {
|
||||
dispatch({
|
||||
type: ActionTypes.SetFocus,
|
||||
payload: contactID,
|
||||
});
|
||||
},
|
||||
|
||||
addMessage(message: Message) {
|
||||
dispatch({
|
||||
type: ActionTypes.AddMessage,
|
||||
payload: message,
|
||||
});
|
||||
},
|
||||
nextActiveContact() {
|
||||
dispatch({
|
||||
type: ActionTypes.MoveFocusToNextActiveContact,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
prevActiveContact() {
|
||||
dispatch({
|
||||
type: ActionTypes.MoveFocusToPrevActiveContact,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
deactiveCurrentActiveContact() {
|
||||
dispatch({
|
||||
type: ActionTypes.DeactiveCurrentActiveContact,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
forceUpdateMessagesAfterMutation() {
|
||||
dispatch({
|
||||
type: ActionTypes.ForceUpdateMessagesAfterMutation,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
forceUpdateContactsAfterMutation() {
|
||||
dispatch({
|
||||
type: ActionTypes.ForceUpdateActiveContactsAfterMutation,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
|
||||
setStatus(key: string, value: any) {
|
||||
dispatch({
|
||||
type: ActionTypes.SetStatus,
|
||||
payload: {
|
||||
key,
|
||||
value,
|
||||
},
|
||||
});
|
||||
window.localStorage.setItem(key, value);
|
||||
},
|
||||
|
||||
toggleLoginRegisterDialog(visible: boolean) {
|
||||
dispatch({
|
||||
type: ActionTypes.SetStatus,
|
||||
payload: {
|
||||
key: 'loginRegisterDialogVisible',
|
||||
value: visible,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setSideBarTabState(state: 'contact' | 'message') {
|
||||
dispatch({
|
||||
type: ActionTypes.ChangeSideBarTabState,
|
||||
payload: state,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../state/reducer';
|
||||
|
||||
/**
|
||||
* 获取毛玻璃状态属性
|
||||
*/
|
||||
export default function useAero() {
|
||||
const aero = useSelector((state: State) => state.status.aero);
|
||||
return {
|
||||
'data-aero': aero,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../state/reducer';
|
||||
|
||||
/**
|
||||
* 获取登录态
|
||||
*/
|
||||
export default function useIsLogin() {
|
||||
const isLogin = useSelector((state: State) => state.user && state.user.id);
|
||||
return isLogin;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Contact, State } from '../state/reducer';
|
||||
|
||||
export function useStore() {
|
||||
return useSelector((state: State) => state);
|
||||
}
|
||||
|
||||
export function useFocusLinkman(): Contact | null {
|
||||
const store = useStore();
|
||||
const { focusContactID } = store;
|
||||
if (focusContactID) {
|
||||
return store.allContacts.get(focusContactID) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useSelfId() {
|
||||
const store = useStore();
|
||||
return store.user?.id || '';
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import config from '@fiora/config/client';
|
||||
import themes from './themes';
|
||||
|
||||
/** LocalStorage存储的键值 */
|
||||
export enum LocalStorageKey {
|
||||
Theme = 'theme',
|
||||
PrimaryColor = 'primaryColor',
|
||||
PrimaryTextColor = 'primaryTextColor',
|
||||
BackgroundImage = 'backgroundImage',
|
||||
Aero = 'aero',
|
||||
Sound = 'sound',
|
||||
SoundSwitch = 'soundSwitch',
|
||||
NotificationSwitch = 'notificationSwitch',
|
||||
VoiceSwitch = 'voiceSwitch',
|
||||
SelfVoiceSwitch = 'selfVoiceSwitch',
|
||||
TagColorMode = 'tagColorMode',
|
||||
EnableSearchExpression = 'enableSearchExpression',
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取LocalStorage中的文本值
|
||||
* @param key 键值
|
||||
* @param defaultValue 默认值
|
||||
*/
|
||||
function getTextValue(key: string, defaultValue: string) {
|
||||
const value = window.localStorage.getItem(key);
|
||||
return value || defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取LocalStorage中的boolean值
|
||||
* @param key 键值
|
||||
* @param defaultValue 默认值
|
||||
*/
|
||||
function getSwitchValue(key: string, defaultValue: boolean = true) {
|
||||
const value = window.localStorage.getItem(key);
|
||||
return value ? value === 'true' : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取LocalStorage值
|
||||
*/
|
||||
export default function getData() {
|
||||
const theme = getTextValue(LocalStorageKey.Theme, config.defaultTheme);
|
||||
let themeConfig = {
|
||||
primaryColor: '',
|
||||
primaryTextColor: '',
|
||||
backgroundImage: '',
|
||||
aero: false,
|
||||
};
|
||||
// @ts-ignore
|
||||
if (theme && themes[theme]) {
|
||||
// @ts-ignore
|
||||
themeConfig = themes[theme];
|
||||
} else {
|
||||
themeConfig = {
|
||||
primaryColor: getTextValue(
|
||||
LocalStorageKey.PrimaryColor,
|
||||
themes[config.defaultTheme]?.primaryColor,
|
||||
),
|
||||
primaryTextColor: getTextValue(
|
||||
LocalStorageKey.PrimaryTextColor,
|
||||
themes[config.defaultTheme]?.primaryTextColor,
|
||||
),
|
||||
backgroundImage: getTextValue(
|
||||
LocalStorageKey.BackgroundImage,
|
||||
themes[config.defaultTheme]?.backgroundImage,
|
||||
),
|
||||
aero: getSwitchValue(LocalStorageKey.Aero, false),
|
||||
};
|
||||
}
|
||||
return {
|
||||
theme,
|
||||
...themeConfig,
|
||||
sound: getTextValue(LocalStorageKey.Sound, config.sound),
|
||||
soundSwitch: getSwitchValue(LocalStorageKey.SoundSwitch),
|
||||
notificationSwitch: getSwitchValue(LocalStorageKey.NotificationSwitch),
|
||||
voiceSwitch: getSwitchValue(LocalStorageKey.VoiceSwitch),
|
||||
selfVoiceSwitch: getSwitchValue(LocalStorageKey.SelfVoiceSwitch, false),
|
||||
tagColorMode: getTextValue(
|
||||
LocalStorageKey.TagColorMode,
|
||||
config.tagColorMode,
|
||||
),
|
||||
enableSearchExpression: getSwitchValue(
|
||||
LocalStorageKey.EnableSearchExpression,
|
||||
false,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import ReactDom from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
/* eslint-disable camelcase */
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import App from './App';
|
||||
import getData from './localStorage';
|
||||
import setCssVariable from './utils/setCssVariable';
|
||||
import store from './state/store';
|
||||
|
||||
// 注册 Service Worker
|
||||
if (window.location.protocol === 'https:' && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register(`/service-worker.js`);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新 css variable
|
||||
const { primaryColor, primaryTextColor } = getData();
|
||||
setCssVariable(primaryColor, primaryTextColor);
|
||||
|
||||
// 请求 Notification 授权
|
||||
if (
|
||||
window.Notification &&
|
||||
(window.Notification.permission === 'default' ||
|
||||
window.Notification.permission === 'denied')
|
||||
) {
|
||||
window.Notification.requestPermission();
|
||||
}
|
||||
|
||||
if (window.location.pathname !== '/') {
|
||||
window.history.pushState({}, 'fiora', '/');
|
||||
}
|
||||
|
||||
ReactDom.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
document.getElementById('app'),
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.contact {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
cursor: default;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.focus {
|
||||
background-color: var(--primary-color-4);
|
||||
|
||||
&[data-aero='true'] {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
background-color: var(--primary-color-9);
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--primary-text-color-7);
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
}
|
||||
.unread {
|
||||
@size: 18px;
|
||||
background-color: var(--primary-color-10);
|
||||
background-color: darkred;
|
||||
width: @size;
|
||||
height: @size;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
& > span {
|
||||
color: #e9e9e9;
|
||||
font-size: 10px;
|
||||
line-height: @size;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.rowContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nameTimeBlock {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--primary-text-color-10);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.previewUnreadBlock {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
color: var(--primary-text-color-7);
|
||||
font-size: 12px;
|
||||
width: 188px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import useAction from '../../hooks/useAction';
|
||||
import useAero from '../../hooks/useAero';
|
||||
import {
|
||||
Contact as ActiveContact,
|
||||
Contact,
|
||||
Message,
|
||||
State,
|
||||
} from '../../state/reducer';
|
||||
import Style from './ActiveContact.less';
|
||||
|
||||
interface ActiveContactProps {
|
||||
contact: Contact;
|
||||
}
|
||||
|
||||
function ActiveContact(props: ActiveContactProps) {
|
||||
const C = props.contact;
|
||||
const aero = useAero();
|
||||
const action = useAction();
|
||||
const hasFocus = useSelector(
|
||||
(state: State) => state.focusContactID === C.id,
|
||||
);
|
||||
const messageList = useSelector((state: State) => {
|
||||
return state.messages[C.id] || [];
|
||||
});
|
||||
const lastMessage = messageList.at(-1);
|
||||
let preview = lastMessage?.content;
|
||||
if (lastMessage && lastMessage.speakingGroupMemberName) {
|
||||
preview = lastMessage.speakingGroupMemberName + ': ' + preview;
|
||||
}
|
||||
const lastReadMessageRef: any = useRef<Message>(null);
|
||||
let unread = 0;
|
||||
if (hasFocus) {
|
||||
lastReadMessageRef.current = lastMessage;
|
||||
} else if (messageList.length) {
|
||||
const idx = messageList.indexOf(lastReadMessageRef.current);
|
||||
unread = idx === -1 ? messageList.length : messageList.length - idx - 1;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
action.setFocus(C.id);
|
||||
}
|
||||
function formatTime() {
|
||||
if (!lastMessage) {
|
||||
return null;
|
||||
}
|
||||
return dayjs.unix(lastMessage.createTimestamp).format('HH:ss');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={Style.contact + (hasFocus ? ' ' + Style.focus : '')}
|
||||
role="button"
|
||||
{...aero}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Avatar src={C.avatar} size={48} />
|
||||
<div className={Style.container}>
|
||||
<div className={`${Style.rowContainer} ${Style.nameTimeBlock}`}>
|
||||
<span className={Style.name}>{C.displayName}</span>
|
||||
<span className={Style.time}>{formatTime()}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${Style.rowContainer} ${Style.previewUnreadBlock}`}
|
||||
>
|
||||
<span
|
||||
className={Style.preview}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preview || '暂无消息',
|
||||
}}
|
||||
/>
|
||||
{unread > 0 && (
|
||||
<div className={Style.unread}>
|
||||
<span>{unread > 99 ? '99+' : unread}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActiveContact;
|
||||
@@ -0,0 +1,39 @@
|
||||
@import "../../styles/variable.less";
|
||||
|
||||
.contactList {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
background-color: unset;
|
||||
|
||||
@media @mobile {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
background-color: rgba(37, 37, 37, 0.5);
|
||||
|
||||
.container {
|
||||
background-color: var(--primary-color-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: var(--primary-color-5);
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&[data-aero='true'] {
|
||||
background-color: var(--primary-color-0);
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
background-color: var(--primary-color-7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../state/reducer';
|
||||
import ActiveContact from './ActiveContact';
|
||||
import Style from './ActiveContactList.less';
|
||||
|
||||
function ActiveContactList() {
|
||||
const contacts = useSelector((state: State) => {
|
||||
return state.activeContacts.sort((c1, c2) => {
|
||||
const lm1 = state.messages[c1.id]?.at(-1);
|
||||
const lm2 = state.messages[c2.id]?.at(-1);
|
||||
if (!lm1 || !lm2) {
|
||||
if (lm1) {
|
||||
return -1;
|
||||
} else if (lm2) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return lm2.createTimestamp - lm1.createTimestamp;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={Style.contactList} role="button">
|
||||
<div className={Style.container}>
|
||||
{contacts.map((contact) => (
|
||||
<ActiveContact key={contact.id} contact={contact} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActiveContactList;
|
||||
@@ -0,0 +1,42 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(241, 241, 241, 0.6);
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&[data-aero=true] {
|
||||
background-color: rgba(241, 241, 241, 0.15);
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.noLinkman {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noLinkmanImage {
|
||||
border-radius: 8px;
|
||||
width: 170px;
|
||||
height: 180px;
|
||||
background-position-y: 180px;
|
||||
}
|
||||
|
||||
.noLinkmanText {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import useAero from '../../hooks/useAero';
|
||||
import { ShowUserOrGroupInfoContext } from '../../context';
|
||||
import { State } from '../../state/reducer';
|
||||
import ChatInput from './ChatInput';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import MessageList from './MessageList';
|
||||
import Style from './Chat.less';
|
||||
|
||||
function Chat() {
|
||||
const activeContact = useSelector((state: State) => {
|
||||
return state.contacts
|
||||
.concat(state.groups)
|
||||
.find((c) => c.id === state.focusContactID);
|
||||
});
|
||||
const aero = useAero();
|
||||
|
||||
if (!activeContact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={Style.chat} {...aero}>
|
||||
<HeaderBar name={activeContact.displayName} />
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chat;
|
||||
@@ -0,0 +1,154 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.chatInput {
|
||||
height: 70px;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 20px;
|
||||
border-bottom-right-radius: 10px;
|
||||
position: relative;
|
||||
|
||||
&[data-aero='true'] {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.input {
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
border: none;
|
||||
&::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
:global {
|
||||
.iconfont {
|
||||
color: var(--primary-text-color-8);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
:global {
|
||||
.iconfont {
|
||||
color: var(--primary-color-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
height: 50px;
|
||||
padding: 0 6px;
|
||||
border-bottom-right-radius: 0px;
|
||||
height: calc(50px + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 0px 8px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
outline: none;
|
||||
border: 1px solid rgba(208, 208, 208, 0.5);
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
user-select: auto;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 8px;
|
||||
font-size: 22px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
:global {
|
||||
.iconfont {
|
||||
opacity: 0.8;
|
||||
color: var(--primary-color-10);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
:global {
|
||||
.iconfont {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expressionDropdown {
|
||||
width: 415px;
|
||||
height: 240px;
|
||||
background-color: white;
|
||||
transform: translateY(-8px);
|
||||
|
||||
@media @mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.featureDropdown {
|
||||
background-color: white;
|
||||
|
||||
:global {
|
||||
.rc-menu {
|
||||
padding: 6px 0;
|
||||
}
|
||||
.rc-menu-item {
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.atPanel {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
left: 120px;
|
||||
bottom: 60px;
|
||||
padding: 6px 0;
|
||||
border-radius: 3px;
|
||||
max-height: 266px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.atUserList {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
min-width: 120px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--primary-color-4);
|
||||
}
|
||||
}
|
||||
|
||||
.atText {
|
||||
font-size: 14px;
|
||||
margin-left: 6px;
|
||||
margin-top: 4px;
|
||||
color: #444;
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import compressImage from '@fiora/utils/compressImage';
|
||||
import config from '@fiora/config/client';
|
||||
import dayjs from 'dayjs';
|
||||
import loadable from '@loadable/component';
|
||||
import xss from '@fiora/utils/xss';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { css } from 'linaria';
|
||||
import { isMobile } from '@fiora/utils/ua';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import Dropdown from '../../components/Dropdown';
|
||||
import IconButton from '../../components/IconButton';
|
||||
import MessageControl from '../../components/Message';
|
||||
import Tooltip from '../../components/Tooltip';
|
||||
import composeMessage from '../../utils/composeMessage';
|
||||
import fetch from '../../utils/fetch';
|
||||
import uploadFile from '../../utils/uploadFile';
|
||||
import useAction from '../../hooks/useAction';
|
||||
import useAero from '../../hooks/useAero';
|
||||
import useIsLogin from '../../hooks/useIsLogin';
|
||||
import readDiskFile, { ReadFileResult } from '../../utils/readDiskFile';
|
||||
import { Menu, MenuItem } from '../../components/Menu';
|
||||
import { Message, State } from '../../state/reducer';
|
||||
import { sendMessage } from '../../service';
|
||||
import Style from './ChatInput.less';
|
||||
|
||||
const expressionList = css`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -80px;
|
||||
background-color: inherit;
|
||||
overflow-x: auto;
|
||||
`;
|
||||
const expressionImageContainer = css`
|
||||
min-width: 80px;
|
||||
height: 80px;
|
||||
`;
|
||||
const expressionImage = css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const ExpressionAsync = loadable(
|
||||
() =>
|
||||
// @ts-ignore
|
||||
import(/* webpackChunkName: "expression" */ './Expression'),
|
||||
);
|
||||
const CodeEditorAsync = loadable(
|
||||
// @ts-ignore
|
||||
() => import(/* webpackChunkName: "code-editor" */ './CodeEditor'),
|
||||
);
|
||||
|
||||
let searchExpressionTimer: number = 0;
|
||||
|
||||
let inputIME = false;
|
||||
|
||||
function ChatInput() {
|
||||
const action = useAction();
|
||||
const isLogin = useIsLogin();
|
||||
const connect = useSelector((state: State) => state.connect);
|
||||
const selfId = useSelector((state: State) => state.user?.id);
|
||||
const username = useSelector((state: State) => state.user?.displayName);
|
||||
const avatar = useSelector((state: State) => state.user?.avatar);
|
||||
const focus = useSelector((state: State) => state.focusContactID);
|
||||
const linkman = useSelector((state: State) => state.allContacts.get(focus));
|
||||
const [inputFocus, toggleInputFocus] = useState(false);
|
||||
const [expressionDialog, toggleExpressionDialog] = useState(false);
|
||||
const [at, setAt] = useState({ enable: false, content: '' });
|
||||
const $input = useRef<HTMLInputElement>(null);
|
||||
const aero = useAero();
|
||||
|
||||
/** 全局输入框聚焦快捷键 */
|
||||
function focusInput(e: KeyboardEvent) {
|
||||
const $target: HTMLElement = e.target as HTMLElement;
|
||||
if (
|
||||
$target.tagName === 'INPUT' ||
|
||||
$target.tagName === 'TEXTAREA' ||
|
||||
e.key !== 'i'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
// @ts-ignore
|
||||
$input.current.focus(e);
|
||||
}
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', focusInput);
|
||||
return () => window.removeEventListener('keydown', focusInput);
|
||||
}, []);
|
||||
|
||||
if (!isLogin) {
|
||||
return (
|
||||
<div className={Style.chatInput}>
|
||||
<span className={Style.guest}>未连接,请刷新</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入文本到输入框光标处
|
||||
* @param value 要插入的文本
|
||||
*/
|
||||
function insertAtCursor(value: string) {
|
||||
const input = ($input.current as unknown) as HTMLInputElement;
|
||||
if (input.selectionStart || input.selectionStart === 0) {
|
||||
const startPos = input.selectionStart;
|
||||
const endPos = input.selectionEnd;
|
||||
const restoreTop = input.scrollTop;
|
||||
input.value =
|
||||
input.value.substring(0, startPos) +
|
||||
value +
|
||||
input.value.substring(endPos as number, input.value.length);
|
||||
if (restoreTop > 0) {
|
||||
input.scrollTop = restoreTop;
|
||||
}
|
||||
input.focus();
|
||||
input.selectionStart = startPos + value.length;
|
||||
input.selectionEnd = startPos + value.length;
|
||||
} else {
|
||||
input.value += value;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectExpression(expression: string) {
|
||||
toggleExpressionDialog(false);
|
||||
insertAtCursor(`#(${expression})`);
|
||||
}
|
||||
|
||||
async function handleSendMessage({
|
||||
type,
|
||||
toID,
|
||||
data,
|
||||
filename,
|
||||
fileSize,
|
||||
localFileUrl,
|
||||
}: {
|
||||
type: string;
|
||||
toID: string;
|
||||
data: string;
|
||||
filename?: string;
|
||||
fileSize?: number;
|
||||
localFileUrl?: string;
|
||||
}) {
|
||||
const message: Message = composeMessage({
|
||||
type: type as any,
|
||||
target: toID,
|
||||
data,
|
||||
filename,
|
||||
fileSize,
|
||||
localFileUrl,
|
||||
});
|
||||
action.addMessage(message);
|
||||
const [error, idOnlyMessage] = await sendMessage(
|
||||
toID,
|
||||
type,
|
||||
data,
|
||||
filename,
|
||||
);
|
||||
//TODO: handle result
|
||||
if (error) {
|
||||
throw Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function sendImageMessage(image: string): void;
|
||||
function sendImageMessage(image: ReadFileResult): void;
|
||||
function sendImageMessage(image: string | ReadFileResult) {
|
||||
if (typeof image === 'string') {
|
||||
// handleSendMessage(id, 'emotion', image);
|
||||
// toggleExpressionDialog(false);
|
||||
// TODO: send emotion
|
||||
return;
|
||||
}
|
||||
if (image.length > config.maxImageSize) {
|
||||
MessageControl.warning('要发送的图片过大', 3);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(image.result);
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const imageUrl = await uploadFile(image.result as Blob);
|
||||
handleSendMessage({
|
||||
type: 'img',
|
||||
toID: focus,
|
||||
data: imageUrl,
|
||||
localFileUrl: url,
|
||||
});
|
||||
} catch (err) {
|
||||
MessageControl.error('发送图片失败');
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
async function sendFileMessage(file: ReadFileResult) {
|
||||
if (file.length > config.maxFileSize) {
|
||||
MessageControl.warning('要发送的文件过大', 3);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fileUrl = await uploadFile(
|
||||
file.result as Blob,
|
||||
`FileMessage/${selfId}_${Date.now()}.${file.ext}`,
|
||||
);
|
||||
handleSendMessage({
|
||||
type: 'file',
|
||||
toID: focus,
|
||||
data: fileUrl,
|
||||
filename: file.filename,
|
||||
fileSize: file.length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
MessageControl.error('上传文件失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendImage() {
|
||||
if (!connect) {
|
||||
return MessageControl.error('发送消息失败, 您当前处于离线状态');
|
||||
}
|
||||
const image = await readDiskFile(
|
||||
'blob',
|
||||
'image/png,image/jpeg,image/gif',
|
||||
);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
sendImageMessage(image);
|
||||
return null;
|
||||
}
|
||||
async function handleSendFile() {
|
||||
if (!connect) {
|
||||
MessageControl.error('发送消息失败, 您当前处于离线状态');
|
||||
return;
|
||||
}
|
||||
const file = await readDiskFile('blob');
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
sendFileMessage(file);
|
||||
}
|
||||
|
||||
function handleFeatureMenuClick({
|
||||
key,
|
||||
domEvent,
|
||||
}: {
|
||||
key: string;
|
||||
domEvent: any;
|
||||
}) {
|
||||
// Quickly hitting the Enter key causes the button to repeatedly trigger the problem
|
||||
if (domEvent.keyCode === 13) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'image': {
|
||||
handleSendImage();
|
||||
break;
|
||||
}
|
||||
case 'file': {
|
||||
handleSendFile();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: any) {
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
if (!connect) {
|
||||
e.preventDefault();
|
||||
return MessageControl.error('发送消息失败, 您当前处于离线状态');
|
||||
}
|
||||
const { items, types } =
|
||||
e.clipboardData || e.originalEvent.clipboardData;
|
||||
// 如果包含文件内容
|
||||
if (types.indexOf('Files') > -1) {
|
||||
if (!window.confirm('发送剪贴板中的图片?')) {
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function handleLoad() {
|
||||
const image = new Image();
|
||||
image.onload = async () => {
|
||||
const imageBlob = await compressImage(
|
||||
image,
|
||||
file.type,
|
||||
1,
|
||||
);
|
||||
// @ts-ignore
|
||||
sendImageMessage({
|
||||
filename: file.name,
|
||||
ext: imageBlob?.type.split('/').pop(),
|
||||
length: imageBlob?.size,
|
||||
type: imageBlob?.type,
|
||||
result: imageBlob,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
image.src = this.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendTextMessage() {
|
||||
if (!connect) {
|
||||
return MessageControl.error('发送消息失败, 您当前处于离线状态');
|
||||
}
|
||||
// @ts-ignore
|
||||
const message = $input.current.value.trim();
|
||||
if (message.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// @ts-ignore
|
||||
$input.current.value = '';
|
||||
await handleSendMessage({
|
||||
type: 'text',
|
||||
toID: focus,
|
||||
data: message,
|
||||
});
|
||||
// setExpressions([]);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getExpressionsFromContent() {
|
||||
if ($input.current) {
|
||||
const content = $input.current.value.trim();
|
||||
if (searchExpressionTimer) {
|
||||
clearTimeout(searchExpressionTimer);
|
||||
}
|
||||
// @ts-ignore
|
||||
searchExpressionTimer = setTimeout(async () => {
|
||||
if (content.length >= 1 && content.length <= 4) {
|
||||
const [err, res] = await fetch(
|
||||
'searchExpression',
|
||||
{ keywords: content, limit: 10 },
|
||||
{ toast: false },
|
||||
);
|
||||
if (!err && $input.current?.value.trim() === content) {
|
||||
// setExpressions(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// setExpressions([]);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInputKeyDown(
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
) {
|
||||
if (!e.ctrlKey && e.key === 'Tab' && !e.shiftKey) {
|
||||
action.nextActiveContact();
|
||||
e.preventDefault();
|
||||
} else if (!e.ctrlKey && e.key === 'Tab' && e.shiftKey) {
|
||||
action.prevActiveContact();
|
||||
e.preventDefault();
|
||||
} else if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
e.key.toLowerCase() === 'delete'
|
||||
) {
|
||||
action.deactiveCurrentActiveContact();
|
||||
} else if (e.key === 'Enter' && !inputIME) {
|
||||
sendTextMessage();
|
||||
} else if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
e.key.toLowerCase() === 'y' &&
|
||||
!inputIME
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSendImage();
|
||||
} else if (
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
e.key.toLowerCase() === 'y' &&
|
||||
!inputIME
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSendFile();
|
||||
} else if (e.ctrlKey && (e.key === 'e' || e.key === '∂')) {
|
||||
// toggleExpressionDialog(true);
|
||||
e.preventDefault();
|
||||
} else if (e.key === '@') {
|
||||
// 如果按下@建, 则进入@计算模式
|
||||
// @ts-ignore
|
||||
// if (!/@/.test($input.current.value)) {
|
||||
// setAt({
|
||||
// enable: true,
|
||||
// content: '',
|
||||
// });
|
||||
// }
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
} else if (at.enable) {
|
||||
// 如果处于@计算模式
|
||||
const { key } = e;
|
||||
// 延时, 以便拿到新的value和ime状态
|
||||
setTimeout(() => {
|
||||
// 如果@已经被删掉了, 退出@计算模式
|
||||
// @ts-ignore
|
||||
if (!/@/.test($input.current.value)) {
|
||||
setAt({ enable: false, content: '' });
|
||||
return;
|
||||
}
|
||||
// 如果是输入中文, 并且不是空格键, 忽略输入
|
||||
if (inputIME && key !== ' ') {
|
||||
return;
|
||||
}
|
||||
// 如果是不是输入中文, 并且是空格键, 则@计算模式结束
|
||||
if (!inputIME && key === ' ') {
|
||||
setAt({ enable: false, content: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是正在输入中文, 则直接返回, 避免取到拼音字母
|
||||
if (inputIME) {
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
const regexResult = /@([^ ]*)/.exec($input.current.value);
|
||||
if (regexResult) {
|
||||
setAt({ enable: true, content: regexResult[1] });
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
// Set timer to get current input value
|
||||
setTimeout(() => {
|
||||
if (inputIME) {
|
||||
return;
|
||||
}
|
||||
if ($input.current?.value) {
|
||||
getExpressionsFromContent();
|
||||
} else {
|
||||
clearTimeout(searchExpressionTimer);
|
||||
// setExpressions([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getSuggestion() {
|
||||
if (!at.enable || linkman.type !== 'group') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
// TODO: 处理@
|
||||
// return linkman.memberList.filter((member) => {
|
||||
// const regex = new RegExp(`^${at.content}`);
|
||||
// if (regex.test(member.user.username)) {
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
// });
|
||||
}
|
||||
|
||||
function replaceAt(targetUsername: string) {
|
||||
// @ts-ignore
|
||||
$input.current.value = $input.current.value.replace(
|
||||
`@${at.content}`,
|
||||
`@${targetUsername} `,
|
||||
);
|
||||
setAt({
|
||||
enable: false,
|
||||
content: '',
|
||||
});
|
||||
// @ts-ignore
|
||||
$input.current.focus();
|
||||
}
|
||||
|
||||
function handleClickExpressionImage(
|
||||
image: string,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
sendImageMessage(`${image}?width=${width}&height=${height}`);
|
||||
// setExpressions([]);
|
||||
if ($input.current) {
|
||||
$input.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={Style.chatInput} {...aero}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
visible={expressionDialog}
|
||||
onVisibleChange={toggleExpressionDialog}
|
||||
overlay={
|
||||
<div className={Style.expressionDropdown}>
|
||||
<ExpressionAsync
|
||||
onSelectText={handleSelectExpression}
|
||||
onSelectImage={sendImageMessage}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
animation="slide-up"
|
||||
placement="topLeft"
|
||||
>
|
||||
<IconButton
|
||||
className={Style.iconButton}
|
||||
width={32}
|
||||
icon="expression"
|
||||
iconSize={24}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<div className={Style.featureDropdown}>
|
||||
<Menu onClick={handleFeatureMenuClick}>
|
||||
<MenuItem key="huaji">发送滑稽</MenuItem>
|
||||
<MenuItem key="image">发送图片</MenuItem>
|
||||
<MenuItem key="code">发送代码</MenuItem>
|
||||
<MenuItem key="file">发送文件</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
}
|
||||
animation="slide-up"
|
||||
placement="topLeft"
|
||||
>
|
||||
<IconButton
|
||||
width={32}
|
||||
className={Style.iconButton}
|
||||
icon="feature"
|
||||
iconSize={24}
|
||||
/>
|
||||
</Dropdown>
|
||||
<form
|
||||
className={Style.form}
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<input
|
||||
className={Style.input}
|
||||
type="text"
|
||||
placeholder="请输入消息"
|
||||
maxLength={2048}
|
||||
ref={$input}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => {
|
||||
inputIME = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
inputIME = false;
|
||||
}}
|
||||
onFocus={() => toggleInputFocus(true)}
|
||||
onBlur={() => toggleInputFocus(false)}
|
||||
/>
|
||||
|
||||
{!isMobile && !inputFocus && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
overlay={
|
||||
<span>
|
||||
支持粘贴图片发图
|
||||
<br />
|
||||
全局按 i 键聚焦
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<i className={`iconfont icon-about ${Style.tooltip}`} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</form>
|
||||
<IconButton
|
||||
className={Style.iconButton}
|
||||
width={20}
|
||||
icon="send"
|
||||
iconSize={28}
|
||||
onClick={sendTextMessage}
|
||||
/>
|
||||
|
||||
{/* <div className={Style.atPanel}>
|
||||
{at.enable &&
|
||||
getSuggestion().map((member) => (
|
||||
<div
|
||||
className={Style.atUserList}
|
||||
key={member.user._id}
|
||||
onClick={() => replaceAt(member.user.username)}
|
||||
role="button"
|
||||
>
|
||||
<Avatar size={24} src={member.user.avatar} />
|
||||
<span className={Style.atText}>
|
||||
{member.user.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
{/* {expressions.length > 0 && (
|
||||
<div className={expressionList}>
|
||||
{expressions.map(({ image, width, height }) => (
|
||||
<div className={expressionImageContainer}>
|
||||
<img
|
||||
className={expressionImage}
|
||||
src={image}
|
||||
key={image}
|
||||
alt="表情图"
|
||||
onClick={() =>
|
||||
handleClickExpressionImage(
|
||||
image,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatInput;
|
||||
@@ -0,0 +1,52 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.codeEditor {
|
||||
width: 600px;
|
||||
|
||||
@media @mobile {
|
||||
width: 100%;
|
||||
height: 410px;
|
||||
}
|
||||
|
||||
:global {
|
||||
.rc-dialog-body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 55vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.languageSelect {
|
||||
width: 150px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.editorContainer {
|
||||
flex: 1;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #f1f1f1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
margin-top: 8px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'brace/theme/tomorrow';
|
||||
import 'brace/ext/language_tools';
|
||||
|
||||
import Style from './CodeEditor.less';
|
||||
import Dialog from '../../components/Dialog';
|
||||
import { Select, Option } from '../../components/Select';
|
||||
import Button from '../../components/Button';
|
||||
|
||||
const languages = [
|
||||
'javascript',
|
||||
'typescript',
|
||||
'text',
|
||||
'java',
|
||||
'c_cpp',
|
||||
'python',
|
||||
'ruby',
|
||||
'php',
|
||||
'golang',
|
||||
'csharp',
|
||||
'html',
|
||||
'css',
|
||||
'sql',
|
||||
'json',
|
||||
];
|
||||
|
||||
interface LoadedLanguage {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
const loadedLanguage: LoadedLanguage = {};
|
||||
|
||||
interface CodeEditorProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSend: (language: string, code: string) => void;
|
||||
}
|
||||
|
||||
function CodeEditor(props: CodeEditorProps) {
|
||||
const { visible, onClose, onSend } = props;
|
||||
|
||||
const [language, setLanguage] = useState('javascript');
|
||||
const [value, setValue] = useState('');
|
||||
const [timestamp, setTimestamp] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setValue('');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// 动态加载语言包
|
||||
if (visible && !loadedLanguage[language]) {
|
||||
/**
|
||||
* 为了减小打包产物体积, 这里是逐个引入所需的语音包
|
||||
* 如果直接用变量路径, 会将该目录下的文件全部打包
|
||||
* 另外不单个引入也不好设置 webpackChunkName
|
||||
*/
|
||||
switch (language) {
|
||||
case 'javascript': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-javascript" */ 'brace/mode/javascript'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'typescript': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-typescript" */ 'brace/mode/typescript'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'java': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-java" */ 'brace/mode/java'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'c_cpp': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-c_cpp" */ 'brace/mode/c_cpp'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'python': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-python" */ 'brace/mode/python'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'ruby': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-ruby" */ 'brace/mode/ruby'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'php': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-php" */ 'brace/mode/php'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'golang': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-golang" */ 'brace/mode/golang'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'csharp': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-csharp" */ 'brace/mode/csharp'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'html': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-html" */ 'brace/mode/html'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'css': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-css" */ 'brace/mode/css'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-text" */ 'brace/mode/markdown'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'sql': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-sql" */ 'brace/mode/sql'
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'json': {
|
||||
// @ts-ignore
|
||||
await import(
|
||||
/* webpackChunkName: "react-ace-mode-json" */ 'brace/mode/json'
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('不支持的语言包', language);
|
||||
}
|
||||
}
|
||||
loadedLanguage[language] = true;
|
||||
setTimestamp(Date.now());
|
||||
}
|
||||
})();
|
||||
}, [visible, language]);
|
||||
|
||||
function renderEditor() {
|
||||
if (!loadedLanguage[language]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorProps = {
|
||||
theme: 'tomorrow',
|
||||
value,
|
||||
onChange: (newValue: string) => setValue(newValue),
|
||||
fontSize: 12,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
showPrintMargin: true,
|
||||
showGutter: true,
|
||||
highlightActiveLine: true,
|
||||
setOptions: {
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
enableSnippets: false,
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AceEditor
|
||||
mode={language === 'text' ? 'markdown' : language}
|
||||
{...editorProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
className={Style.codeEditor}
|
||||
title="请输入要发送的代码"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={Style.container}>
|
||||
<div className={Style.selectContainer}>
|
||||
<h3 className={Style.title}>编程语言: </h3>
|
||||
<Select
|
||||
className={Style.languageSelect}
|
||||
defaultValue={languages[0]}
|
||||
// @ts-ignore
|
||||
onSelect={(lang: string) => setLanguage(lang)}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<Option value={lang} key={lang}>
|
||||
{lang}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className={Style.editorContainer}>
|
||||
{renderEditor()}
|
||||
</div>
|
||||
<Button
|
||||
className={Style.sendButton}
|
||||
onClick={() => onSend(language, value)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
<span className="hide">{timestamp}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -0,0 +1,107 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.expression {
|
||||
height: 100%;
|
||||
|
||||
:global {
|
||||
.rc-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-tabs-content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.defaultExpression {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.defaultExpressionBlock {
|
||||
width: 40px;
|
||||
height: 40px;;
|
||||
padding: 5px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #e9e9e9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
width: 10%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.defaultExpressionItem {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 30px auto;
|
||||
background-image: url('@fiora/assets/images/baidu.png');
|
||||
}
|
||||
|
||||
.searchExpression {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchExpressionInputBlock {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchExpressionInput {
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.searchExpressionButton {
|
||||
height: 34px;
|
||||
width: 100px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
height: 155px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.searchImage {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
margin: 0 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
& > img {
|
||||
max-width: 90px;
|
||||
max-height: 90px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import Loading from 'react-loading';
|
||||
|
||||
import expressions from '@fiora/utils/expressions';
|
||||
import { addParam } from '@fiora/utils/url';
|
||||
import BaiduImage from '@fiora/assets/images/baidu.png';
|
||||
import Style from './Expression.less';
|
||||
import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
TabContent,
|
||||
ScrollableInkTabBar,
|
||||
} from '../../components/Tabs';
|
||||
import Input from '../../components/Input';
|
||||
import Button from '../../components/Button';
|
||||
import Message from '../../components/Message';
|
||||
|
||||
interface ExpressionProps {
|
||||
onSelectText: (expression: string) => void;
|
||||
onSelectImage: (expression: string) => void;
|
||||
}
|
||||
|
||||
function Expression(props: ExpressionProps) {
|
||||
const { onSelectText, onSelectImage } = props;
|
||||
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [searchLoading, toggleSearchLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
|
||||
async function handleSearchExpression() {
|
||||
}
|
||||
|
||||
const renderDefaultExpression = (
|
||||
<div className={Style.defaultExpression}>
|
||||
{expressions.default.map((e, index) => (
|
||||
<div
|
||||
className={Style.defaultExpressionBlock}
|
||||
key={e}
|
||||
data-name={e}
|
||||
onClick={(event) =>
|
||||
onSelectText(event.currentTarget.dataset.name as string)
|
||||
}
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
className={Style.defaultExpressionItem}
|
||||
style={{
|
||||
backgroundPosition: `left ${-30 * index}px`,
|
||||
backgroundImage: `url(${BaiduImage})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleClickExpression(e: any) {
|
||||
const $target = e.target;
|
||||
const url = addParam($target.src, {
|
||||
width: $target.naturalWidth,
|
||||
height: $target.naturalHeight,
|
||||
});
|
||||
onSelectImage(url);
|
||||
}
|
||||
|
||||
const renderSearchExpression = (
|
||||
<div className={Style.searchExpression}>
|
||||
<div className={Style.searchExpressionInputBlock}>
|
||||
<Input
|
||||
className={Style.searchExpressionInput}
|
||||
value={keywords}
|
||||
onChange={setKeywords}
|
||||
onEnter={handleSearchExpression}
|
||||
/>
|
||||
<Button
|
||||
className={Style.searchExpressionButton}
|
||||
onClick={handleSearchExpression}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`${Style.loading} ${
|
||||
searchLoading ? 'show' : 'hide'
|
||||
}`}
|
||||
>
|
||||
<Loading
|
||||
type="spinningBubbles"
|
||||
color="#4A90E2"
|
||||
height={100}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
<div className={Style.searchResult}>
|
||||
{searchResults.map(({ image }) => (
|
||||
<div className={Style.searchImage}>
|
||||
<img
|
||||
src={image}
|
||||
alt="表情"
|
||||
key={image}
|
||||
onClick={handleClickExpression}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={Style.expression}>
|
||||
<Tabs
|
||||
defaultActiveKey="default"
|
||||
renderTabBar={() => <ScrollableInkTabBar />}
|
||||
renderTabContent={() => <TabContent />}
|
||||
>
|
||||
<TabPane tab="默认表情" key="default">
|
||||
{renderDefaultExpression}
|
||||
</TabPane>
|
||||
<TabPane tab="搜索表情包" key="search">
|
||||
{renderSearchExpression}
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Expression;
|
||||
@@ -0,0 +1,148 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.groupManagePanel {
|
||||
height: 100%;
|
||||
width: 300px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transition: transform 0.5s;
|
||||
|
||||
@media @mobile {
|
||||
width: 100%;
|
||||
background-color: rgba(37, 37, 37, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.hide {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(250, 250, 250, 0.95);
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@media @mobile {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 70px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
line-height: 70px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
|
||||
@media @mobile {
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
line-height: 33px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
& > .component-input {
|
||||
height: 36px;
|
||||
}
|
||||
& > .component-button {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.onlineMember{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.userinfoBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-left: 10px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
word-break: keep-all;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.clientInfoText {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.deleteGroupConfirmDialog {
|
||||
width: 250px;
|
||||
:global {
|
||||
.rc-dialog-body {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteGroupConfirmButton {
|
||||
width: 60px;
|
||||
height: 36px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import readDiskFIle from '../../utils/readDiskFile';
|
||||
import Style from './GroupManagePanel.less';
|
||||
import useIsLogin from '../../hooks/useIsLogin';
|
||||
import { State, GroupMember } from '../../state/reducer';
|
||||
import Input from '../../components/Input';
|
||||
import Button from '../../components/Button';
|
||||
import Message from '../../components/Message';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import Tooltip from '../../components/Tooltip';
|
||||
import Dialog from '../../components/Dialog';
|
||||
import {
|
||||
changeGroupName,
|
||||
changeGroupAvatar,
|
||||
deleteGroup,
|
||||
leaveGroup,
|
||||
} from '../../service';
|
||||
import useAction from '../../hooks/useAction';
|
||||
import config from '@fiora/config/client';
|
||||
import { ShowUserOrGroupInfoContext } from '../../context';
|
||||
|
||||
interface GroupManagePanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
groupId: string;
|
||||
avatar: string;
|
||||
creator: string;
|
||||
onlineMembers: GroupMember[];
|
||||
}
|
||||
|
||||
function GroupManagePanel(props: GroupManagePanelProps) {
|
||||
const { visible, onClose, groupId, avatar, creator, onlineMembers } = props;
|
||||
|
||||
const action = useAction();
|
||||
const isLogin = useIsLogin();
|
||||
const selfId = useSelector((state: State) => state.user?._id);
|
||||
const [deleteConfirmDialog, setDialogStatus] = useState(false);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const context = useContext(ShowUserOrGroupInfoContext);
|
||||
|
||||
async function handleChangeGroupName() {
|
||||
const isSuccess = await changeGroupName(groupId, groupName);
|
||||
if (isSuccess) {
|
||||
Message.success('修改群名称成功');
|
||||
action.setLinkmanProperty(groupId, 'name', groupName);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeGroupAvatar() {
|
||||
const image = await readDiskFIle(
|
||||
'blob',
|
||||
'image/png,image/jpeg,image/gif',
|
||||
);
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
if (image.length > config.maxAvatarSize) {
|
||||
// eslint-disable-next-line consistent-return
|
||||
return Message.error('设置群头像失败, 请选择小于1.5MB的图片');
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl = await uploadFile(
|
||||
image.result as Blob,
|
||||
`GroupAvatar/${selfId}_${Date.now()}.${image.ext}`,
|
||||
);
|
||||
const isSuccess = await changeGroupAvatar(groupId, imageUrl);
|
||||
if (isSuccess) {
|
||||
action.setLinkmanProperty(
|
||||
groupId,
|
||||
'avatar',
|
||||
URL.createObjectURL(image.result),
|
||||
);
|
||||
Message.success('修改群头像成功');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
Message.error('上传群头像失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteGroup() {
|
||||
const isSuccess = await deleteGroup(groupId);
|
||||
if (isSuccess) {
|
||||
setDialogStatus(false);
|
||||
onClose();
|
||||
action.removeLinkman(groupId);
|
||||
Message.success('解散群组成功');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLeaveGroup() {
|
||||
const isSuccess = await leaveGroup(groupId);
|
||||
if (isSuccess) {
|
||||
onClose();
|
||||
action.removeLinkman(groupId);
|
||||
Message.success('退出群组成功');
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickMask(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowUserInfo(userInfo: any) {
|
||||
if (userInfo._id === selfId) {
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
context.showUserInfo(userInfo);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${Style.groupManagePanel} ${visible ? 'show' : 'hide'}`}
|
||||
onClick={handleClickMask}
|
||||
role="button"
|
||||
data-float-panel="true"
|
||||
>
|
||||
<div
|
||||
className={`${Style.container} ${
|
||||
visible ? Style.show : Style.hide
|
||||
}`}
|
||||
>
|
||||
<span className={Style.title}>群组信息</span>
|
||||
<div className={Style.content}>
|
||||
{isLogin && selfId === creator ? (
|
||||
<div className={Style.block}>
|
||||
<span className={Style.blockTitle}>修改群名称</span>
|
||||
<Input
|
||||
className={Style.input}
|
||||
value={groupName}
|
||||
onChange={setGroupName}
|
||||
/>
|
||||
<Button
|
||||
className={Style.button}
|
||||
onClick={handleChangeGroupName}
|
||||
>
|
||||
确认修改
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{isLogin && selfId === creator ? (
|
||||
<div className={Style.block}>
|
||||
<span className={Style.blockTitle}>修改群头像</span>
|
||||
<img
|
||||
className={Style.avatar}
|
||||
src={avatar}
|
||||
alt="群头像预览"
|
||||
onClick={handleChangeGroupAvatar}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={Style.block}>
|
||||
<span className={Style.blockTitle}>功能</span>
|
||||
{selfId === creator ? (
|
||||
<Button
|
||||
className={Style.button}
|
||||
type="danger"
|
||||
onClick={() => setDialogStatus(true)}
|
||||
>
|
||||
解散群组
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={Style.button}
|
||||
type="danger"
|
||||
onClick={handleLeaveGroup}
|
||||
>
|
||||
退出群组
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={Style.block}>
|
||||
<span className={Style.blockTitle}>
|
||||
在线成员 <span>{onlineMembers.length}</span>
|
||||
</span>
|
||||
<div>
|
||||
{onlineMembers.map((member) => (
|
||||
<div
|
||||
key={member.user._id}
|
||||
className={Style.onlineMember}
|
||||
>
|
||||
<div
|
||||
className={Style.userinfoBlock}
|
||||
onClick={() =>
|
||||
handleShowUserInfo(member.user)
|
||||
}
|
||||
role="button"
|
||||
>
|
||||
<Avatar
|
||||
size={24}
|
||||
src={member.user.avatar}
|
||||
/>
|
||||
<span className={Style.username}>
|
||||
{member.user.username}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
trigger={['hover']}
|
||||
overlay={
|
||||
<span>{member.environment}</span>
|
||||
}
|
||||
>
|
||||
<span className={Style.clientInfoText}>
|
||||
{member.browser}
|
||||
|
||||
{member.os ===
|
||||
'Windows Server 2008 R2 / 7'
|
||||
? 'Windows 7'
|
||||
: member.os}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
className={Style.deleteGroupConfirmDialog}
|
||||
title="再次确认是否解散群组?"
|
||||
visible={deleteConfirmDialog}
|
||||
onClose={() => setDialogStatus(false)}
|
||||
>
|
||||
<Button
|
||||
className={Style.deleteGroupConfirmButton}
|
||||
type="danger"
|
||||
onClick={handleDeleteGroup}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
className={Style.deleteGroupConfirmButton}
|
||||
onClick={() => setDialogStatus(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupManagePanel;
|
||||
@@ -0,0 +1,69 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.headerBar {
|
||||
height: 70px;
|
||||
border-bottom: 1px solid rgba(208, 208, 208, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 18px;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
|
||||
&[data-aero=true] {
|
||||
border-bottom: 1px solid rgba(208, 208, 208, 0.3);
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
height: 50px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
:global {
|
||||
.iconfont {
|
||||
color: var(--primary-color-10);
|
||||
&:hover {
|
||||
color: var(--primary-color-8);
|
||||
}
|
||||
}
|
||||
|
||||
.online, .offline {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.rightButtonContainer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
|
||||
@media @mobile {
|
||||
font-size: 14px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { isMobile } from '@fiora/utils/ua';
|
||||
import IconButton from '../../components/IconButton';
|
||||
import useAction from '../../hooks/useAction';
|
||||
import useAero from '../../hooks/useAero';
|
||||
import useIsLogin from '../../hooks/useIsLogin';
|
||||
import Style from './HeaderBar.less';
|
||||
|
||||
type Props = {
|
||||
/** 联系人名称, 没有联系人时会传空 */
|
||||
name: string;
|
||||
};
|
||||
|
||||
function HeaderBar(props: Props) {
|
||||
const { name } = props;
|
||||
|
||||
const action = useAction();
|
||||
const isLogin = useIsLogin();
|
||||
const sidebarVisible = false;
|
||||
const aero = useAero();
|
||||
|
||||
return (
|
||||
<div className={Style.headerBar} {...aero}>
|
||||
{isMobile && (
|
||||
<div className={Style.buttonContainer}>
|
||||
<IconButton
|
||||
width={40}
|
||||
height={40}
|
||||
icon="feature"
|
||||
iconSize={24}
|
||||
onClick={() =>
|
||||
action.setStatus('sidebarVisible', !sidebarVisible)
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
width={40}
|
||||
height={40}
|
||||
icon="friends"
|
||||
iconSize={24}
|
||||
onClick={() =>
|
||||
action.setStatus(
|
||||
'functionBarAndLinkmanListVisible',
|
||||
true,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2 className={Style.name}>
|
||||
{<span>{name || '未知联系人'}</span>}
|
||||
</h2>
|
||||
<div
|
||||
className={`${Style.buttonContainer} ${Style.rightButtonContainer}`}
|
||||
>
|
||||
<IconButton
|
||||
width={40}
|
||||
height={40}
|
||||
icon="gongneng"
|
||||
iconSize={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderBar;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import loadable from '@loadable/component';
|
||||
|
||||
import Style from './CodeMessage.less';
|
||||
|
||||
const CodeDialogAsync = loadable(() =>
|
||||
// @ts-ignore
|
||||
import(/* webpackChunkName: "code-dialog" */ './CodeDialog'),
|
||||
);
|
||||
|
||||
type LanguageMap = {
|
||||
[language: string]: string;
|
||||
};
|
||||
|
||||
const languagesMap: LanguageMap = {
|
||||
javascript: 'javascript',
|
||||
typescript: 'typescript',
|
||||
java: 'java',
|
||||
c_cpp: 'cpp',
|
||||
python: 'python',
|
||||
ruby: 'ruby',
|
||||
php: 'php',
|
||||
golang: 'go',
|
||||
csharp: 'csharp',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
sql: 'sql',
|
||||
json: 'json',
|
||||
text: 'text',
|
||||
};
|
||||
|
||||
interface CodeMessageProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function CodeMessage(props: CodeMessageProps) {
|
||||
const { code } = props;
|
||||
|
||||
const [codeDialog, toggleCodeDialog] = useState(false);
|
||||
|
||||
const parseResult = /@language=([_a-z]+)@/.exec(code);
|
||||
if (!parseResult) {
|
||||
return <pre className="code">不支持的编程语言</pre>;
|
||||
}
|
||||
|
||||
const language = languagesMap[parseResult[1]] || 'text';
|
||||
const rawCode = code.replace(/@language=[_a-z]+@/, '');
|
||||
let size = `${rawCode.length}B`;
|
||||
if (rawCode.length > 1024) {
|
||||
size = `${Math.ceil((rawCode.length / 1024) * 100) / 100}KB`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={Style.codeMessage}
|
||||
onClick={() => toggleCodeDialog(true)}
|
||||
role="button"
|
||||
>
|
||||
<div className={Style.codeInfo}>
|
||||
<div className={Style.icon}>
|
||||
<i className="iconfont icon-code" />
|
||||
</div>
|
||||
<div>
|
||||
<span>{language}</span>
|
||||
<span className={Style.codeSize}>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={Style.codeViewButton}>查看</span>
|
||||
</div>
|
||||
{codeDialog && (
|
||||
<CodeDialogAsync
|
||||
visible={codeDialog}
|
||||
onClose={() => toggleCodeDialog(false)}
|
||||
language={language}
|
||||
code={rawCode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeMessage;
|
||||
@@ -0,0 +1,60 @@
|
||||
import FileSize from 'filesize';
|
||||
import React from 'react';
|
||||
import { css } from 'linaria';
|
||||
|
||||
const styles = {
|
||||
container: css`
|
||||
display: block;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
`,
|
||||
fileInfo: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid green;
|
||||
padding-bottom: 8px;
|
||||
`,
|
||||
fileInfoText: css`
|
||||
word-break: break-all;
|
||||
`,
|
||||
button: css`
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
`,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
fileUrl: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
function FileMessage({ fileUrl, filename, fileSize }: Props) {
|
||||
return (
|
||||
<a
|
||||
className={styles.container}
|
||||
{...(fileUrl
|
||||
? { href: fileUrl, download: filename, target: '_blank' }
|
||||
: {})}
|
||||
>
|
||||
<div className={styles.fileInfo}>
|
||||
<span className={styles.fileInfoText}>{filename}</span>
|
||||
<span className={styles.fileInfoText}>
|
||||
{FileSize(fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.button}>下载</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(FileMessage);
|
||||
@@ -0,0 +1,104 @@
|
||||
import loadable from '@loadable/component';
|
||||
import React, { MouseEvent, useCallback, useRef, useState } from 'react';
|
||||
import { isMobile } from '@fiora/utils/ua';
|
||||
import { CircleProgress } from '../../../components/Progress';
|
||||
import Style from './Message.less';
|
||||
|
||||
const ReactViewerAsync = loadable(
|
||||
async () =>
|
||||
// @ts-ignore
|
||||
import(/* webpackChunkName: "react-viewer" */ 'react-viewer'),
|
||||
);
|
||||
|
||||
interface ImageMessageProps {
|
||||
src: string;
|
||||
loading: boolean;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function ImageMessage(props: ImageMessageProps) {
|
||||
const { src, loading, percent } = props;
|
||||
|
||||
const [viewer, toggleViewer] = useState(false);
|
||||
const closeViewer = useCallback(() => toggleViewer(false), []);
|
||||
const $container = useRef(null);
|
||||
|
||||
let imageSrc = src;
|
||||
const containerWidth = isMobile ? window.innerWidth - 25 - 50 : 450;
|
||||
const maxWidth = containerWidth - 100 > 500 ? 500 : containerWidth - 100;
|
||||
const maxHeight = 400;
|
||||
let width = 200;
|
||||
const parseResult = /width=([0-9]+)&height=([0-9]+)/.exec(imageSrc);
|
||||
if (parseResult) {
|
||||
const natureWidth = +parseResult[1];
|
||||
const naturehHeight = +parseResult[2];
|
||||
let scale = 1;
|
||||
if (natureWidth * scale > maxWidth) {
|
||||
scale = maxWidth / natureWidth;
|
||||
}
|
||||
if (naturehHeight * scale > maxHeight) {
|
||||
scale = maxHeight / naturehHeight;
|
||||
}
|
||||
width = natureWidth * scale;
|
||||
imageSrc = src;
|
||||
}
|
||||
|
||||
let className = Style.imageMessage;
|
||||
if (loading) {
|
||||
className += ` ${Style.iamgeLoading}`;
|
||||
}
|
||||
if (/huaji=true/.test(imageSrc)) {
|
||||
className += ` ${Style.huaji}`;
|
||||
}
|
||||
|
||||
function handleImageViewerMaskClick(e: MouseEvent) {
|
||||
// @ts-ignore
|
||||
if (e.target?.tagName !== 'IMG') {
|
||||
closeViewer();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className} ref={$container}>
|
||||
<img
|
||||
className={Style.image}
|
||||
src={imageSrc}
|
||||
alt="消息图片"
|
||||
width={width}
|
||||
onClick={() => toggleViewer(true)}
|
||||
/>
|
||||
<CircleProgress
|
||||
className={Style.imageProgress}
|
||||
percent={percent}
|
||||
strokeWidth={5}
|
||||
strokeColor="#a0c672"
|
||||
trailWidth={5}
|
||||
/>
|
||||
<div
|
||||
className={`${Style.imageProgress} ${Style.imageProgressNumber}`}
|
||||
>
|
||||
{Math.ceil(percent)}%
|
||||
</div>
|
||||
{viewer && (
|
||||
<ReactViewerAsync
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
visible={viewer}
|
||||
onClose={closeViewer}
|
||||
onMaskClick={handleImageViewerMaskClick}
|
||||
zoomSpeed={0.2}
|
||||
images={[
|
||||
{
|
||||
src: src,
|
||||
alt: '',
|
||||
},
|
||||
]}
|
||||
noNavbar
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ImageMessage);
|
||||
@@ -0,0 +1,231 @@
|
||||
.message {
|
||||
display: flex;
|
||||
margin-right: 54px;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nicknameTimeBlock {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tag {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
background-color: var(--primary-color-9);
|
||||
color: var(--primary-text-color-10);
|
||||
transform: scale(0.9);
|
||||
margin-left: -1px;
|
||||
margin-right: 2px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
user-select: text;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.contentButtonBlock {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding-right: 30px;
|
||||
max-width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border-top-left-radius: 0px;
|
||||
min-height: 28px;
|
||||
margin-top: 3px;
|
||||
max-width: -webkit-fill-available;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0px 7px 15px 0;
|
||||
border-color: transparent rgba(255, 255, 255, 0.8) transparent transparent;
|
||||
position: absolute;
|
||||
top: 23px;
|
||||
left: 50px;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
margin-left: 5px;
|
||||
margin-bottom: 2px;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: rgba(165, 181, 192, 1);
|
||||
}
|
||||
|
||||
@borderRadius: 3px;
|
||||
&:first-child {
|
||||
border-top-left-radius: @borderRadius;
|
||||
border-bottom-left-radius: @borderRadius;
|
||||
}
|
||||
&:last-child {
|
||||
border-top-right-radius: @borderRadius;
|
||||
border-bottom-right-radius: @borderRadius;
|
||||
}
|
||||
}
|
||||
|
||||
.textMessage {
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
line-height: 22px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.imageMessage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.imageProgress {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.imageProgressNumber {
|
||||
color: var(--primary-text-color-10);
|
||||
text-align: center;
|
||||
line-height: 80px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.iamgeLoading {
|
||||
.image {
|
||||
filter: blur(5px);
|
||||
}
|
||||
.imageProgress {
|
||||
display: block;
|
||||
}
|
||||
.imageProgressNumber {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.huaji {
|
||||
.image {
|
||||
width: auto;
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.selecteAble {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.baidu {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-repeat: no-repeat;
|
||||
margin: 0 2px;
|
||||
background-size: 30px auto;
|
||||
background-image: url(~@fiora/assets/images/baidu.png);
|
||||
}
|
||||
|
||||
.self {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
flex-direction: row-reverse;
|
||||
margin-right: 0px;
|
||||
margin-left: 54px;
|
||||
|
||||
.right {
|
||||
margin-left: 0px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.nicknameTimeBlock {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.tag {
|
||||
margin-left: 2px;
|
||||
margin-right: -1px;
|
||||
}
|
||||
.nickname {
|
||||
margin-right: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.contentButtonBlock {
|
||||
flex-direction: row-reverse;
|
||||
padding-left: 30px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.content {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: var(--primary-color-10);
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 0px;
|
||||
padding: 8px 12px;
|
||||
* {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
.buttonList {
|
||||
margin-right: 5px;
|
||||
left: 0;
|
||||
right: inherit;
|
||||
}
|
||||
.arrow {
|
||||
border-width: 0 0 15px 7px;
|
||||
border-color: transparent transparent transparent
|
||||
var(--primary-color-10);
|
||||
left: initial;
|
||||
right: 50px;
|
||||
top: 23px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Time from '@fiora/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import pureRender from 'pure-render-decorator';
|
||||
import React, { Component, createRef } from 'react';
|
||||
import Avatar from '../../../components/Avatar';
|
||||
import IconButton from '../../../components/IconButton';
|
||||
import MessageControl from '../../../components/Message';
|
||||
import Tooltip from '../../../components/Tooltip';
|
||||
import store from '../../../state/store';
|
||||
import { Message } from '../../../state/reducer';
|
||||
import { ShowUserOrGroupInfoContext } from '../../../context';
|
||||
import FileMessage from './FileMessage';
|
||||
import ImageMessage from './ImageMessage';
|
||||
import Style from './Message.less';
|
||||
import TextMessage from './TextMessage';
|
||||
|
||||
const { dispatch } = store;
|
||||
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
showButtonList: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message组件用hooks实现有些问题
|
||||
* 功能上要求Message组件渲染后触发滚动, 实测中发现在useEffect中触发滚动会比在componentDidMount中晚
|
||||
* 具体表现就是会先看到历史消息, 然后一闪而过再滚动到合适的位置
|
||||
*/
|
||||
@pureRender
|
||||
class MessageC extends Component<MessageProps, MessageState> {
|
||||
constructor(props: MessageProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showButtonList: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleMouseEnter = () => {
|
||||
if (this.props.message.isSentBySelf) {
|
||||
this.setState({ showButtonList: true });
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.message.isSentBySelf) {
|
||||
this.setState({ showButtonList: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleDeleteMessage = async () => {
|
||||
const m = this.props.message;
|
||||
if (
|
||||
dayjs.unix(m.createTimestamp).add(2, 'minute').millisecond() <
|
||||
Date.now()
|
||||
) {
|
||||
MessageControl.warning('仅支持撤回2分钟以内的消息');
|
||||
return;
|
||||
}
|
||||
// TODO: 撤回
|
||||
};
|
||||
|
||||
formatTime() {
|
||||
const messageTime = new Date(this.props.message.createTimestamp * 1000);
|
||||
const nowTime = new Date();
|
||||
if (Time.isToday(nowTime, messageTime)) {
|
||||
return Time.getHourMinute(messageTime);
|
||||
}
|
||||
if (Time.isYesterday(nowTime, messageTime)) {
|
||||
return `昨天 ${Time.getHourMinute(messageTime)}`;
|
||||
}
|
||||
return `${Time.getMonthDate(messageTime)} ${Time.getHourMinute(
|
||||
messageTime,
|
||||
)}`;
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const {
|
||||
type,
|
||||
content,
|
||||
fileUrl,
|
||||
fileSize,
|
||||
displayFilename,
|
||||
} = this.props.message;
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
return <TextMessage content={content} />;
|
||||
}
|
||||
case 'emotion':
|
||||
case 'img': {
|
||||
return (
|
||||
<ImageMessage
|
||||
src={fileUrl as string}
|
||||
loading={false}
|
||||
percent={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'video': {
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
src={fileUrl}
|
||||
style={{ maxHeight: 500 }}
|
||||
></video>
|
||||
);
|
||||
}
|
||||
case 'file': {
|
||||
return (
|
||||
<FileMessage
|
||||
fileUrl={fileUrl as string}
|
||||
filename={displayFilename || ''}
|
||||
fileSize={(fileSize as number) | 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className={Style.unknown}>
|
||||
{content || '不支持的消息类型,请在手机上查看'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { showButtonList } = this.state;
|
||||
const m = this.props.message;
|
||||
const state = store.getState();
|
||||
const isSelf = m.isSentBySelf;
|
||||
const from = isSelf ? state.user : state.allContacts.get(m.fromID);
|
||||
const avatar = m.speakingGroupMemberID
|
||||
? state.allContacts.get(m.speakingGroupMemberID)?.avatar
|
||||
: from?.avatar;
|
||||
return (
|
||||
<div className={`${Style.message} ${isSelf ? Style.self : ''}`}>
|
||||
<ShowUserOrGroupInfoContext.Consumer>
|
||||
{(context) => (
|
||||
<Avatar
|
||||
className={Style.avatar}
|
||||
src={avatar as string}
|
||||
size={44}
|
||||
/>
|
||||
)}
|
||||
</ShowUserOrGroupInfoContext.Consumer>
|
||||
<div className={Style.right}>
|
||||
<div className={Style.nicknameTimeBlock}>
|
||||
<span className={Style.nickname}>
|
||||
{m.speakingGroupMemberName || from?.displayName}
|
||||
</span>
|
||||
<span className={Style.time}>{this.formatTime()}</span>
|
||||
</div>
|
||||
<div
|
||||
className={Style.contentButtonBlock}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
<div className={Style.content}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
{showButtonList && (
|
||||
<div className={Style.buttonList}>
|
||||
<Tooltip
|
||||
placement={isSelf ? 'left' : 'right'}
|
||||
mouseEnterDelay={0.3}
|
||||
overlay={<span>撤回消息</span>}
|
||||
>
|
||||
<div>
|
||||
<IconButton
|
||||
className={Style.button}
|
||||
icon="recall"
|
||||
iconSize={16}
|
||||
width={20}
|
||||
height={20}
|
||||
onClick={this.handleDeleteMessage}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={Style.arrow} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageC;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { getPerRandomColor } from '@fiora/utils/getRandomColor';
|
||||
|
||||
interface SystemMessageProps {
|
||||
message: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function SystemMessage(props: SystemMessageProps) {
|
||||
const { message, username } = props;
|
||||
return (
|
||||
<div className="system">
|
||||
<span style={{ color: getPerRandomColor(username) }}>
|
||||
{username}
|
||||
</span>
|
||||
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemMessage;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import expressions from '@fiora/utils/expressions';
|
||||
import { TRANSPARENT_IMAGE } from '@fiora/utils/const';
|
||||
import Style from './Message.less';
|
||||
|
||||
interface TextMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
function TextMessage(props: TextMessageProps) {
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const content = props.content
|
||||
.replace(
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}(\.[a-z]{2,6})?\b(:[0-9]{2,5})?([-a-zA-Z0-9@:%_+.~#?&//=]*)/g,
|
||||
(r) =>
|
||||
`<a class="${Style.selecteAble}" href="${r}" rel="noopener noreferrer" target="_blank">${r}</a>`,
|
||||
)
|
||||
.replace(/#\(([\u4e00-\u9fa5a-z]+)\)/g, (r, e) => {
|
||||
const index = expressions.default.indexOf(e);
|
||||
if (index !== -1) {
|
||||
return `<img class="${Style.baidu} ${
|
||||
Style.selecteAble
|
||||
}" src="${TRANSPARENT_IMAGE}" style="background-position: left ${
|
||||
-30 * index
|
||||
}px;" onerror="this.style.display='none'" alt="${r}">`;
|
||||
}
|
||||
return r;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={Style.textMessage}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextMessage;
|
||||
@@ -0,0 +1,15 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.messageList {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px 10px 0 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: relative;
|
||||
|
||||
@media @mobile {
|
||||
padding: 8px 6px 0 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { css } from 'linaria';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Contact, State } from '../../state/reducer';
|
||||
import MessageComponent from './Message/Message';
|
||||
import Style from './MessageList.less';
|
||||
|
||||
const styles = {
|
||||
container: css`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`,
|
||||
unread: css`
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--primary-color-8);
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color-9);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
`,
|
||||
};
|
||||
|
||||
function MessageList() {
|
||||
const [prevFocus, setPrevFocus] = useState('');
|
||||
const [prevMessages, setPrevMessages] = useState([]);
|
||||
const focus = useSelector((state: State) => state.focusContactID);
|
||||
const activeContact = useSelector(
|
||||
(state: State) => state.allContacts.get(focus) as Contact,
|
||||
);
|
||||
const $list = useRef<HTMLDivElement>(null);
|
||||
const _messages = useSelector((state: State) => state.messages[focus]);
|
||||
const messages = _messages || [];
|
||||
|
||||
useEffect(() => {
|
||||
const l = $list.current as HTMLDivElement;
|
||||
if (prevFocus === focus) {
|
||||
// 当前联系人新消息
|
||||
if (
|
||||
(messages.length && messages.at(-1)?.isSentBySelf) ||
|
||||
l.scrollHeight - l.clientHeight - l.scrollTop < l.clientHeight
|
||||
) {
|
||||
$list.current?.scrollTo({ top: $list.current.scrollHeight });
|
||||
// FIXME: redux不应该突变属性,因为检测不到其变化
|
||||
activeContact.lastReadMessage = messages.at(-1);
|
||||
}
|
||||
} else {
|
||||
// 当前聊天对象改变
|
||||
if (activeContact?.lastReadMessage === messages.at(-1)) {
|
||||
$list.current?.scrollTo({ top: $list.current.scrollHeight });
|
||||
}
|
||||
}
|
||||
setPrevFocus(focus);
|
||||
setPrevMessages(messages as any);
|
||||
}, [focus, _messages]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={`${Style.messageList} show-scrollbar`} ref={$list}>
|
||||
{messages.map((message) => (
|
||||
<MessageComponent
|
||||
key={message.messageID}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageList;
|
||||
@@ -0,0 +1,57 @@
|
||||
@import '../../styles/variable.less';
|
||||
|
||||
.contact {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
cursor: default;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.focus {
|
||||
background-color: var(--primary-color-4);
|
||||
|
||||
&[data-aero='true'] {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@media @mobile {
|
||||
background-color: var(--primary-color-9);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.rowContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nameTimeBlock {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--primary-text-color-10);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.previewUnreadBlock {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
color: var(--primary-text-color-7);
|
||||
font-size: 12px;
|
||||
width: 188px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user