first version

This commit is contained in:
2021-11-01 22:30:51 +08:00
parent da2a96865c
commit d964439624
143 changed files with 29617 additions and 101 deletions
+12
View File
@@ -1 +1,13 @@
.DS_Store
node_modules/
dist/
coverage/
.idea/
.linaria-cache/
npm-debug.log
yarn-error.log
.eslintcache
lerna-debug.log
.env
+4 -1
View File
@@ -1,3 +1,6 @@
# v-chat
微信多客户端方案。服务器通过Web版微信接口提供收发微信消息的能力,并允许多个客户端同时连接。
## 鸣谢
前端程序参考自[fiora](https://github.com/yinxin630/fiora)web聊天室。
服务端程序使用了[wechat4u](https://github.com/nodeWechat/wechat4u)。
+83
View File
@@ -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",
+1
View File
@@ -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",
+4 -1
View File
@@ -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];
}
+16 -4
View File
@@ -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);
}
+31
View File
@@ -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();
+18 -10
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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,
};
+4
View File
@@ -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,
+43
View File
@@ -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
}
]
]
}
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
public/
build/
docs/
*.d.ts
+72
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"tabWidth": 4,
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "always",
"printWidth": 80
}
+6
View File
@@ -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`登录。
+131
View File
@@ -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,
}),
],
};
+29
View File
@@ -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()],
});
+43
View File
@@ -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(),
],
});
+21261
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -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

+39
View File
@@ -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);
}
});
}
+19
View File
@@ -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];
}
+6
View File
@@ -0,0 +1,6 @@
import { getLogger } from 'log4js';
const logger = getLogger();
logger.level = process.env.NODE_ENV === 'development' ? 'trace' : 'info';
export default logger;
+5
View File
@@ -0,0 +1,5 @@
export default function sleep(duration = 200) {
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
}
+9
View File
@@ -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}`);
});
});
+28
View File
@@ -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()}`;
},
};
+7
View File
@@ -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;
+16
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
import xss from 'xss';
/**
* xss防护
* @param text 要处理的文字
*/
export default function processXss(text: string) {
return xss(text);
}
+25
View File
@@ -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;
}
}
+164
View File
@@ -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;
+55
View File
@@ -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;
+46
View File
@@ -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;
+47
View File
@@ -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;
}
}
}
+6
View File
@@ -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;
}
}
+6
View File
@@ -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;
+31
View File
@@ -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);
}
}
+102
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import Loading from 'react-loading';
export default Loading;
+4
View File
@@ -0,0 +1,4 @@
import Menu, { SubMenu, MenuItem } from 'rc-menu';
import 'rc-menu/assets/index.css';
export { Menu, MenuItem, SubMenu };
+37
View File
@@ -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;
}
+34
View File
@@ -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');
},
};
+4
View File
@@ -0,0 +1,4 @@
import { Line as LineProgress, Circle as CircleProgress } from 'rc-progress';
import 'rc-progress/assets/index.css';
export { LineProgress, CircleProgress };
+4
View File
@@ -0,0 +1,4 @@
import Select, { Option, OptGroup } from 'rc-select';
import 'rc-select/assets/index.css';
export { Select, Option, OptGroup };
+6
View File
@@ -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 };
+13
View File
@@ -0,0 +1,13 @@
:global {
.rc-tooltip {
display: inline-block !important;
}
.rc-tooltip-hidden {
display: none !important;
}
.rc-tooltip-inner {
span {
color: #f1f1f1 !important;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import Tooltip from 'rc-tooltip';
import 'rc-tooltip/assets/bootstrap.css';
import './Tooltip.less';
export default Tooltip;
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from 'react';
// eslint-disable-next-line import/prefer-default-export
export const ShowUserOrGroupInfoContext = createContext(null);
+16
View File
@@ -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;
+90
View File
@@ -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,
});
},
};
}
+12
View File
@@ -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,
};
}
+10
View File
@@ -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;
}
+20
View File
@@ -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 || '';
}
+89
View File
@@ -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,
),
};
}
+41
View File
@@ -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;
+42
View File
@@ -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;
}
+32
View File
@@ -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;
}
+636
View File
@@ -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}>
线 &nbsp;<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}
&nbsp;&nbsp;
{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>
&nbsp;
{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;
}
}

Some files were not shown because too many files have changed in this diff Show More