├── .gitignore ├── client ├── static │ └── .gitkeep ├── src │ ├── main │ │ ├── service │ │ │ ├── handle-event.js │ │ │ ├── global.js │ │ │ └── menu.js │ │ ├── index.dev.js │ │ └── index.js │ ├── renderer │ │ ├── components │ │ │ ├── content │ │ │ │ ├── _parts │ │ │ │ │ ├── content-head.vue │ │ │ │ │ └── default-page.vue │ │ │ │ ├── chat-box │ │ │ │ │ └── parts │ │ │ │ │ │ ├── message-types │ │ │ │ │ │ ├── file-message.vue │ │ │ │ │ │ ├── audio-message.vue │ │ │ │ │ │ ├── video-message.vue │ │ │ │ │ │ ├── system-message.vue │ │ │ │ │ │ ├── text-message.vue │ │ │ │ │ │ └── image-message.vue │ │ │ │ │ │ ├── message-item.vue │ │ │ │ │ │ └── message-boxes │ │ │ │ │ │ ├── right-message-box.vue │ │ │ │ │ │ └── left-message-box.vue │ │ │ │ ├── settings │ │ │ │ │ ├── parts │ │ │ │ │ │ ├── system-message.vue │ │ │ │ │ │ ├── software-setting.vue │ │ │ │ │ │ └── profile-setting.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── functions │ │ │ │ │ ├── parts │ │ │ │ │ │ ├── manage-category.vue │ │ │ │ │ │ ├── parts │ │ │ │ │ │ │ └── search-result-item.vue │ │ │ │ │ │ ├── create-group.vue │ │ │ │ │ │ └── add-new-contact.vue │ │ │ │ │ └── index.vue │ │ │ │ └── contact-info │ │ │ │ │ ├── index.vue │ │ │ │ │ └── parts │ │ │ │ │ ├── parts │ │ │ │ │ └── group-member-item.vue │ │ │ │ │ ├── friend-info.vue │ │ │ │ │ └── group-info.vue │ │ │ ├── sidebar │ │ │ │ ├── contacts │ │ │ │ │ └── index.vue │ │ │ │ ├── _parts │ │ │ │ │ ├── function-item.vue │ │ │ │ │ ├── contact-item.vue │ │ │ │ │ ├── category-item.vue │ │ │ │ │ └── chat-item.vue │ │ │ │ ├── functions │ │ │ │ │ └── index.vue │ │ │ │ ├── settings │ │ │ │ │ └── index.vue │ │ │ │ ├── search-bar.vue │ │ │ │ ├── chats │ │ │ │ │ └── index.vue │ │ │ │ └── menu-bar.vue │ │ │ ├── main.vue │ │ │ └── login.vue │ │ ├── assets │ │ │ ├── logo.png │ │ │ └── info-bg.png │ │ ├── styles │ │ │ ├── fonts │ │ │ │ ├── fonts │ │ │ │ │ ├── icomoon.eot │ │ │ │ │ ├── icomoon.ttf │ │ │ │ │ └── icomoon.woff │ │ │ │ ├── custom-fonts │ │ │ │ │ └── Flavors-Regular.ttf │ │ │ │ └── style.css │ │ │ ├── _variables.styl │ │ │ └── style.styl │ │ ├── app.vue │ │ ├── store │ │ │ ├── modules │ │ │ │ ├── Main.js │ │ │ │ ├── Function.js │ │ │ │ ├── Contact.js │ │ │ │ └── Chat.js │ │ │ └── index.js │ │ ├── lang │ │ │ ├── index.js │ │ │ └── langs │ │ │ │ ├── zh-CN.yml │ │ │ │ └── en.yml │ │ ├── main.js │ │ ├── vue-extend │ │ │ └── index.js │ │ ├── utils │ │ │ └── index.js │ │ └── router │ │ │ └── index.js │ └── index.ejs ├── .eslintignore ├── build │ └── icons │ │ └── icon.icns ├── .gitignore ├── test │ ├── .eslintrc │ └── unit │ │ ├── specs │ │ └── LandingPage.spec.js │ │ ├── index.js │ │ └── karma.conf.js ├── README.md ├── .babelrc ├── LICENSE ├── .electron-vue │ ├── dev-client.js │ ├── webpack.main.config.js │ ├── build.js │ ├── webpack.web.config.js │ ├── dev-runner.js │ └── webpack.renderer.config.js ├── .eslintrc.js └── package.json ├── server ├── extends │ └── .gitkeep ├── routes │ └── .gitkeep ├── tests │ └── .gitkeep ├── middleware │ └── .gitkeep ├── upload │ └── default │ │ ├── default-group-avatar.png │ │ └── default-user-avatar.png ├── .gitignore ├── README.md ├── utils │ ├── log.js │ └── redis.js ├── controllers │ ├── index.js │ └── modules │ │ ├── store.js │ │ ├── chat.js │ │ ├── category.js │ │ ├── login.js │ │ ├── webrtc.js │ │ ├── group.js │ │ ├── user.js │ │ ├── contacts.js │ │ └── chat-message.js ├── package.json ├── models │ ├── message.js │ ├── user.js │ ├── group.js │ └── category.js └── app.js ├── preview.png ├── docs ├── UI 设计.sketch ├── 流程图 │ ├── 普通通信架构.png │ ├── 注册登录流程.png │ ├── 视频通信架构.png │ ├── 退出登录流程.png │ ├── app 启动引导页.png │ └── @汇总.graffle │ │ ├── data.plist │ │ ├── image1.tiff │ │ ├── image2.png │ │ └── image3.png ├── 产品需求概览.mindnode │ ├── contents.xml │ ├── viewState.plist │ ├── QuickLook │ │ └── Preview.jpg │ └── style.mindnodestyle │ │ ├── contents.xml │ │ └── metadata.plist ├── 其它.md ├── 数据库设计.md └── 接口文档.md ├── LICENSE ├── .vscode └── settings.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitkeep -------------------------------------------------------------------------------- /client/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/extends/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/middleware/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/main/service/handle-event.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/_parts/content-head.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/preview.png -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | test/unit/coverage/** 2 | test/unit/*.js 3 | test/e2e/*.js 4 | -------------------------------------------------------------------------------- /docs/UI 设计.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/UI 设计.sketch -------------------------------------------------------------------------------- /docs/流程图/普通通信架构.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/普通通信架构.png -------------------------------------------------------------------------------- /docs/流程图/注册登录流程.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/注册登录流程.png -------------------------------------------------------------------------------- /docs/流程图/视频通信架构.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/视频通信架构.png -------------------------------------------------------------------------------- /docs/流程图/退出登录流程.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/退出登录流程.png -------------------------------------------------------------------------------- /docs/流程图/app 启动引导页.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/app 启动引导页.png -------------------------------------------------------------------------------- /client/build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/build/icons/icon.icns -------------------------------------------------------------------------------- /docs/流程图/@汇总.graffle/data.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/@汇总.graffle/data.plist -------------------------------------------------------------------------------- /docs/流程图/@汇总.graffle/image1.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/@汇总.graffle/image1.tiff -------------------------------------------------------------------------------- /docs/流程图/@汇总.graffle/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/@汇总.graffle/image2.png -------------------------------------------------------------------------------- /docs/流程图/@汇总.graffle/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/流程图/@汇总.graffle/image3.png -------------------------------------------------------------------------------- /docs/产品需求概览.mindnode/contents.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/产品需求概览.mindnode/contents.xml -------------------------------------------------------------------------------- /client/src/renderer/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/assets/logo.png -------------------------------------------------------------------------------- /docs/产品需求概览.mindnode/viewState.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/产品需求概览.mindnode/viewState.plist -------------------------------------------------------------------------------- /client/src/renderer/assets/info-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/assets/info-bg.png -------------------------------------------------------------------------------- /docs/产品需求概览.mindnode/QuickLook/Preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/产品需求概览.mindnode/QuickLook/Preview.jpg -------------------------------------------------------------------------------- /server/upload/default/default-group-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/server/upload/default/default-group-avatar.png -------------------------------------------------------------------------------- /server/upload/default/default-user-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/server/upload/default/default-user-avatar.png -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.eot -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.ttf -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/styles/fonts/fonts/icomoon.woff -------------------------------------------------------------------------------- /docs/产品需求概览.mindnode/style.mindnodestyle/contents.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/产品需求概览.mindnode/style.mindnodestyle/contents.xml -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .db 2 | .DS_Store 3 | node_modules/ 4 | npm-debug.log 5 | npm-debug.log.* 6 | thumbs.db 7 | !.gitkeep 8 | upload/images 9 | upload/avatars -------------------------------------------------------------------------------- /docs/产品需求概览.mindnode/style.mindnodestyle/metadata.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/docs/产品需求概览.mindnode/style.mindnodestyle/metadata.plist -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/custom-fonts/Flavors-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy507/hola/HEAD/client/src/renderer/styles/fonts/custom-fonts/Flavors-Regular.ttf -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/electron/* 3 | dist/web/* 4 | build/* 5 | !build/icons 6 | coverage 7 | node_modules/ 8 | npm-debug.log 9 | npm-debug.log.* 10 | thumbs.db 11 | dump.rdb 12 | !.gitkeep 13 | -------------------------------------------------------------------------------- /client/src/renderer/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /docs/其它.md: -------------------------------------------------------------------------------- 1 | ### 服务端 redis 保存的数据 2 | 3 | ```js 4 | `${uid}_authcode`: xxxxxx 5 | `${uid}_can_login`: uid 6 | ``` 7 | 8 | ### 客户端本地配置文件保存的内容 9 | 10 | ```js 11 | { 12 | uid: 'u10000', 13 | language: 'zh-CN' 14 | } 15 | ``` -------------------------------------------------------------------------------- /client/test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "assert": true, 7 | "expect": true, 8 | "should": true, 9 | "__static": true 10 | }, 11 | "rules": { 12 | "func-names": 0, 13 | "prefer-arrow-callback": 0 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Hola 2 | 3 | #### Build Setup 4 | 5 | ``` bash 6 | # install dependencies 7 | npm install 8 | 9 | # serve with hot reload at localhost:9080 10 | npm run dev 11 | 12 | # build electron application for production 13 | npm run build 14 | 15 | # run unit tests 16 | npm test 17 | 18 | # lint all JS/Vue component files in `src/` 19 | npm run lint 20 | ``` 21 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | ### Build Steps 2 | 3 | ```bash 4 | # now at server root folder 5 | 6 | # start database server 7 | # launched at http://localhost:27017/ 8 | mkdir -p .db && mongod --dbpath ./.db 9 | 10 | # new terminal tab 11 | # start redis-server 12 | redis-server 13 | 14 | # new terminal tab 15 | # install dependency packs & start app server 16 | npm install && npm start 17 | ``` -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Main.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | userInfo: '' 3 | }; 4 | 5 | const mutations = { 6 | SET_USERINFO(state, obj) { 7 | state.userInfo = obj; 8 | } 9 | }; 10 | 11 | const actions = { 12 | someAsyncTask({ commit }) { 13 | // do something async 14 | commit('INCREMENT_MAIN_COUNTER'); 15 | } 16 | }; 17 | 18 | export default { 19 | state, 20 | mutations, 21 | actions 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Function.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | currentFunction: '' 3 | }; 4 | 5 | const mutations = { 6 | CURRENT_FUNCTION(state, flag) { 7 | state.currentFunction = flag; 8 | } 9 | }; 10 | 11 | const actions = { 12 | someAsyncTask({ commit }) { 13 | // do something async 14 | commit('INCREMENT_MAIN_COUNTER'); 15 | } 16 | }; 17 | 18 | export default { 19 | state, 20 | mutations, 21 | actions 22 | }; 23 | -------------------------------------------------------------------------------- /client/test/unit/specs/LandingPage.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import LandingPage from '@/components/LandingPage'; 3 | 4 | describe('LandingPage.vue', () => { 5 | it('should render correct contents', () => { 6 | const vm = new Vue({ 7 | el: document.createElement('div'), 8 | render: h => h(LandingPage), 9 | }).$mount(); 10 | 11 | expect(vm.$el.querySelector('.title').textContent).to.contain('Welcome to your new project!'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/file-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/audio-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/video-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/system-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/renderer/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | const files = require.context('./modules', false, /\.js$/); 5 | const modules = {}; 6 | 7 | files.keys().forEach(key => { 8 | if (key === './index.js') { 9 | return; 10 | } 11 | modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default; 12 | }); 13 | 14 | Vue.use(Vuex); 15 | 16 | export default new Vuex.Store({ 17 | modules, 18 | strict: process.env.NODE_ENV !== 'production' 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/main/service/global.js: -------------------------------------------------------------------------------- 1 | const Store = require('electron-store'); 2 | const socketClient = require('socket.io-client'); 3 | const path = require('path'); 4 | 5 | if (process.env.NODE_ENV !== 'development') { 6 | global.__static = path.join(__dirname, '/static') 7 | .replace(/\\/g, '\\\\'); 8 | } 9 | 10 | global.store = new Store({ 11 | name: 'hola.config', 12 | encryptionKey: 'hola' 13 | }); 14 | 15 | global.socket = socketClient('http://localhost:3000', { 16 | reconnection: true, 17 | }); 18 | 19 | global.isAllowLogin = false; 20 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/text-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 29 | 30 | -------------------------------------------------------------------------------- /client/src/renderer/lang/index.js: -------------------------------------------------------------------------------- 1 | const yaml = require('js-yaml'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const langPath = path.join(__dirname, './langs'); 5 | 6 | const langArr = fs.readdirSync(langPath); 7 | const langObj = {}; 8 | 9 | if (langArr.length) { 10 | langArr.forEach(el => { 11 | let fileStr = fs.readFileSync(`${langPath}/${el}`, 'utf8'); 12 | 13 | langObj[path.parse(el).name] = yaml.safeLoad(fileStr); 14 | }); 15 | } 16 | 17 | // example: 18 | // { 19 | // 'zh-CN': [Object], 20 | // 'en': [Object] 21 | // } 22 | export default langObj; 23 | -------------------------------------------------------------------------------- /client/test/unit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | Vue.config.devtools = false 3 | Vue.config.productionTip = false 4 | 5 | // require all test files (files that ends with .spec.js) 6 | const testsContext = require.context('./specs', true, /\.spec$/) 7 | testsContext.keys().forEach(testsContext) 8 | 9 | // require all src files except main.js for coverage. 10 | // you can also change this to match only the subset of files that 11 | // you want coverage for. 12 | const srcContext = require.context('../../src/renderer', true, /^\.\/(?!main(\.js)?$)/) 13 | srcContext.keys().forEach(srcContext) 14 | -------------------------------------------------------------------------------- /server/utils/log.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const clog = console.log; 3 | 4 | const INFO_COLOR = '#5C6270'; 5 | const SUCCESS_COLOR = '#67C23A'; 6 | const WARNING_COLOR = '#E6A23C'; 7 | const ERROR_COLOR = '#F56C6C'; 8 | 9 | const log = { 10 | info(msg) { 11 | clog(chalk.hex(INFO_COLOR)(msg)); 12 | }, 13 | success(msg) { 14 | clog(chalk.hex(SUCCESS_COLOR)(msg)); 15 | }, 16 | 17 | warning(msg) { 18 | clog(chalk.hex(WARNING_COLOR)(msg)); 19 | }, 20 | 21 | error(msg) { 22 | clog(chalk.hex(ERROR_COLOR)(msg)); 23 | } 24 | }; 25 | 26 | module.exports = log; 27 | -------------------------------------------------------------------------------- /server/controllers/index.js: -------------------------------------------------------------------------------- 1 | const login = require('./modules/login'); 2 | const chat = require('./modules/chat'); 3 | const chatMessage = require('./modules/chat-message'); 4 | const user = require('./modules/user'); 5 | const group = require('./modules/group'); 6 | const category = require('./modules/category'); 7 | const contacts = require('./modules/contacts'); 8 | const store = require('./modules/store'); 9 | const webrtc = require('./modules/webrtc'); 10 | 11 | module.exports = { 12 | login, 13 | chat, 14 | chatMessage, 15 | user, 16 | group, 17 | category, 18 | contacts, 19 | store, 20 | webrtc 21 | }; 22 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hola_server", 3 | "version": "0.0.0", 4 | "author": "percy507 ", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "nodemon ./app.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "license": "MIT", 13 | "dependencies": { 14 | "koa": "^2.5.0", 15 | "koa-mount": "^3.0.0", 16 | "koa-static": "^4.0.2", 17 | "mongoose": "^5.0.11", 18 | "redis": "^2.8.0", 19 | "socket.io": "^2.0.4" 20 | }, 21 | "devDependencies": { 22 | "nodemon": "^1.17.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Contact.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | currentCategoryId: '', 3 | currentContact: '' 4 | }; 5 | 6 | const mutations = { 7 | SET_CURRENT_CATEGORY_ID(state, id) { 8 | // 1234 9 | state.currentCategoryId = id; 10 | }, 11 | CURRENT_CONTACT(state, obj) { 12 | // { 13 | // id: 1234, 14 | // isGroup: false 15 | // } 16 | state.currentContact = obj; 17 | } 18 | }; 19 | 20 | const actions = { 21 | someAsyncTask({ commit }) { 22 | // do something async 23 | commit('INCREMENT_MAIN_COUNTER'); 24 | } 25 | }; 26 | 27 | export default { 28 | state, 29 | mutations, 30 | actions 31 | }; 32 | -------------------------------------------------------------------------------- /client/src/renderer/styles/_variables.styl: -------------------------------------------------------------------------------- 1 | $app-width = 885px; 2 | $app-height = 550px; 3 | $app-bg = #F6F9FD; 4 | 5 | $sidebar-width = 300px; 6 | 7 | $menu-bar-width = 60px; 8 | $menu-bar-bg = #323A43; 9 | 10 | $search-bar-width = $sidebar-width - $menu-bar-width; 11 | $search-bar-height = 50px; 12 | $search-bar-bg = #353A42; 13 | 14 | $menu-content-bg = #383F47; 15 | 16 | 17 | $content-width = $app-width - $sidebar-width; 18 | 19 | 20 | 21 | 22 | 23 | 24 | // message box 25 | $left-message-box-bg-color = #6c7989; 26 | $left-message-box-font-color = #e0e0e0; 27 | 28 | $right-message-box-bg-color = #525C68; 29 | $right-message-box-font-color = #d6d6d6; -------------------------------------------------------------------------------- /client/src/renderer/components/content/_parts/default-page.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | 37 | -------------------------------------------------------------------------------- /server/utils/redis.js: -------------------------------------------------------------------------------- 1 | const Redis = require('redis'); 2 | const { 3 | promisify 4 | } = require('util'); 5 | const log = require('./log'); 6 | 7 | const client = Redis.createClient({ 8 | host: '127.0.0.1', 9 | port: 6379 10 | }); 11 | 12 | const get = promisify(client.get) 13 | .bind(client); 14 | const set = promisify(client.set) 15 | .bind(client); 16 | const del = promisify(client.del) 17 | .bind(client); 18 | 19 | client.on('connect', () => { 20 | log.success('Redis: connect success'); 21 | }); 22 | 23 | client.on('reconnecting', () => { 24 | log.info('Redis: reconnecting...'); 25 | }); 26 | 27 | client.on('error', err => { 28 | log.error(err); 29 | }); 30 | 31 | module.exports = { 32 | get, 33 | set, 34 | del 35 | }; 36 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "env": { 4 | "test": { 5 | "presets": [ 6 | ["env", { 7 | "targets": { "node": 7 } 8 | }], 9 | "stage-0" 10 | ], 11 | "plugins": ["istanbul"] 12 | }, 13 | "main": { 14 | "presets": [ 15 | ["env", { 16 | "targets": { "node": 7 } 17 | }], 18 | "stage-0" 19 | ] 20 | }, 21 | "renderer": { 22 | "presets": [ 23 | ["env", { 24 | "modules": false 25 | }], 26 | "stage-0" 27 | ] 28 | }, 29 | "web": { 30 | "presets": [ 31 | ["env", { 32 | "modules": false 33 | }], 34 | "stage-0" 35 | ] 36 | } 37 | }, 38 | "plugins": ["transform-runtime"] 39 | } 40 | -------------------------------------------------------------------------------- /client/src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | app_client 6 | <% if (htmlWebpackPlugin.options.nodeModules) { %> 7 | 8 | 11 | <% } %> 12 | 13 | 14 |
15 | 16 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /server/models/message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const MessageSchema = mongoose.Schema({ 3 | uuid: { 4 | type: String, 5 | required: [true, 'message-private uuid is required'] 6 | }, 7 | from: { 8 | type: String, 9 | required: [true, 'message-private from is required'] 10 | }, 11 | to: { 12 | type: String, 13 | required: [true, 'message-private to is required'] 14 | }, 15 | type: { 16 | type: String, 17 | required: [true, 'message-private type is required'] 18 | }, 19 | time: mongoose.Schema.Types.Mixed, 20 | content: mongoose.Schema.Types.Mixed 21 | }); 22 | 23 | MessageSchema.statics = { 24 | async saveMessage(message) { 25 | let newMessage = new this(message); 26 | 27 | await newMessage.save(); 28 | } 29 | }; 30 | 31 | module.exports = mongoose.model('Message', MessageSchema); 32 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/settings/parts/system-message.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 25 | 26 | 40 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/functions/parts/manage-category.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 25 | 26 | 40 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/functions/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 39 | 40 | 42 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/settings/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 39 | 40 | 42 | -------------------------------------------------------------------------------- /client/src/renderer/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueExtend from './vue-extend'; 3 | 4 | import App from './app'; 5 | import router from './router'; 6 | import store from './store'; 7 | 8 | // import global initial style 9 | import './styles/style.styl'; 10 | 11 | // register shortcut for opening devtools 12 | // import { remote } from 'electron'; 13 | 14 | // remote.globalShortcut.register('CommandOrControl+Option+J', () => { 15 | // remote.BrowserWindow.getFocusedWindow().webContents.openDevTools(); 16 | // }); 17 | 18 | // window.addEventListener('beforeunload', () => { 19 | // remote.globalShortcut.unregisterAll(); 20 | // }); 21 | 22 | Vue.use(VueExtend); 23 | 24 | // stop drag file to the app 25 | document.addEventListener('dragover', function(event) { 26 | event.preventDefault(); 27 | }); 28 | document.addEventListener('drop', function(event) { 29 | event.preventDefault(); 30 | }); 31 | 32 | /* eslint-disable no-new */ 33 | new Vue({ 34 | components: { App }, 35 | router, 36 | store, 37 | template: '' 38 | }).$mount('#app'); 39 | -------------------------------------------------------------------------------- /client/src/main/index.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically and only for development. It installs 3 | * `electron-debug` & `vue-devtools`. There shouldn't be any need to 4 | * modify this file, but it can be used to extend your development 5 | * environment. 6 | */ 7 | 8 | /* eslint-disable */ 9 | 10 | // Set environment for development 11 | process.env.NODE_ENV = 'development'; 12 | 13 | // Install `electron-debug` 14 | require('electron-debug')({ showDevTools: false }); 15 | 16 | require('electron').app.on('ready', () => { 17 | let installExtension = require('electron-devtools-installer'); 18 | 19 | // Install `vue-devtools` 20 | installExtension 21 | .default(installExtension.VUEJS_DEVTOOLS) 22 | .then(extension => { 23 | console.log(`${extension} installed successfull!`); 24 | }) 25 | .catch(err => { 26 | console.log('Unable to install `vue-devtools`: \n', err); 27 | }); 28 | 29 | // Install `devtron` 30 | require('devtron').install(); 31 | }); 32 | 33 | // Require `main` process to boot app 34 | require('./index'); 35 | -------------------------------------------------------------------------------- /server/controllers/modules/store.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'store-image': storeImageFile 3 | }; 4 | 5 | async function storeImageFile({ 6 | base64Data 7 | }, callback) { 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const uuid = generateUUID(); 11 | const folder = 'images'; 12 | const folderPath = path.join(__dirname, `../../upload/${folder}`); 13 | const filePath = path.join(__dirname, `../../upload/${folder}/${uuid}.png`); 14 | const url = `http://localhost:3000/upload/${folder}/${uuid}.png`; 15 | 16 | if (!fs.existsSync(folderPath)) { 17 | fs.mkdirSync(folderPath); 18 | } 19 | 20 | base64Data = base64Data.replace(/^data:image\/.+?;base64,/, ''); 21 | 22 | fs.writeFileSync(filePath, base64Data, 'base64'); 23 | 24 | callback({ 25 | code: 0, 26 | data: { 27 | url 28 | }, 29 | message: '上传图片成功' 30 | }); 31 | } 32 | 33 | function generateUUID() { 34 | let s4 = function() { 35 | return Math.floor((1 + Math.random()) * 0x10000) 36 | .toString(16) 37 | .substring(1); 38 | }; 39 | 40 | return `${s4() + s4()}-${new Date().getTime()}`; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Percy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Percy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 44 | 45 | 47 | -------------------------------------------------------------------------------- /client/src/renderer/styles/style.styl: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | html { 8 | user-select: none; 9 | -webkit-user-drag: none; 10 | cursor: default; 11 | } 12 | 13 | body,pre,input,textarea { 14 | font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', STHeiti, 'Microsoft YaHei', 'Microsoft JhengHei', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Source Han Sans CN', 'Noto Sans SC', 'Source Han Sans TC', 'Noto Sans CJK TC', 'WenQuanYi Micro Hei', SimSun, sans-serif; 15 | line-height: 1.7; 16 | } 17 | 18 | // disable the ability of user drag img、text in input field 19 | input, textarea, select, button, img { 20 | -webkit-user-drag: none; 21 | } 22 | 23 | // remove the arrows from input[type=“number”] 24 | input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-outer-spin-button { 25 | -webkit-appearance: none; 26 | margin: 0; 27 | } 28 | 29 | /* 30 | define logo font 31 | */ 32 | @font-face { 33 | font-family: 'local-Flavors'; 34 | src: url('./fonts/custom-fonts/Flavors-Regular.ttf'); 35 | } 36 | 37 | // import fonts 38 | @import './fonts/style.css'; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "beautify.language": { 3 | "js": { 4 | "type": ["javascript", "json"], 5 | "filename": [".jshintrc", ".jsbeautify"] 6 | }, 7 | "css": ["css", "scss"], 8 | "html": [] 9 | }, 10 | "beautify.config": { 11 | "indent_size": 2, 12 | "indent_char": " ", 13 | "end_with_newline": true, 14 | "css": { 15 | "indent_size": 2 16 | }, 17 | "js": { 18 | "break_chained_methods": true 19 | } 20 | }, 21 | "editor.dragAndDrop": true, 22 | "editor.fontSize": 16, 23 | "editor.formatOnSave": true, 24 | "editor.minimap.enabled": true, 25 | "editor.renderControlCharacters": false, 26 | "editor.renderLineHighlight": "none", 27 | "editor.renderWhitespace": "none", 28 | "editor.tabSize": 2, 29 | "editor.wordWrap": "on", 30 | "files.eol": "\n", 31 | "html.format.wrapAttributes": "force-aligned", 32 | "prettier.tabWidth": 2, 33 | "prettier.singleQuote": true, 34 | "vetur.format.defaultFormatter.html": "js-beautify-html", 35 | "vetur.format.defaultFormatterOptions": { 36 | "js-beautify-html": { 37 | "indent_size": 2, 38 | "wrap_attributes": "force-aligned" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/contacts/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 54 | 55 | 59 | -------------------------------------------------------------------------------- /client/.electron-vue/dev-client.js: -------------------------------------------------------------------------------- 1 | const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 2 | 3 | hotClient.subscribe(event => { 4 | /** 5 | * Reload browser when HTMLWebpackPlugin emits a new index.html 6 | * 7 | * Currently disabled until jantimon/html-webpack-plugin#680 is resolved. 8 | * https://github.com/SimulatedGREG/electron-vue/issues/437 9 | * https://github.com/jantimon/html-webpack-plugin/issues/680 10 | */ 11 | // if (event.action === 'reload') { 12 | // window.location.reload() 13 | // } 14 | 15 | /** 16 | * Notify `mainWindow` when `main` process is compiling, 17 | * giving notice for an expected reload of the `electron` process 18 | */ 19 | if (event.action === 'compiling') { 20 | document.body.innerHTML += ` 21 | 34 | 35 |
36 | Compiling Main Process... 37 |
38 | ` 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/_parts/function-item.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 36 | 37 | 66 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/functions/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 54 | 55 | 59 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/settings/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 54 | 55 | 59 | -------------------------------------------------------------------------------- /client/src/renderer/lang/langs/zh-CN.yml: -------------------------------------------------------------------------------- 1 | login: 2 | phone_hint: '请输入您的手机号' 3 | authcode_hint: '请输入验证码' 4 | authcode_button: '获取验证码' 5 | submit_button: '注册/登录' 6 | error: 7 | title: '错误' 8 | phone_error: '请输入格式正确的手机号' 9 | login_error: '验证码错误' 10 | 11 | sidebar: 12 | search: ' 搜索' 13 | groupDissolve: '此群已解散' 14 | 15 | chat: 16 | groupMember: '群成员' 17 | 18 | contacts: 19 | category: 20 | my_groups: '我的群组' 21 | default_category: '默认分组' 22 | friend_info_titles: 23 | nickname: '昵称' 24 | signature: '个性签名' 25 | alias: '备注' 26 | phone: '手机号' 27 | gender: '性别' 28 | age: '年龄' 29 | email: '邮箱' 30 | birthTime: '出生日期' 31 | address: '现居地址' 32 | selfIntro: '个人简介' 33 | group_info_titles: 34 | nickname: '群名称' 35 | gid: '群编号' 36 | createdAt: '创建日期' 37 | groupInfo: '群公告' 38 | members: '群成员' 39 | info_content: 40 | default: '暂无' 41 | 42 | functions: 43 | add_contact: 44 | main_title: '添加好友 / 群组' 45 | friend: '好友' 46 | group: '群组' 47 | create_group: 48 | main_title: '创建群组' 49 | manage_category: 50 | main_title: '好友分组管理' 51 | 52 | settings: 53 | user_profile_setting: 54 | main_title: '个人资料设置' 55 | software_setting: 56 | main_title: '软件设置' 57 | system_message: 58 | main_title: '系统消息' 59 | 60 | form: 61 | gender: 62 | man: '男' 63 | woman: '女' 64 | changeAvatar: '更换头像' 65 | save: '保存' 66 | 67 | error: 68 | imageMaxSize: '图片文件大小不得大于 500KB' -------------------------------------------------------------------------------- /client/src/renderer/vue-extend/index.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | import Chance from 'chance'; 3 | import lang from '../lang'; 4 | import utils from '../utils'; 5 | 6 | const remote = electron.remote; 7 | const uid = remote.getGlobal('store').get('uid'); 8 | const language = remote.getGlobal('store').get('lang'); 9 | 10 | function install(Vue) { 11 | // do not show the tips of the develop mode 12 | Vue.config.productionTip = false; 13 | 14 | /* 15 | * @desc: bind some useful methods to the prototype of Vue 16 | * @use: this.$[method] 17 | */ 18 | Object.keys(utils).forEach(key => { 19 | Vue.prototype[`$${key}`] = utils[key]; 20 | }); 21 | 22 | /* 23 | * @desc: use electron 24 | * @use: this.$electron 25 | */ 26 | Vue.prototype.$electron = electron; 27 | 28 | /* 29 | * @desc: multi-languages support 30 | * @use: this.$electron 31 | */ 32 | Vue.prototype.$lang = lang[language || 'zh-CN']; 33 | 34 | /* 35 | * @desc: bind uid 36 | * @use: this.$uid 37 | */ 38 | Vue.prototype.$uid = uid || null; 39 | 40 | /* 41 | * @desc: tools for generate random data 42 | * @use: this.$chance 43 | */ 44 | Vue.prototype.$chance = new Chance(); 45 | 46 | /* 47 | * @desc: socket.io for communicate 48 | * @use: this.$socket 49 | */ 50 | Vue.prototype.$socket = remote.getGlobal('socket'); 51 | 52 | /* 53 | * @desc: to store some config to local file 54 | * @use: this.$electronStore 55 | */ 56 | Vue.prototype.$electronStore = remote.getGlobal('store'); 57 | } 58 | 59 | export default install; 60 | -------------------------------------------------------------------------------- /server/controllers/modules/chat.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-recent-chat-info': getRecentContactsInfo 3 | }; 4 | 5 | const CategoryModel = require('../../models/category'); 6 | const GroupModel = require('../../models/group'); 7 | const UserModel = require('../../models/user'); 8 | 9 | async function getRecentContactsInfo({ 10 | uid, 11 | recentChatIdArr 12 | }, callback) { 13 | let resultArr = []; 14 | let len = recentChatIdArr.length; 15 | 16 | for (let i = 0; i < len; i++) { 17 | let el = recentChatIdArr[i]; 18 | let obj = {}; 19 | let _uid = el.uid; 20 | let _gid = el.gid; 21 | 22 | if (_uid) { 23 | let data = await UserModel.where({ 24 | uid: _uid 25 | }) 26 | .select({ 27 | uid: 1, 28 | avatar: 1, 29 | nickname: 1 30 | }) 31 | .lean() 32 | .exec(); 33 | 34 | obj = data[0]; 35 | obj.alias = await CategoryModel.getFriendAliasFromCategory({ 36 | uid, 37 | friendUid: _uid 38 | }); 39 | 40 | obj.lastMessage = 'TODO the last message'; 41 | } 42 | 43 | if (_gid) { 44 | let data = await GroupModel.where({ 45 | gid: _gid 46 | }) 47 | .select({ 48 | gid: 1, 49 | avatar: 1, 50 | nickname: 1, 51 | status: 1 52 | }) 53 | .lean() 54 | .exec(); 55 | 56 | obj = data[0]; 57 | obj.lastMessage = 'TODO the last message'; 58 | } 59 | 60 | resultArr.push(obj); 61 | } 62 | 63 | callback({ 64 | code: 0, 65 | data: resultArr, 66 | message: '' 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /client/src/renderer/lang/langs/en.yml: -------------------------------------------------------------------------------- 1 | login: 2 | phone_hint: 'phone number' 3 | authcode_hint: 'authcode' 4 | authcode_button: 'Authcode' 5 | submit_button: 'SIGN IN / SIGN OUT' 6 | error: 7 | title: 'Error' 8 | phone_error: 'Please enter vaild phone number' 9 | login_error: 'wrong authcode' 10 | 11 | sidebar: 12 | search: ' Search' 13 | groupDissolve: 'Group has dissolved' 14 | 15 | chat: 16 | groupMember: 'Members' 17 | 18 | contacts: 19 | category: 20 | my_groups: 'My Groups' 21 | default_category: 'Default Category' 22 | friend_info_titles: 23 | nickname: 'Nickname' 24 | signature: 'Signature' 25 | alias: 'Remark' 26 | phone: 'Phone' 27 | gender: 'Gender' 28 | age: 'Age' 29 | email: 'Email' 30 | birthTime: 'Birthday' 31 | address: 'Address' 32 | selfIntro: 'Profile' 33 | group_info_titles: 34 | nickname: 'Name' 35 | gid: 'ID' 36 | createdAt: 'Create' 37 | groupInfo: 'Notice' 38 | members: 'Member' 39 | info_content: 40 | default: 'none' 41 | 42 | functions: 43 | add_contact: 44 | main_title: 'Add New Friend / Group' 45 | friend: 'Friends' 46 | group: 'Groups' 47 | create_group: 48 | main_title: 'Create Group' 49 | manage_category: 50 | main_title: 'Manage Category' 51 | 52 | settings: 53 | user_profile_setting: 54 | main_title: 'Profile Setting' 55 | software_setting: 56 | main_title: 'Software Setting' 57 | system_message: 58 | main_title: 'System Message' 59 | 60 | form: 61 | gender: 62 | man: 'Man' 63 | woman: 'Woman' 64 | changeAvatar: 'Change' 65 | save: 'Save' 66 | 67 | error: 68 | imageMaxSize: 'Image file size must less than 500KB' -------------------------------------------------------------------------------- /client/src/renderer/utils/index.js: -------------------------------------------------------------------------------- 1 | function formatTimestamp(timestamp) { 2 | let time = Number.parseInt(timestamp, 10); 3 | let date = new Date(time); 4 | 5 | let year = date.getFullYear(); 6 | let month = date.getMonth() + 1; 7 | let day = date.getDate(); 8 | // let hour = date.getHours(); 9 | // let minute = date.getMinutes(); 10 | // let second = date.getSeconds(); 11 | 12 | month = month > 9 ? month : `0${month}`; 13 | day = day > 9 ? day : `0${day}`; 14 | // hour = hour > 9 ? hour : `0${hour}`; 15 | // minute = minute > 9 ? minute : `0${minute}`; 16 | // second = second > 9 ? second : `0${second}`; 17 | 18 | // return `${year}/${month}/${day} ${hour}:${minute}:${second}`; 19 | return `${year}/${month}/${day}`; 20 | } 21 | 22 | function generateUUID() { 23 | let s4 = function() { 24 | return Math.floor((1 + Math.random()) * 0x10000) 25 | .toString(16) 26 | .substring(1); 27 | }; 28 | 29 | return `${s4() + s4()}-${new Date().getTime()}`; 30 | } 31 | 32 | function capitalizeFirstLetter(str) { 33 | return str.charAt(0).toUpperCase() + str.slice(1); 34 | } 35 | 36 | function mergeObjects(targetObj, ...objArr) { 37 | if (targetObj.toString() !== '[object Object]') { 38 | return targetObj; 39 | } 40 | 41 | if (objArr.length) { 42 | objArr.forEach(obj => { 43 | if (obj.toString() !== '[object Object]') { 44 | return; 45 | } 46 | 47 | Object.keys(obj).forEach(key => { 48 | if (targetObj.hasOwnProperty(key)) { 49 | targetObj[key] = obj[key]; 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | return targetObj; 56 | } 57 | 58 | export default { 59 | formatTimestamp, 60 | generateUUID, 61 | capitalizeFirstLetter, 62 | mergeObjects 63 | }; 64 | -------------------------------------------------------------------------------- /server/controllers/modules/category.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-category-list': getCategoryList 3 | }; 4 | 5 | const CategoryModel = require('../../models/category'); 6 | const GroupModel = require('../../models/group'); 7 | const UserModel = require('../../models/user'); 8 | 9 | async function getCategoryList(uid, callback) { 10 | let categoryArr = await CategoryModel.getCategoryListByUid(uid); 11 | let categoryLen = categoryArr.length; 12 | 13 | for (let m = 0; m < categoryLen; m++) { 14 | let category = categoryArr[m]; 15 | 16 | if (category.cid !== 0) { 17 | // get friends info 18 | let friendArr = category.friends; 19 | let friendLen = friendArr.length; 20 | 21 | if (friendArr instanceof Array && friendLen) { 22 | for (let i = 0; i < friendLen; i++) { 23 | let friend = friendArr[i]; 24 | let userInfo = await UserModel.findOne({ 25 | uid: friend.uid 26 | }) 27 | .exec(); 28 | 29 | friend.nickname = userInfo.nickname; 30 | friend.avatar = userInfo.avatar; 31 | friend.signature = userInfo.signature; 32 | } 33 | } 34 | } else { 35 | // get group info 36 | let groupArr = category.groups; 37 | let groupLen = groupArr.length; 38 | 39 | if (groupArr instanceof Array && groupLen) { 40 | for (let i = 0; i < groupLen; i++) { 41 | let group = groupArr[i]; 42 | let groupInfo = await GroupModel.findOne({ 43 | gid: group.gid 44 | }) 45 | .exec(); 46 | 47 | group.nickname = groupInfo.nickname; 48 | group.avatar = groupInfo.avatar; 49 | } 50 | } 51 | } 52 | } 53 | 54 | callback(categoryArr); 55 | } 56 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/search-bar.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 39 | 40 | 80 | -------------------------------------------------------------------------------- /client/test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const merge = require('webpack-merge') 5 | const webpack = require('webpack') 6 | 7 | const baseConfig = require('../../.electron-vue/webpack.renderer.config') 8 | const projectRoot = path.resolve(__dirname, '../../src/renderer') 9 | 10 | // Set BABEL_ENV to use proper preset config 11 | process.env.BABEL_ENV = 'test' 12 | 13 | let webpackConfig = merge(baseConfig, { 14 | devtool: '#inline-source-map', 15 | plugins: [ 16 | new webpack.DefinePlugin({ 17 | 'process.env.NODE_ENV': '"testing"' 18 | }) 19 | ] 20 | }) 21 | 22 | // don't treat dependencies as externals 23 | delete webpackConfig.entry 24 | delete webpackConfig.externals 25 | delete webpackConfig.output.libraryTarget 26 | 27 | // apply vue option to apply isparta-loader on js 28 | webpackConfig.module.rules 29 | .find(rule => rule.use.loader === 'vue-loader').use.options.loaders.js = 'babel-loader' 30 | 31 | module.exports = config => { 32 | config.set({ 33 | browsers: ['visibleElectron'], 34 | client: { 35 | useIframe: false 36 | }, 37 | coverageReporter: { 38 | dir: './coverage', 39 | reporters: [ 40 | { type: 'lcov', subdir: '.' }, 41 | { type: 'text-summary' } 42 | ] 43 | }, 44 | customLaunchers: { 45 | 'visibleElectron': { 46 | base: 'Electron', 47 | flags: ['--show'] 48 | } 49 | }, 50 | frameworks: ['mocha', 'chai'], 51 | files: ['./index.js'], 52 | preprocessors: { 53 | './index.js': ['webpack', 'sourcemap'] 54 | }, 55 | reporters: ['spec', 'coverage'], 56 | singleRun: true, 57 | webpack: webpackConfig, 58 | webpackMiddleware: { 59 | noInfo: true 60 | } 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const UserSchema = mongoose.Schema({ 3 | uid: { 4 | type: String, 5 | required: [true, 'uid is required'] 6 | }, 7 | avatar: { 8 | type: String, 9 | default: 'http://localhost:3000/upload/default/default-user-avatar.png' 10 | }, 11 | nickname: { 12 | type: String, 13 | maxlength: 15 14 | }, 15 | signature: { 16 | type: String, 17 | maxlength: 50 18 | }, 19 | gender: String, 20 | age: Number, 21 | phone: { 22 | type: String, 23 | required: [true, 'phone number is required'] 24 | }, 25 | email: String, 26 | tags: [{ 27 | type: String 28 | }], 29 | address: { 30 | country: String, 31 | province: String, 32 | city: String, 33 | detail: String 34 | }, 35 | birthTime: Date, 36 | selfIntro: { 37 | type: String, 38 | maxlength: 150 39 | }, 40 | isOnline: Boolean 41 | }, { 42 | // auto generate `createdAt` and `updatedAt` field 43 | timestamps: true 44 | }); 45 | 46 | UserSchema.statics = { 47 | async registerUser(phone) { 48 | // this equal to UserModel 49 | let user = await this.where('phone') 50 | .equals(phone) 51 | .exec(); 52 | 53 | user = user[0]; 54 | 55 | if (!user) { 56 | let len = await this.count() 57 | .exec(); 58 | let info = { 59 | uid: `u${10000 + len}`, 60 | phone: `${phone}` 61 | }; 62 | 63 | user = new this(info); 64 | 65 | await user.save(); 66 | } 67 | 68 | return user.uid; 69 | }, 70 | async getUserInfo(uid) { 71 | let userInfo = await this.where({ 72 | uid 73 | }) 74 | .exec(); 75 | 76 | userInfo = userInfo[0]; 77 | 78 | return userInfo; 79 | } 80 | }; 81 | 82 | module.exports = mongoose.model('User', UserSchema); 83 | -------------------------------------------------------------------------------- /server/models/group.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const GroupSchema = mongoose.Schema({ 3 | gid: { 4 | type: String, 5 | required: [true, 'group-gid is required'] 6 | }, 7 | ownerUid: { 8 | type: String, 9 | required: [true, 'group-ownerUid is required'] 10 | }, 11 | status: { 12 | type: String, 13 | default: 'active' 14 | }, 15 | avatar: { 16 | type: String, 17 | default: 'http://localhost:3000/upload/default/default-group-avatar.png' 18 | }, 19 | nickname: { 20 | type: String, 21 | maxlength: 15 22 | }, 23 | groupInfo: { 24 | type: String, 25 | maxlength: 150 26 | }, 27 | tags: { 28 | type: Array, 29 | default: [] 30 | }, 31 | members: { 32 | type: Array, 33 | default: [] 34 | } 35 | }, { 36 | // auto generate `createdAt` and `updatedAt` field 37 | timestamps: true 38 | }); 39 | 40 | GroupSchema.statics = { 41 | async createGroup({ 42 | uid, 43 | groupInfo 44 | }) { 45 | // this equal to GroupModel 46 | let len = await this.count() 47 | .exec(); 48 | let info = { 49 | gid: `g${10000 + len}`, 50 | ownerUid: uid, 51 | ...groupInfo 52 | }; 53 | let group = new this(info); 54 | 55 | await group.save(); 56 | 57 | return group.gid; 58 | }, 59 | async addNewMember({ 60 | uid, 61 | gid 62 | }) { 63 | let groups = await this.where({ 64 | gid 65 | }) 66 | .exec(); 67 | let group = groups[0]; 68 | let members = group.members.slice(); 69 | 70 | members.push({ 71 | uid, 72 | flag: '' 73 | }); 74 | 75 | await this.where({ 76 | gid 77 | }) 78 | .update({ 79 | members 80 | }) 81 | .exec(); 82 | } 83 | }; 84 | 85 | module.exports = mongoose.model('Group', GroupSchema); 86 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/parts/parts/group-member-item.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | 32 | 88 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const OFF = 0; 2 | const WARN = 1; 3 | const ERROR = 2; 4 | 5 | module.exports = { 6 | env: { 7 | es6: true, 8 | browser: true, 9 | node: true 10 | }, 11 | 12 | parserOptions: { 13 | ecmaVersion: 8, 14 | sourceType: 'module' 15 | }, 16 | 17 | plugins: ['html'], 18 | 19 | extends: 'eslint:recommended', 20 | 21 | rules: { 22 | 'no-console': OFF, 23 | 'no-extra-parens': ERROR, 24 | 'consistent-return': ERROR, 25 | curly: ERROR, 26 | 'default-case': ERROR, 27 | eqeqeq: ERROR, 28 | 'no-caller': ERROR, 29 | 'no-else-return': ERROR, 30 | 'no-empty-function': ERROR, 31 | 'no-eq-null': ERROR, 32 | 'no-eval': ERROR, 33 | 'no-extra-bind': ERROR, 34 | 'no-extra-label': ERROR, 35 | 'no-floating-decimal': ERROR, 36 | 'no-labels': ERROR, 37 | 'no-lone-blocks': ERROR, 38 | 39 | 'no-multi-spaces': ERROR, 40 | 'no-new-func': ERROR, 41 | 'no-new-wrappers': ERROR, 42 | 'no-octal-escape': ERROR, 43 | 'no-param-reassign': ERROR, 44 | 'no-proto': ERROR, 45 | 'no-return-assign': ERROR, 46 | 'no-return-await': ERROR, 47 | 'no-self-compare': ERROR, 48 | 'no-sequences': ERROR, 49 | 'no-throw-literal': ERROR, 50 | 'no-unmodified-loop-condition': ERROR, 51 | 'no-unused-expressions': ERROR, 52 | 'no-with': ERROR, 53 | 54 | 'prefer-promise-reject-errors': WARN, 55 | 56 | radix: ERROR, 57 | 58 | 'require-await': ERROR, 59 | 60 | 'wrap-iife': ERROR, 61 | 'no-undefined': ERROR, 62 | 63 | 'new-cap': ERROR, 64 | 'new-parens': ERROR, 65 | 'no-array-constructor': ERROR, 66 | 'no-new-object': ERROR, 67 | 'no-multi-assign': ERROR, 68 | 69 | semi: [ERROR, 'always'], 70 | 71 | 'no-duplicate-imports': ERROR, 72 | 'no-useless-rename': ERROR, 73 | 'no-var': ERROR 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /server/controllers/modules/login.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-authcode': getAuthCode, 3 | login: loginFunc, 4 | 'is-allow-login': detectIsAllowLogin, 5 | logout: logoutFunc 6 | }; 7 | 8 | const UserModel = require('../../models/user'); 9 | const redis = require('../../utils/redis'); 10 | 11 | async function getAuthCode(phone, callback) { 12 | const uid = await UserModel.registerUser(phone); 13 | const redisKey = `${uid}_authcode`; 14 | 15 | let authcode = await redis.get(redisKey); 16 | 17 | if (!authcode) { 18 | authcode = Math.floor(100000 + Math.random() * 900000); 19 | 20 | // expire time: 10 minutes 21 | await redis.set(redisKey, authcode, 'EX', 10 * 60); 22 | 23 | // TODO 为了方便,直接将验证码返回给前端 24 | callback({ 25 | authcode 26 | }); 27 | 28 | // 调用短信服务商接口发短信 29 | // call message service interface 30 | // do something 31 | } else { 32 | // TODO 为了方便,直接将验证码返回给前端 33 | callback({ 34 | authcode 35 | }); 36 | 37 | // 调用短信服务商接口发短信 38 | // call message service interface 39 | // do something 40 | } 41 | } 42 | 43 | async function loginFunc({ 44 | phone, 45 | authcode 46 | }, callback) { 47 | const uid = await UserModel.registerUser(phone); 48 | const serverAuthcode = await redis.get(`${uid}_authcode`); 49 | 50 | let isAllowLogin = false; 51 | 52 | if (Number.parseInt(serverAuthcode, 10) === Number.parseInt(authcode, 10)) { 53 | redis.set(`${uid}_can_login`, `${uid}`, 'EX', 7 * 24 * 60 * 60); 54 | 55 | isAllowLogin = true; 56 | } 57 | 58 | callback({ 59 | isAllowLogin, 60 | uid 61 | }); 62 | } 63 | 64 | async function detectIsAllowLogin(uid, callback) { 65 | let isAllowLogin = await redis.get(`${uid}_can_login`); 66 | 67 | callback({ 68 | isAllowLogin: isAllowLogin ? true : false 69 | }); 70 | } 71 | 72 | async function logoutFunc(uid, callback) { 73 | await redis.del(`${uid}_authcode`); 74 | await redis.del(`${uid}_can_login`); 75 | 76 | callback({ 77 | isLogoutSuccess: true 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /client/src/renderer/store/modules/Chat.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | currentChat: '', 3 | recentChatIdArr: [] 4 | }; 5 | 6 | const getters = { 7 | // 8 | }; 9 | 10 | const mutations = { 11 | CURRENT_CHAT(state, obj) { 12 | state.currentChat = obj; 13 | }, 14 | LOAD_RECENT_CHAT(state) { 15 | let key = 'recentChatIdArr'; 16 | let recentChatIdArr = JSON.parse(localStorage.getItem(key)) || []; 17 | 18 | state.recentChatIdArr = recentChatIdArr; 19 | }, 20 | NEW_CHAT(state, chatObj) { 21 | let key = 'recentChatIdArr'; 22 | let recentChatIdArr = JSON.parse(localStorage.getItem(key)) || []; 23 | let _idName = chatObj.uid ? 'uid' : 'gid'; 24 | let _id = chatObj[_idName]; 25 | let isIdExist = false; 26 | 27 | recentChatIdArr.forEach(el => { 28 | if (el[_idName] === _id) { 29 | isIdExist = true; 30 | } 31 | }); 32 | 33 | if (!isIdExist) { 34 | recentChatIdArr.push(chatObj); 35 | } 36 | 37 | localStorage.setItem(key, JSON.stringify(recentChatIdArr)); 38 | state.recentChatIdArr = recentChatIdArr; 39 | }, 40 | DELETE_CHAT(state, chatObj) { 41 | let key = 'recentChatIdArr'; 42 | let recentChatIdArr = JSON.parse(localStorage.getItem(key)) || []; 43 | let _idName = chatObj.uid ? 'uid' : 'gid'; 44 | let _id = chatObj[_idName]; 45 | let deleteIndex; 46 | 47 | recentChatIdArr.find((el, index) => { 48 | if (el[_idName] === _id) { 49 | deleteIndex = index; 50 | return true; 51 | } 52 | return false; 53 | }); 54 | 55 | // delete the target chat 56 | if (deleteIndex >= 0) { 57 | recentChatIdArr.splice(deleteIndex, 1); 58 | } 59 | 60 | localStorage.setItem(key, JSON.stringify(recentChatIdArr)); 61 | state.recentChatIdArr = recentChatIdArr; 62 | } 63 | }; 64 | 65 | const actions = { 66 | someAsyncTask({ 67 | commit 68 | }) { 69 | // do something async 70 | commit('INCREMENT_MAIN_COUNTER'); 71 | } 72 | }; 73 | 74 | export default { 75 | state, 76 | getters, 77 | mutations, 78 | actions 79 | }; 80 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'main' 4 | 5 | const path = require('path') 6 | const { dependencies } = require('../package.json') 7 | const webpack = require('webpack') 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin') 10 | 11 | let mainConfig = { 12 | entry: { 13 | main: path.join(__dirname, '../src/main/index.js') 14 | }, 15 | externals: [ 16 | ...Object.keys(dependencies || {}) 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js)$/, 22 | enforce: 'pre', 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'eslint-loader', 26 | options: { 27 | formatter: require('eslint-friendly-formatter') 28 | } 29 | } 30 | }, 31 | { 32 | test: /\.js$/, 33 | use: 'babel-loader', 34 | exclude: /node_modules/ 35 | }, 36 | { 37 | test: /\.node$/, 38 | use: 'node-loader' 39 | } 40 | ] 41 | }, 42 | node: { 43 | __dirname: process.env.NODE_ENV !== 'production', 44 | __filename: process.env.NODE_ENV !== 'production' 45 | }, 46 | output: { 47 | filename: '[name].js', 48 | libraryTarget: 'commonjs2', 49 | path: path.join(__dirname, '../dist/electron') 50 | }, 51 | plugins: [ 52 | new webpack.NoEmitOnErrorsPlugin() 53 | ], 54 | resolve: { 55 | extensions: ['.js', '.json', '.node'] 56 | }, 57 | target: 'electron-main' 58 | } 59 | 60 | /** 61 | * Adjust mainConfig for development settings 62 | */ 63 | if (process.env.NODE_ENV !== 'production') { 64 | mainConfig.plugins.push( 65 | new webpack.DefinePlugin({ 66 | '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 67 | }) 68 | ) 69 | } 70 | 71 | /** 72 | * Adjust mainConfig for production settings 73 | */ 74 | if (process.env.NODE_ENV === 'production') { 75 | mainConfig.plugins.push( 76 | new BabiliWebpackPlugin(), 77 | new webpack.DefinePlugin({ 78 | 'process.env.NODE_ENV': '"production"' 79 | }) 80 | ) 81 | } 82 | 83 | module.exports = mainConfig 84 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-item.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 68 | 69 | 74 | -------------------------------------------------------------------------------- /client/src/renderer/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | 4 | import Login from '../components/login.vue'; 5 | import Main from '../components/main.vue'; 6 | 7 | import MenuChats from '../components/sidebar/chats'; 8 | import MenuContacts from '../components/sidebar/contacts'; 9 | import MenuFunctions from '../components/sidebar/functions'; 10 | import MenuSettings from '../components/sidebar/settings'; 11 | 12 | import ContentChatBox from '../components/content/chat-box'; 13 | import ContentContactInfo from '../components/content/contact-info'; 14 | import ContentFunctions from '../components/content/functions'; 15 | import ContentSettings from '../components/content/settings'; 16 | 17 | import VideoChat from '../components/content/chat-box/special-pages/video-chat.vue'; 18 | 19 | Vue.use(Router); 20 | 21 | export default new Router({ 22 | routes: [ 23 | { 24 | path: '*', 25 | redirect: '/login' 26 | }, 27 | { 28 | path: '/login', 29 | name: 'login', 30 | component: Login 31 | }, 32 | { 33 | path: '/app', 34 | name: 'app', 35 | component: Main, 36 | children: [ 37 | { 38 | path: 'chats', 39 | name: 'chats', 40 | components: { 41 | menus: MenuChats, 42 | contents: ContentChatBox 43 | } 44 | }, 45 | { 46 | path: 'contacts', 47 | name: 'contacts', 48 | components: { 49 | menus: MenuContacts, 50 | contents: ContentContactInfo 51 | } 52 | }, 53 | { 54 | path: 'functions', 55 | name: 'functions', 56 | components: { 57 | menus: MenuFunctions, 58 | contents: ContentFunctions 59 | } 60 | }, 61 | { 62 | path: 'settings', 63 | name: 'settings', 64 | components: { 65 | menus: MenuSettings, 66 | contents: ContentSettings 67 | } 68 | } 69 | ] 70 | }, 71 | { 72 | path: '/video-chat', 73 | name: 'video-chat', 74 | component: VideoChat 75 | } 76 | ] 77 | }); 78 | -------------------------------------------------------------------------------- /client/src/renderer/components/main.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 68 | 69 | 95 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-boxes/right-message-box.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 36 | 37 | 96 | 97 | -------------------------------------------------------------------------------- /server/controllers/modules/webrtc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'request-video-chat': requestVideoChat, 3 | 'video-chat-ready': videoChatReady, 4 | 'rtc-candidate': handleCandidate, 5 | 'rtc-offer': handleOffer, 6 | 'rtc-answer': handleAnswer 7 | }; 8 | 9 | async function requestVideoChat({ 10 | from, 11 | to 12 | }) { 13 | const userHash = global.$userHash; 14 | const fromSocketId = userHash[from]; 15 | const toSocketId = userHash[to]; 16 | const socket = global.$sockets[fromSocketId]; 17 | 18 | socket.to(toSocketId) 19 | .emit('request-video-chat', { 20 | from 21 | }); 22 | } 23 | 24 | async function videoChatReady({ 25 | from, 26 | to 27 | }) { 28 | const userHash = global.$userHash; 29 | const fromSocketId = userHash[from]; 30 | const toSocketId = userHash[to]; 31 | const socket = global.$sockets[fromSocketId]; 32 | 33 | socket.to(toSocketId) 34 | .emit('video-chat-ready', { 35 | from 36 | }); 37 | } 38 | 39 | async function handleCandidate({ 40 | from, 41 | to, 42 | candidateSdp 43 | }) { 44 | const userHash = global.$userHash; 45 | const fromSocketId = userHash[from]; 46 | const toSocketId = userHash[to]; 47 | const socket = global.$sockets[fromSocketId]; 48 | 49 | console.log(`candidateSdp: ${candidateSdp}`); 50 | 51 | socket.to(toSocketId) 52 | .emit('rtc-candidate', { 53 | from, 54 | candidateSdp 55 | }); 56 | } 57 | 58 | async function handleOffer({ 59 | from, 60 | to, 61 | offerSdp 62 | }) { 63 | const userHash = global.$userHash; 64 | const fromSocketId = userHash[from]; 65 | const toSocketId = userHash[to]; 66 | const socket = global.$sockets[fromSocketId]; 67 | 68 | console.log(`offerSdp: ${offerSdp}`); 69 | 70 | socket.to(toSocketId) 71 | .emit('rtc-offer', { 72 | from, 73 | offerSdp 74 | }); 75 | } 76 | 77 | async function handleAnswer({ 78 | from, 79 | to, 80 | answerSdp 81 | }) { 82 | const userHash = global.$userHash; 83 | const fromSocketId = userHash[from]; 84 | const toSocketId = userHash[to]; 85 | const socket = global.$sockets[fromSocketId]; 86 | 87 | console.log(`answerSdp: ${answerSdp}`); 88 | 89 | socket.to(toSocketId) 90 | .emit('rtc-answer', { 91 | from, 92 | answerSdp 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/chats/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 85 | 86 | 98 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/settings/parts/software-setting.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 61 | 62 | 93 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const koaMount = require('koa-mount'); 3 | const koaStatic = require('koa-static'); 4 | const Http = require('http'); 5 | const Socket = require('socket.io'); 6 | const mongoose = require('mongoose'); 7 | const controllers = require('./controllers'); 8 | const log = require('./utils/log'); 9 | 10 | const app = new Koa(); 11 | const server = Http.createServer(app.callback()); 12 | const dbConnection = mongoose.connection; 13 | const io = Socket(server, { 14 | origins: '*:*' 15 | }); 16 | 17 | mongoose.connect('mongodb://localhost:27017/hola', { 18 | useNewUrlParser: true 19 | }); 20 | 21 | dbConnection.on('error', (err) => { 22 | log.error(`${err.name}: ${err.message}`); 23 | }); 24 | 25 | dbConnection.once('open', () => { 26 | log.success('Database: connect success'); 27 | }); 28 | 29 | app.keys = ['secret key']; 30 | 31 | app.use(koaMount('/upload', koaStatic('./upload'))); 32 | 33 | global.$userHash = {}; 34 | global.$sockets = io.sockets.sockets; 35 | 36 | let userHash = global.$userHash; 37 | 38 | io.on('connection', function(socket) { 39 | // bind events and handlers 40 | Object.keys(controllers) 41 | .forEach(module => { 42 | Object.keys(controllers[module]) 43 | .forEach(key => { 44 | socket.on(key, controllers[module][key]); 45 | }); 46 | }); 47 | 48 | // handle user connect,trigger at login success 49 | socket.on('user-connect', function(uid) { 50 | userHash[uid] = socket.id; 51 | 52 | log.success(`\nuser[${uid}] connected`); 53 | log.success(JSON.stringify(userHash, null, 2)); 54 | }); 55 | 56 | // handle user disconnect,trigger at logout success 57 | socket.on('user-disconnect', function(uid) { 58 | delete userHash[uid]; 59 | 60 | log.success(`\nuser[${uid}] disconnected`); 61 | log.success(JSON.stringify(userHash, null, 2)); 62 | }); 63 | 64 | // handle unexpected disconnect,such as app process been killed 65 | socket.on('disconnect', function() { 66 | let uid = ''; 67 | 68 | Object.keys(userHash) 69 | .find(key => { 70 | if (userHash[key] === socket.id) { 71 | uid = key; 72 | return true; 73 | } 74 | }); 75 | 76 | delete userHash[uid]; 77 | 78 | log.success(`\nuser[${uid}] disconnected`); 79 | log.success(JSON.stringify(userHash, null, 2)); 80 | }); 81 | }); 82 | 83 | // error handling 84 | app.on('error', (err, ctx) => { 85 | log.error('server error', err, ctx); 86 | }); 87 | 88 | server.listen(3000); 89 | 90 | log.success('\nServer: http://localhost:3000'); 91 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/menu-bar.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 50 | 51 | 108 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/functions/parts/parts/search-result-item.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 57 | 58 | 59 | 112 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-boxes/left-message-box.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 51 | 52 | 111 | 112 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/_parts/contact-item.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 65 | 66 | 122 | -------------------------------------------------------------------------------- /server/controllers/modules/group.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'create-group': createGroup, 3 | 'get-group-info': getGroupInfo, 4 | 'leave-group': leaveGroup, 5 | 'dissolve-group': dissolveGroup 6 | }; 7 | 8 | const CategoryModel = require('../../models/category'); 9 | const GroupModel = require('../../models/group'); 10 | const UserModel = require('../../models/user'); 11 | 12 | async function createGroup({ 13 | uid, 14 | groupInfo 15 | }, callback) { 16 | const myGroups = await GroupModel.where({ 17 | ownerUid: uid, 18 | status: 'active' 19 | }) 20 | .exec(); 21 | 22 | // everyone can only create 5 group 23 | if (myGroups.length < 5) { 24 | const categoryArr = await CategoryModel.getCategoryListByUid(uid); 25 | const groupCategory = categoryArr[0]; 26 | const gid = await GroupModel.createGroup({ 27 | uid, 28 | groupInfo 29 | }); 30 | 31 | groupCategory.groups.push({ 32 | gid 33 | }); 34 | 35 | await CategoryModel.updateCategoryList(uid, categoryArr); 36 | 37 | callback({ 38 | code: 0, 39 | message: '创建群组成功' 40 | }); 41 | } else { 42 | callback({ 43 | code: 1, 44 | message: '创建群组失败,每个人最多能创建5个群组' 45 | }); 46 | } 47 | } 48 | 49 | async function getGroupInfo(gid, callback) { 50 | let groupInfo = await GroupModel.where({ 51 | gid 52 | }) 53 | .lean() 54 | .exec(); 55 | 56 | groupInfo = groupInfo[0]; 57 | 58 | let members = groupInfo.members; 59 | const memberNum = members.length; 60 | 61 | for (let i = 0; i < memberNum; i++) { 62 | let member = members[i]; 63 | const userInfo = await UserModel.getUserInfo(member.uid); 64 | 65 | member.avatar = userInfo.avatar; 66 | member.nickname = userInfo.nickname; 67 | } 68 | 69 | callback(groupInfo); 70 | } 71 | 72 | async function leaveGroup({ 73 | uid, 74 | gid 75 | }, callback) { 76 | const categoryArr = await CategoryModel.getCategoryListByUid(uid); 77 | const groupCategory = categoryArr[0]; 78 | let groups = groupCategory.groups; 79 | 80 | groups.slice() 81 | .find((el, index) => { 82 | if (el.gid === gid) { 83 | groups.splice(index, 1); 84 | return true; 85 | } 86 | }); 87 | 88 | await CategoryModel.updateCategoryList(uid, categoryArr); 89 | 90 | callback && 91 | callback({ 92 | code: 0, 93 | data: null, 94 | message: '成功退出群组' 95 | }); 96 | } 97 | 98 | async function dissolveGroup({ 99 | uid, 100 | gid 101 | }, callback) { 102 | await leaveGroup({ 103 | uid, 104 | gid 105 | }); 106 | 107 | await GroupModel.where({ 108 | gid 109 | }) 110 | .update({ 111 | status: 'dissolve' 112 | }) 113 | .exec(); 114 | 115 | callback({ 116 | code: 0, 117 | data: null, 118 | message: '成功解散群组' 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/_parts/category-item.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 84 | 85 | 108 | -------------------------------------------------------------------------------- /client/src/renderer/styles/fonts/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?rykwf1'); 4 | src: url('fonts/icomoon.eot?rykwf1#iefix') format('embedded-opentype'), 5 | url('fonts/icomoon.ttf?rykwf1') format('truetype'), 6 | url('fonts/icomoon.woff?rykwf1') format('woff'), 7 | url('fonts/icomoon.svg?rykwf1#icomoon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | [class^="icon-"], [class*=" icon-"] { 13 | /* use !important to prevent issues with browser extensions that change fonts */ 14 | font-family: 'icomoon' !important; 15 | speak: none; 16 | font-style: normal; 17 | font-weight: normal; 18 | font-variant: normal; 19 | text-transform: none; 20 | line-height: 1; 21 | 22 | /* Better Font Rendering =========== */ 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .icon-check:before { 28 | content: "\e5ca"; 29 | } 30 | .icon-photo_size_select_actual:before { 31 | content: "\e432"; 32 | } 33 | .icon-sentiment_satisfied:before { 34 | content: "\e813"; 35 | } 36 | .icon-add:before { 37 | content: "\e900"; 38 | } 39 | .icon-delete:before { 40 | content: "\e901"; 41 | } 42 | .icon-keyboard_arrow_down:before { 43 | content: "\e902"; 44 | } 45 | .icon-keyboard_arrow_right:before { 46 | content: "\e903"; 47 | } 48 | .icon-close:before { 49 | content: "\e904"; 50 | } 51 | .icon-plus:before { 52 | content: "\f067"; 53 | } 54 | .icon-search:before { 55 | content: "\f002"; 56 | } 57 | .icon-user:before { 58 | content: "\f007"; 59 | } 60 | .icon-th-large2:before { 61 | content: "\f009"; 62 | } 63 | .icon-cog:before { 64 | content: "\f013"; 65 | } 66 | .icon-gear:before { 67 | content: "\f013"; 68 | } 69 | .icon-video-camera:before { 70 | content: "\f03d"; 71 | } 72 | .icon-edit:before { 73 | content: "\f044"; 74 | } 75 | .icon-pencil-square-o:before { 76 | content: "\f044"; 77 | } 78 | .icon-folder:before { 79 | content: "\f07b"; 80 | } 81 | .icon-cogs:before { 82 | content: "\f085"; 83 | } 84 | .icon-gears:before { 85 | content: "\f085"; 86 | } 87 | .icon-list-ul:before { 88 | content: "\f0ca"; 89 | } 90 | .icon-microphone:before { 91 | content: "\f130"; 92 | } 93 | .icon-paper-plane-o:before { 94 | content: "\f1d9"; 95 | } 96 | .icon-send-o:before { 97 | content: "\f1d9"; 98 | } 99 | .icon-user-plus:before { 100 | content: "\f234"; 101 | } 102 | .icon-commenting:before { 103 | content: "\f27a"; 104 | } 105 | .icon-address-book:before { 106 | content: "\f2b9"; 107 | } 108 | .icon-drivers-license-o:before { 109 | content: "\f2c3"; 110 | } 111 | .icon-id-card-o:before { 112 | content: "\f2c3"; 113 | } 114 | .icon-phone-hang-up:before { 115 | content: "\e943"; 116 | } 117 | .icon-users:before { 118 | content: "\e972"; 119 | } 120 | .icon-key:before { 121 | content: "\e98d"; 122 | } 123 | .icon-happy2:before { 124 | content: "\e9e0"; 125 | } 126 | .icon-info:before { 127 | content: "\ea0c"; 128 | } 129 | .icon-exit:before { 130 | content: "\ea14"; 131 | } 132 | -------------------------------------------------------------------------------- /server/controllers/modules/user.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-user-info': getUserInfo, 3 | 'update-user-info': updateUserInfo, 4 | 'update-user-avatar': updateUserAvatar, 5 | 'get-friend-info': getFriendInfo, 6 | 'delete-friend': deleteFriend 7 | }; 8 | 9 | const CategoryModel = require('../../models/category'); 10 | const UserModel = require('../../models/user'); 11 | 12 | async function getUserInfo(uid, callback) { 13 | let userInfo = await UserModel.getUserInfo(uid); 14 | 15 | callback(userInfo); 16 | } 17 | 18 | async function updateUserInfo({ 19 | uid, 20 | userInfo 21 | }, callback) { 22 | await UserModel.where({ 23 | uid 24 | }) 25 | .update({ ...userInfo 26 | }) 27 | .exec(); 28 | 29 | callback({ 30 | code: 0, 31 | data: '', 32 | message: '更新成功' 33 | }); 34 | } 35 | 36 | async function updateUserAvatar({ 37 | uid, 38 | base64Data 39 | }, callback) { 40 | const fs = require('fs'); 41 | const path = require('path'); 42 | const filePath = path.join(__dirname, `../../upload/avatars`); 43 | const url = `http://localhost:3000/upload/avatars/${uid}.png?time=${Date.now()}`; 44 | 45 | base64Data = base64Data.replace(/^data:image\/.+?;base64,/, ''); 46 | 47 | if (!fs.existsSync(filePath)) { 48 | fs.mkdirSync(filePath); 49 | } 50 | 51 | fs.writeFileSync(`${filePath}/${uid}.png`, base64Data, 'base64'); 52 | 53 | await UserModel.where({ 54 | uid 55 | }) 56 | .update({ 57 | avatar: url 58 | }) 59 | .exec(); 60 | 61 | callback({ 62 | code: 0, 63 | data: '', 64 | message: '更新头像成功' 65 | }); 66 | } 67 | 68 | async function getFriendInfo({ 69 | uid, 70 | friendUid 71 | }, callback) { 72 | let friendInfo = await UserModel.where({ 73 | uid: friendUid 74 | }) 75 | .lean() 76 | .exec(); 77 | let alias = await CategoryModel.getFriendAliasFromCategory({ 78 | uid, 79 | friendUid 80 | }); 81 | 82 | friendInfo = friendInfo[0]; 83 | 84 | if (friendInfo) { 85 | friendInfo.alias = alias; 86 | 87 | callback(friendInfo); 88 | } 89 | } 90 | 91 | async function deleteFriend({ 92 | uid, 93 | friendUid 94 | }, callback) { 95 | let categoryArr = await CategoryModel.getCategoryListByUid(uid); 96 | let categoryLen = categoryArr.length; 97 | 98 | for (let m = 0; m < categoryLen; m++) { 99 | let category = categoryArr[m]; 100 | 101 | if (category.cid !== 0) { 102 | let friendArr = category.friends; 103 | let friendLen = friendArr.length; 104 | 105 | if (friendArr instanceof Array && friendLen) { 106 | for (let i = 0; i < friendLen; i++) { 107 | if (friendArr[i].uid === friendUid) { 108 | friendArr.splice(i, 1); 109 | 110 | await CategoryModel.updateCategoryList(uid, categoryArr); 111 | 112 | callback({ 113 | code: 0, 114 | data: null, 115 | message: '成功删除好友' 116 | }); 117 | 118 | return; 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | callback({ 126 | isDelete: false 127 | }); 128 | } 129 | -------------------------------------------------------------------------------- /server/models/category.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const CategorySchema = mongoose.Schema({ 3 | ownerUid: String, 4 | categories: [mongoose.Schema.Types.Mixed] 5 | }); 6 | 7 | CategorySchema.statics = { 8 | async getCategoryListByUid(uid) { 9 | // this equal to CatogoryModel 10 | let category = await this.where({ 11 | ownerUid: uid 12 | }) 13 | .exec(); 14 | 15 | category = category[0]; 16 | 17 | if (!category) { 18 | category = new this({ 19 | ownerUid: uid, 20 | categories: [{ 21 | // cid = 0, always means [My Groups] 22 | cid: 0, 23 | groups: [] 24 | }, 25 | { 26 | // cid = 1, always means [Default Category] 27 | cid: 1, 28 | friends: [] 29 | } 30 | ] 31 | }); 32 | 33 | await category.save(); 34 | } 35 | 36 | return category.categories; 37 | }, 38 | 39 | async updateCategoryList(uid, category) { 40 | await this.where({ 41 | ownerUid: uid 42 | }) 43 | .update({ 44 | categories: category 45 | }) 46 | .exec(); 47 | }, 48 | 49 | async getFriendAliasFromCategory({ 50 | uid, 51 | friendUid 52 | }) { 53 | let categoryArr = await this.getCategoryListByUid(uid); 54 | let alias = ''; 55 | 56 | categoryArr.find(category => { 57 | if (category.cid !== 0) { 58 | let friendsArr = category.friends; 59 | let len = friendsArr.length; 60 | 61 | if (len) { 62 | return friendsArr.find(friend => { 63 | if (friend.uid === friendUid) { 64 | alias = friend.alias; 65 | 66 | return true; 67 | } 68 | }); 69 | } 70 | } 71 | }); 72 | 73 | return alias; 74 | }, 75 | 76 | async getAllExistFriendId({ 77 | uid 78 | }) { 79 | let totalFriendsArr = []; 80 | let category = await this.where({ 81 | ownerUid: uid 82 | }) 83 | .exec(); 84 | 85 | category = category[0]; 86 | 87 | let categoryArr = category.categories; 88 | let categoryLen = categoryArr.length; 89 | 90 | for (let m = 0; m < categoryLen; m++) { 91 | let category = categoryArr[m]; 92 | 93 | if (category.cid !== 0) { 94 | let friendArr = category.friends; 95 | 96 | totalFriendsArr.push(...friendArr); 97 | } 98 | } 99 | 100 | // include user self 101 | totalFriendsArr.push({ 102 | uid 103 | }); 104 | 105 | return this.generateArrayByPropName(totalFriendsArr, 'uid'); 106 | }, 107 | 108 | async getAllExistGroupId({ 109 | uid 110 | }) { 111 | let category = await this.where({ 112 | ownerUid: uid 113 | }) 114 | .exec(); 115 | 116 | category = category[0]; 117 | 118 | return this.generateArrayByPropName(category.categories[0].groups, 'gid'); 119 | }, 120 | 121 | generateArrayByPropName(originalArr = [], prop) { 122 | let arr = []; 123 | 124 | originalArr.forEach(el => { 125 | arr.push(el[prop]); 126 | }); 127 | 128 | return arr; 129 | } 130 | }; 131 | 132 | module.exports = mongoose.model('Category', CategorySchema); 133 | -------------------------------------------------------------------------------- /server/controllers/modules/contacts.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'search-contact': searchUserAndGroup, 3 | 'add-friend': addFriend, 4 | 'join-group': joinGroup 5 | }; 6 | 7 | const CategoryModel = require('../../models/category'); 8 | const GroupModel = require('../../models/group'); 9 | const UserModel = require('../../models/user'); 10 | 11 | async function searchUserAndGroup({ 12 | uid, 13 | keyword 14 | }, callback) { 15 | const reg = new RegExp(keyword); 16 | let existFriendsIdArr = await CategoryModel.getAllExistFriendId({ 17 | uid 18 | }); 19 | let existGroupsIdArr = await CategoryModel.getAllExistGroupId({ 20 | uid 21 | }); 22 | let tempFriendArr = []; 23 | let tempGroupArr = []; 24 | let friendArr = []; 25 | let groupArr = []; 26 | 27 | tempFriendArr = await UserModel.where({}) 28 | .select({ 29 | uid: 1, 30 | avatar: 1, 31 | nickname: 1 32 | }) 33 | .or([{ 34 | uid: keyword 35 | }, { 36 | phone: keyword 37 | }, { 38 | nickname: reg 39 | }]) 40 | .exec(); 41 | 42 | // filter the added friends 43 | tempFriendArr.slice() 44 | .forEach((el, index) => { 45 | if (!existFriendsIdArr.includes(el.uid)) { 46 | friendArr.push(el); 47 | } 48 | }); 49 | 50 | tempGroupArr = await GroupModel.where({ 51 | status: 'active' 52 | }) 53 | .select({ 54 | gid: 1, 55 | avatar: 1, 56 | nickname: 1 57 | }) 58 | .or([{ 59 | gid: keyword 60 | }, { 61 | nickname: reg 62 | }]) 63 | .exec(); 64 | 65 | // filter the joined groups 66 | tempGroupArr.slice() 67 | .forEach((el, index) => { 68 | if (!existGroupsIdArr.includes(el.gid)) { 69 | groupArr.push(el); 70 | } 71 | }); 72 | 73 | callback({ 74 | code: 0, 75 | data: { 76 | friendArr, 77 | groupArr 78 | }, 79 | message: '' 80 | }); 81 | } 82 | 83 | async function addFriend({ 84 | uid, 85 | friendUid 86 | }, callback) { 87 | let categoryArr = await CategoryModel.getCategoryListByUid(uid); 88 | let category = categoryArr[1]; 89 | let friendArr = category.friends; 90 | 91 | friendArr.push({ 92 | uid: friendUid, 93 | alias: '' 94 | }); 95 | 96 | await CategoryModel.updateCategoryList(uid, categoryArr); 97 | 98 | // TODO 自动生成一条系统消息,并发送给要添加的用户,以方便对方确认通过添加好友 99 | // generate a system message, send the target user 100 | // to notice the add-friend request 101 | 102 | callback({ 103 | code: 0, 104 | data: null, 105 | message: 'Success to add friend' 106 | }); 107 | } 108 | 109 | async function joinGroup({ 110 | uid, 111 | gid 112 | }, callback) { 113 | let categoryArr = await CategoryModel.getCategoryListByUid(uid); 114 | let category = categoryArr[0]; 115 | let groupArr = category.groups; 116 | 117 | groupArr.push({ 118 | gid 119 | }); 120 | 121 | await CategoryModel.updateCategoryList(uid, categoryArr); 122 | await GroupModel.addNewMember({ 123 | uid, 124 | gid 125 | }); 126 | 127 | // TODO 自动生成一条系统消息,并发送给要添加的用户,以方便对方确认通过添加好友 128 | // generate a system message, send the target user 129 | // to notice the add-friend request 130 | 131 | callback({ 132 | code: 0, 133 | data: null, 134 | message: 'Success to join group' 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /server/controllers/modules/chat-message.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'get-history-message': getHistoryMessage, 3 | 'message': receiveMessage 4 | }; 5 | 6 | const MessageModel = require('../../models/message'); 7 | const GroupModel = require('../../models/group'); 8 | 9 | async function getHistoryMessage(query, callback) { 10 | let isGroup = query.gid ? true : false; 11 | let resultArr = []; 12 | let condition; 13 | 14 | if (isGroup) { 15 | let { 16 | gid 17 | } = query; 18 | 19 | condition = { 20 | to: gid 21 | }; 22 | 23 | resultArr = await MessageModel.where(condition) 24 | .sort({ 25 | time: 'ascending' 26 | }) // descending 27 | .exec(); 28 | } else { 29 | let { 30 | uid, 31 | friendUid 32 | } = query; 33 | 34 | condition = [{ 35 | from: uid, 36 | to: friendUid 37 | }, { 38 | from: friendUid, 39 | to: uid 40 | }]; 41 | 42 | resultArr = await MessageModel.where({}) 43 | .or(condition) 44 | .sort({ 45 | time: 'ascending' 46 | }) // descending 47 | .exec(); 48 | } 49 | 50 | // TODO 51 | // load history messages piece by piece 52 | 53 | callback({ 54 | code: 0, 55 | data: resultArr, 56 | message: '' 57 | }); 58 | } 59 | 60 | async function receiveMessage(message, callback) { 61 | const isGroup = message.to.startsWith('g'); 62 | const userHash = global.$userHash; 63 | const fromSocketId = userHash[message.from]; 64 | const socket = global.$sockets[fromSocketId]; 65 | 66 | // set message time only on server 67 | message.time = Date.now(); 68 | 69 | // save message to database 70 | await MessageModel.saveMessage(message); 71 | 72 | if (isGroup) { 73 | const gid = message.to; 74 | const groups = await GroupModel.where({ 75 | gid 76 | }) 77 | .exec(); 78 | const members = groups[0].members; 79 | 80 | if (members.length > 1) { 81 | members.forEach(el => { 82 | const uid = el.uid; 83 | 84 | if (uid !== message.from) { 85 | const toSocketId = userHash[uid]; 86 | 87 | if (toSocketId) { 88 | socket.to(toSocketId) 89 | .emit('new-message', message); 90 | 91 | callback({ 92 | code: 0, 93 | data: { 94 | uuid: message.uuid, 95 | time: message.time 96 | }, 97 | message: `Message has send to ${uid}` 98 | }); 99 | } else { 100 | callback({ 101 | code: 0, 102 | data: null, 103 | message: 'Current user is offline' 104 | }); 105 | } 106 | } 107 | }); 108 | } 109 | } else { 110 | const toSocketId = userHash[message.to]; 111 | 112 | if (toSocketId) { 113 | // dispatch message 114 | socket.to(toSocketId) 115 | .emit('new-message', message); 116 | 117 | callback({ 118 | code: 0, 119 | data: { 120 | uuid: message.uuid, 121 | time: message.time 122 | }, 123 | message: `Message has send to ${message.to}` 124 | }); 125 | } else { 126 | callback({ 127 | code: 0, 128 | data: null, 129 | message: 'Current user is offline' 130 | }); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hola 2 | 3 | ## 前言 4 | 5 | 本项目旨在**从零到壹**,制作一款界面精美的聊天软件。 6 | 7 | > **因为已工作,所以可能没有多少时间来继续跟进这个项目了,项目可优化的点已在下文列出,欢迎大家 Fork。** 8 | > 9 | > ps: 征 logo 一枚。因为本人是开发,设计功底欠缺,所以软件 logo 设计的有点丑,如果有大神有更好的 logo,欢迎 email。 10 | 11 | ## 技术栈 12 | 13 | * **开发环境** 14 | * 操作系统:macOS High Sierra v10.13.1 15 | * 编辑器:Visual Studio Code v1.19.1 16 | * npm:v5.3.0 17 | * Node:v8.4.0 18 | 19 | * **客户端** 20 | * UI设计:Sketch 21 | * 软件框架:Electron 22 | * 界面实现:Vue.js + Vuex + Vue-Router + Webpack 23 | * 通信模块:[socket.io-client](https://github.com/socketio/socket.io-client) 24 | * 视频聊天:[原生 WebRTC](https://www.html5rocks.com/en/tutorials/webrtc/basics/) 25 | 26 | * **服务端** 27 | * 服务器:Node.js 28 | * 后端框架:Koa2 29 | * 通信模块:[socket.io](https://github.com/socketio/socket.io) 30 | * 数据库:Redis 和 MongoDB 31 | 32 | ## 软件效果图 33 | 34 | ![效果图](./preview.png) 35 | 36 | ## 实现功能 37 | 38 | - [x] 登录注册模块(<手机号+验证码>形式的登录注册) 39 | - [x] 聊天区模块 40 | - [x] 最近联系人列表 41 | - [x] 历史消息(暂未做上拉加载) 42 | - [x] 私聊 43 | - [x] 文本消息 44 | - [x] 图片消息 45 | - [x] 视频聊天 46 | - [x] 群聊 47 | - [x] 文本消息 48 | - [x] 图片消息 49 | - [x] 联系人模块 50 | - [x] 联系人列表 51 | - [x] 好友资料展示 52 | - [x] 群组资料展示 53 | - [x] 删好友,退出或解散群组 54 | - [x] 功能区模块 55 | - [x] 添加好友/群组 56 | - [x] 创建群组 57 | - [x] 设置区模块 58 | - [x] 个人资料设置 59 | - [x] 软件设置 60 | - [x] 国际化 61 | - [x] 中文 62 | - [x] 英文 63 | 64 | ## 项目目录 65 | 66 | ```bash 67 | . 68 | ├── LICENSE 69 | ├── README.md 70 | ├── client # 客户端代码 71 | ├── docs # 各种文档(需求文档、UI文档、流程图、数据库设计等) 72 | ├── preview.png # 软件预览图 73 | └── server # 服务端代码 74 | ``` 75 | 76 | ## 反思 & 展望 77 | 78 | 该项目为我大学毕业设计的项目,因时间紧迫,只实现了基本的聊天、加删好友等功能,很多功能还未实现,所以软件还是有很多的瑕疵。为此,我特意思考了很长时间,将待改进的细节或新的功能总结如下: 79 | 80 | - [ ] 历史消息做成上拉瀑布流加载的效果 81 | - [ ] 为消息注明消息时间、发送状态、已读未读等状态 82 | - [ ] 为最近联系人列表添加最后一条消息的展示 83 | - [ ] 为最近联系人添加未读消息个数的统计 84 | - [ ] 添加好友或加入群组时要进行确认 85 | - [ ] 为软件的新消息使用系统原生通知窗口通知 86 | - [ ] 为软件增加原生菜单 87 | - [ ] 升级输入框,从而可以向输入框直接插入剪切板中的图片 88 | - [ ] 自己搭建文件服务器,图片服务器(或者使用第三方比如七牛云、阿里云的相关服务) 89 | - [ ] 为 WebRTC 实现后备方案,搭建 Relay Server,以增强视频聊天的稳定性 90 | - [ ] 增加网络断开处理的相关逻辑 91 | - [ ] 了解数据加密相关知识,为消息作加密处理 92 | - [ ] 为软件做跨平台处理,兼容性方面有待加强 93 | - [ ] 实现软件自动更新 94 | - [ ] 接入智能机器人聊天 95 | - [ ] 实现本地存储历史消息([nedb](https://github.com/louischatriot/nedb)) 96 | - [ ] 为软件加入聊天情况分析(比如每天发了多少条消息,与谁聊天最频繁等) 97 | 98 | ## 扩展阅读 99 | 100 | * [初探 Electron - 理论篇](http://jartto.wang/2018/01/03/first-exploration-electron/) 101 | * [初探 Electron - 升华篇](http://jartto.wang/2018/01/04/first-exploration-electron-2/) 102 | * [XCel 项目总结 - Electron 与 Vue 的性能优化](https://segmentfault.com/a/1190000007665162) 103 | * [【译】Electron 自动更新的完整教程(Windows 和 OSX)](https://segmentfault.com/a/1190000007616641) 104 | * [Getting Started with WebRTC](https://www.html5rocks.com/en/tutorials/webrtc/basics/) 105 | * [通俗易懂:一篇掌握即时通讯的消息传输安全原理](http://www.52im.net/thread-970-1-1.html) 106 | * [即时通讯安全篇(三):常用加解密算法与通讯安全讲解](http://www.52im.net/thread-219-1-1.html) 107 | * [socket.io断线后重连和消息离线存储如何实现](https://cnodejs.org/topic/57f0fe5ace6d47326a822dc0) 108 | * [Socket.IO stream](https://www.npmjs.com/package/socket.io-stream) 109 | * [运用google-protobuf的IM消息应用开发(前端篇)](http://www.cnblogs.com/1wen/p/6509253.html) 110 | * [Can one hack “paste image” support into a textarea in Firefox?](https://stackoverflow.com/questions/14151018/can-one-hack-paste-image-support-into-a-textarea-in-firefox) 111 | * [在线和离线事件](https://developer.mozilla.org/zh-CN/docs/Web/API/NavigatorOnLine/Online_and_offline_events) 112 | * [im不丢“离线消息”设计](https://blog.csdn.net/wufaliang003/article/details/78638478) -------------------------------------------------------------------------------- /client/src/renderer/components/content/functions/parts/create-group.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 75 | 76 | 147 | -------------------------------------------------------------------------------- /client/src/main/index.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, ipcMain, dialog } = require('electron'); 2 | 3 | // load global variable 4 | require('./service/global'); 5 | 6 | // hold window instance 7 | let mainWindow = null; 8 | 9 | // main process handle the event from render process 10 | ipcMain.on('save-user-data', function(event, data) { 11 | // encrypt data 12 | global.store.set(data); 13 | }); 14 | 15 | ipcMain.on('show-error-dialog', function(event, msg) { 16 | dialog.showErrorBox(msg.title, msg.content); 17 | }); 18 | 19 | ipcMain.on('login', function() { 20 | // here add setTimeout , avoid conflict with closed event 21 | setTimeout(() => { 22 | mainWindow.close(); 23 | 24 | global.isAllowLogin = true; 25 | 26 | // open new window 27 | openHomeWindow(); 28 | }, 100); 29 | }); 30 | 31 | ipcMain.on('logout', function() { 32 | // here add setTimeout , avoid conflict with closed event 33 | setTimeout(() => { 34 | mainWindow.close(); 35 | 36 | global.isAllowLogin = false; 37 | 38 | // open new window 39 | openLoginWindow(); 40 | }, 100); 41 | }); 42 | 43 | app.on('ready', () => { 44 | // require('./service/menu').setMenu(); 45 | const uid = global.store.get('uid'); 46 | 47 | if (uid) { 48 | global.socket.emit('is-allow-login', uid, data => { 49 | if (data.isAllowLogin) { 50 | global.isAllowLogin = true; 51 | openHomeWindow(); 52 | } else { 53 | openLoginWindow(); 54 | } 55 | }); 56 | } else { 57 | openLoginWindow(); 58 | } 59 | }); 60 | 61 | app.on('window-all-closed', () => { 62 | if (process.platform !== 'darwin') { 63 | app.quit(); 64 | } 65 | }); 66 | 67 | app.on('open-file', e => { 68 | e.preventDefault(); 69 | }); 70 | 71 | app.on('open-url', e => { 72 | e.preventDefault(); 73 | }); 74 | 75 | app.on('activate', () => { 76 | if (mainWindow === null) { 77 | createWindow(); 78 | } 79 | }); 80 | 81 | /** 82 | * Auto Updater 83 | * 84 | * Uncomment the following code below and install `electron-updater` to 85 | * support auto updating. Code Signing with a valid certificate is required. 86 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating 87 | */ 88 | 89 | /* 90 | import { autoUpdater } from 'electron-updater' 91 | 92 | autoUpdater.on('update-downloaded', () => { 93 | autoUpdater.quitAndInstall() 94 | }) 95 | 96 | app.on('ready', () => { 97 | if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates() 98 | }) 99 | */ 100 | 101 | function openLoginWindow() { 102 | createWindow({ 103 | width: 280, 104 | height: 400 105 | }); 106 | } 107 | 108 | function openHomeWindow() { 109 | createWindow({ 110 | width: 885, 111 | height: 550 112 | }); 113 | } 114 | 115 | // create main window 116 | function createWindow(configObj) { 117 | const devServer = 'http://localhost:9080'; 118 | const winURL = 119 | process.env.NODE_ENV === 'development' 120 | ? devServer 121 | : `file://${__dirname}/index.html`; 122 | 123 | const config = Object.assign( 124 | { 125 | width: 280, 126 | height: 400, 127 | useContentSize: true, 128 | resizable: false, 129 | maximizable: false, 130 | fullscreen: false, 131 | titleBarStyle: 'hidden' 132 | }, 133 | configObj ? configObj : {} 134 | ); 135 | 136 | mainWindow = new BrowserWindow(config); 137 | 138 | mainWindow.on('closed', () => { 139 | mainWindow = null; 140 | }); 141 | 142 | // disable page zoom 143 | mainWindow.webContents.on('did-finish-load', function() { 144 | this.setZoomFactor(1); 145 | this.setVisualZoomLevelLimits(1, 1); 146 | this.setLayoutZoomLevelLimits(0, 0); 147 | }); 148 | 149 | mainWindow.loadURL(winURL); 150 | mainWindow.show(); 151 | } 152 | -------------------------------------------------------------------------------- /client/.electron-vue/build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'production'; 4 | 5 | const { 6 | say 7 | } = require('cfonts'); 8 | const chalk = require('chalk'); 9 | const del = require('del'); 10 | const { 11 | spawn 12 | } = require('child_process'); 13 | const webpack = require('webpack'); 14 | const Multispinner = require('multispinner'); 15 | 16 | const mainConfig = require('./webpack.main.config'); 17 | const rendererConfig = require('./webpack.renderer.config'); 18 | const webConfig = require('./webpack.web.config'); 19 | 20 | const doneLog = chalk.bgGreen.white(' DONE ') + ' '; 21 | const errorLog = chalk.bgRed.white(' ERROR ') + ' '; 22 | const okayLog = chalk.bgBlue.white(' OKAY ') + ' '; 23 | const isCI = process.env.CI || false; 24 | 25 | if (process.env.BUILD_TARGET === 'clean') clean(); 26 | else if (process.env.BUILD_TARGET === 'web') web(); 27 | else build(); 28 | 29 | function clean() { 30 | del.sync(['build/*', '!build/icons', '!build/icons/icon.*']); 31 | console.log(`\n${doneLog}\n`); 32 | process.exit(); 33 | } 34 | 35 | function build() { 36 | greeting(); 37 | 38 | del.sync(['dist/electron/*', '!.gitkeep']); 39 | 40 | const tasks = ['main', 'renderer']; 41 | const m = new Multispinner(tasks, { 42 | preText: 'building', 43 | postText: 'process' 44 | }); 45 | 46 | let results = ''; 47 | 48 | m.on('success', () => { 49 | process.stdout.write('\x1B[2J\x1B[0f'); 50 | console.log(`\n\n${results}`); 51 | console.log( 52 | `${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n` 53 | ); 54 | process.exit(); 55 | }); 56 | 57 | pack(mainConfig) 58 | .then(result => { 59 | results += result + '\n\n'; 60 | m.success('main'); 61 | }) 62 | .catch(err => { 63 | m.error('main'); 64 | console.log(`\n ${errorLog}failed to build main process`); 65 | console.error(`\n${err}\n`); 66 | process.exit(1); 67 | }); 68 | 69 | pack(rendererConfig) 70 | .then(result => { 71 | results += result + '\n\n'; 72 | m.success('renderer'); 73 | }) 74 | .catch(err => { 75 | m.error('renderer'); 76 | console.log(`\n ${errorLog}failed to build renderer process`); 77 | console.error(`\n${err}\n`); 78 | process.exit(1); 79 | }); 80 | } 81 | 82 | function pack(config) { 83 | return new Promise((resolve, reject) => { 84 | webpack(config, (err, stats) => { 85 | if (err) reject(err.stack || err); 86 | else if (stats.hasErrors()) { 87 | let err = ''; 88 | 89 | stats 90 | .toString({ 91 | chunks: false, 92 | colors: true 93 | }) 94 | .split(/\r?\n/) 95 | .forEach(line => { 96 | err += ` ${line}\n`; 97 | }); 98 | 99 | reject(err); 100 | } else { 101 | resolve( 102 | stats.toString({ 103 | chunks: false, 104 | colors: true 105 | }) 106 | ); 107 | } 108 | }); 109 | }); 110 | } 111 | 112 | function web() { 113 | del.sync(['dist/web/*', '!.gitkeep']); 114 | webpack(webConfig, (err, stats) => { 115 | if (err || stats.hasErrors()) console.log(err); 116 | 117 | console.log( 118 | stats.toString({ 119 | chunks: false, 120 | colors: true 121 | }) 122 | ); 123 | 124 | process.exit(); 125 | }); 126 | } 127 | 128 | function greeting() { 129 | const cols = process.stdout.columns; 130 | let text = ''; 131 | 132 | if (cols > 85) text = 'lets-build'; 133 | else if (cols > 60) text = 'lets-|build'; 134 | else text = false; 135 | 136 | if (text && !isCI) { 137 | say(text, { 138 | colors: ['yellow'], 139 | font: 'simple3d', 140 | space: false 141 | }); 142 | } else console.log(chalk.yellow.bold('\n lets-build')); 143 | console.log(); 144 | } 145 | -------------------------------------------------------------------------------- /client/src/renderer/components/sidebar/_parts/chat-item.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 89 | 90 | 171 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.web.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'web' 4 | 5 | const path = require('path') 6 | const webpack = require('webpack') 7 | 8 | const BabiliWebpackPlugin = require('babili-webpack-plugin') 9 | const CopyWebpackPlugin = require('copy-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const HtmlWebpackPlugin = require('html-webpack-plugin') 12 | 13 | let webConfig = { 14 | devtool: '#cheap-module-eval-source-map', 15 | entry: { 16 | web: path.join(__dirname, '../src/renderer/main.js') 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|vue)$/, 22 | enforce: 'pre', 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'eslint-loader', 26 | options: { 27 | formatter: require('eslint-friendly-formatter') 28 | } 29 | } 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: ExtractTextPlugin.extract({ 34 | fallback: 'style-loader', 35 | use: 'css-loader' 36 | }) 37 | }, 38 | { 39 | test: /\.html$/, 40 | use: 'vue-html-loader' 41 | }, 42 | { 43 | test: /\.js$/, 44 | use: 'babel-loader', 45 | include: [ path.resolve(__dirname, '../src/renderer') ], 46 | exclude: /node_modules/ 47 | }, 48 | { 49 | test: /\.vue$/, 50 | use: { 51 | loader: 'vue-loader', 52 | options: { 53 | extractCSS: true, 54 | loaders: { 55 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 56 | scss: 'vue-style-loader!css-loader!sass-loader' 57 | } 58 | } 59 | } 60 | }, 61 | { 62 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 63 | use: { 64 | loader: 'url-loader', 65 | query: { 66 | limit: 10000, 67 | name: 'imgs/[name].[ext]' 68 | } 69 | } 70 | }, 71 | { 72 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 73 | use: { 74 | loader: 'url-loader', 75 | query: { 76 | limit: 10000, 77 | name: 'fonts/[name].[ext]' 78 | } 79 | } 80 | } 81 | ] 82 | }, 83 | plugins: [ 84 | new ExtractTextPlugin('styles.css'), 85 | new HtmlWebpackPlugin({ 86 | filename: 'index.html', 87 | template: path.resolve(__dirname, '../src/index.ejs'), 88 | minify: { 89 | collapseWhitespace: true, 90 | removeAttributeQuotes: true, 91 | removeComments: true 92 | }, 93 | nodeModules: false 94 | }), 95 | new webpack.DefinePlugin({ 96 | 'process.env.IS_WEB': 'true' 97 | }), 98 | new webpack.HotModuleReplacementPlugin(), 99 | new webpack.NoEmitOnErrorsPlugin() 100 | ], 101 | output: { 102 | filename: '[name].js', 103 | path: path.join(__dirname, '../dist/web') 104 | }, 105 | resolve: { 106 | alias: { 107 | '@': path.join(__dirname, '../src/renderer'), 108 | 'vue$': 'vue/dist/vue.esm.js' 109 | }, 110 | extensions: ['.js', '.vue', '.json', '.css'] 111 | }, 112 | target: 'web' 113 | } 114 | 115 | /** 116 | * Adjust webConfig for production settings 117 | */ 118 | if (process.env.NODE_ENV === 'production') { 119 | webConfig.devtool = '' 120 | 121 | webConfig.plugins.push( 122 | new BabiliWebpackPlugin(), 123 | new CopyWebpackPlugin([ 124 | { 125 | from: path.join(__dirname, '../static'), 126 | to: path.join(__dirname, '../dist/web/static'), 127 | ignore: ['.*'] 128 | } 129 | ]), 130 | new webpack.DefinePlugin({ 131 | 'process.env.NODE_ENV': '"production"' 132 | }), 133 | new webpack.LoaderOptionsPlugin({ 134 | minimize: true 135 | }) 136 | ) 137 | } 138 | 139 | module.exports = webConfig 140 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/chat-box/parts/message-types/image-message.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 52 | 53 | 177 | 178 | -------------------------------------------------------------------------------- /docs/数据库设计.md: -------------------------------------------------------------------------------- 1 | # 数据库设计 2 | 3 | * 数据库采用文档型数据库 MongoDB 4 | * 数据库名称为 `hola` 5 | * 根据需求,项目中需要存储的数据将分别放到四个文档中 6 | * **messages:** 存储聊天消息 7 | * **users:** 存储用户的基本信息 8 | * **groups:** 存储群组的基本信息 9 | * **categories:** 存储用户的联系人和群组列表 10 | 11 | ### 聊天消息-messages 12 | 13 | * 所有的聊天信息的时间统一由服务端来设置 14 | 15 | #### 文本消息 16 | 17 | ```js 18 | // 私聊 19 | { 20 | uuid: '0b5556b3-1521026326974', 21 | from: 'u22312', 22 | to: 'u34423', 23 | type: 'text', 24 | time: 1521024282249, 25 | content: { 26 | text: '一二三四五,武松怕老虎' 27 | } 28 | } 29 | 30 | // 群聊 31 | { 32 | uuid: "0b5556b3-1521026326974", 33 | from: 'u23422', 34 | to: 'g23422', 35 | type: "text", 36 | time: 1521024282249, 37 | content: { 38 | text: "哈哈" 39 | } 40 | } 41 | ``` 42 | 43 | #### 图片消息 44 | 45 | ```js 46 | { 47 | uuid: '0b5556b3-1521026326974', 48 | from: 'u22312', 49 | to: 'u34423', 50 | type: 'image', 51 | time: 1521024282249, 52 | content: { 53 | url: 'https://upload.chinaz.com/2018/0314/6365664509995562636779542.jpeg' 54 | } 55 | } 56 | ``` 57 | 58 | #### 文件消息 59 | 60 | ```js 61 | { 62 | uuid: '0b5556b3-1521026326974', 63 | from: 'u22312', 64 | to: 'u34423', 65 | type: 'file', 66 | time: 1521024282249, 67 | content: { 68 | fileName: 'Ruska Dawaj Dawaj.mp3', 69 | fileSize: 3455674, // 字节 70 | fileType: 'audio/mp3' 71 | } 72 | } 73 | ``` 74 | 75 | #### 语音、视频聊天消息 76 | 77 | ```js 78 | { 79 | uuid: '0b5556b3-1521026326974', 80 | from: 'u22312', 81 | to: 'u34423', 82 | type: 'media', 83 | time: 1521024282249, 84 | content: { 85 | text: '通话时长:12分钟' 86 | } 87 | } 88 | ``` 89 | 90 | #### 系统消息 91 | 92 | ```js 93 | { 94 | uuid: '0b5556b3-1521026326974', 95 | from: 'u22312', 96 | to: 'u34423', 97 | type: 'system', 98 | time: 1521024282249, 99 | content: { 100 | text: '注意:涉及到个人隐私的信息请谨慎回复!' 101 | } 102 | } 103 | ``` 104 | 105 | ### 用户-users 106 | 107 | * 每个 user 都有一个唯一 uid(user ID), uid 从 u10000 起 108 | * 用户名称在前端展示顺序为: alias > nickname > uid 109 | * mongoose 启用 createdAt 和 updatedAt 自动字段 110 | 111 | ```js 112 | { 113 | uid: 'u16789', 114 | avatar: "http://img2.woyaogexing.com/2018/03/14/24fd45c444a261cd!400x400_big.jpg", 115 | nickname: "宁夏(max:15)", 116 | signature: "对不起我对你彻底失望了(max:50)", 117 | gender: "女", 118 | age: 23, 119 | phone: 15645656677, 120 | email: "ningxia@gmail.com", 121 | tags: ["女神", "爱旅游"], 122 | address: { 123 | country: "中国", 124 | province: "浙江省", 125 | city: "杭州市", 126 | detail: '' 127 | }, 128 | birthTime: 839088000000, 129 | selfIntro: "她是德玛西亚之力盖伦的妹妹,擅长使用光魔法的英雄。拉克丝是一个远程输出法师,所有技能均为非锁定目标的,所以释放技能时需要玩家具有一定的预判能力。技能释放的距离较远,若法术强度足够,可以在对手打不到自己的情况下一套技能可以杀死脆皮英雄。(max:150)", 130 | isOnline: true 131 | } 132 | ``` 133 | 134 | ### 群组-groups 135 | 136 | * 每个group都有一个唯一gid(group ID) 137 | * 假设每个用户默认只能创建 5 个群组 138 | 139 | ```js 140 | { 141 | gid: 'g34534', 142 | ownerUid: 'u23421', 143 | status: 'active', // active、dissolve 144 | avatar: 'http://img2.woyaogexing.com/2018/03/14/24fd45c444a261cd!400x400_big.jpg', 145 | nickname: '口语交流群', 146 | groupInfo: '本群旨在帮助大家提高口语交流能力!她是德玛西亚之力盖伦的妹妹,擅长使用光魔法的英雄。(max:150)', 147 | tags: ['口语', '英语'], 148 | members: [ 149 | { 150 | uid: 'u23421', 151 | // alias: '我的群名称', 152 | flag: '群主' 153 | }, 154 | { 155 | uid: 'u23411', 156 | // alias: '14计科全英班-狗蛋', 157 | flag: '' 158 | } 159 | ] 160 | } 161 | ``` 162 | 163 | ### 好友分组-categories 164 | 165 | * 每个分组都有一个cid(collection ID),uid 从 g10000 起 166 | * `cid` 为 `0` 的永远是我的群组,不可变 167 | * 每个用户只维护好友的备注信息(alias) 168 | 169 | ```js 170 | { 171 | owerUid: 'u23231', 172 | categories: [ 173 | { 174 | cid: 0, 175 | groups: [ 176 | { 177 | gid: 'g23421' 178 | } 179 | ] 180 | }, 181 | { 182 | cid: 1, 183 | friends: [ 184 | { 185 | uid: 'u1234', 186 | alias: "14计科全英班-狗蛋(max:15)" 187 | }, 188 | { 189 | uid: 'u3344', 190 | alias: "" 191 | } 192 | ] 193 | } 194 | ] 195 | } 196 | ``` 197 | 198 | -------------------------------------------------------------------------------- /client/src/main/service/menu.js: -------------------------------------------------------------------------------- 1 | const { Menu, BrowserWindow, dialog, app } = require('electron'); 2 | 3 | const template = [ 4 | { 5 | label: 'View', 6 | submenu: [ 7 | { 8 | label: 'Reload', 9 | accelerator: 'CmdOrCtrl+R', 10 | click(item, focusedWindow) { 11 | if (focusedWindow) { 12 | // on reload, start fresh and close any old 13 | // open secondary windows 14 | if (focusedWindow.id === 1) { 15 | BrowserWindow.getAllWindows().forEach(win => { 16 | if (win.id > 1) { 17 | win.close(); 18 | } 19 | }); 20 | } 21 | 22 | focusedWindow.reload(); 23 | } 24 | } 25 | }, 26 | { 27 | label: 'Toggle Developer Tools', 28 | accelerator: (function() { 29 | if (process.platform === 'darwin') { 30 | return 'Alt+Command+I'; 31 | } 32 | 33 | return 'Ctrl+Shift+I'; 34 | }()), 35 | click: function(item, focusedWindow) { 36 | if (focusedWindow) { 37 | focusedWindow.toggleDevTools(); 38 | } 39 | } 40 | }, 41 | { 42 | type: 'separator' 43 | }, 44 | { 45 | label: 'show dialog', 46 | click(item, focusedWindow) { 47 | if (focusedWindow) { 48 | const options = { 49 | type: 'error', 50 | title: 'Application Menu Demo', 51 | buttons: ['确认', '取消'], 52 | message: 53 | 'This demo is for the Menu section, showing how to create a clickable menu item in the application menu.' 54 | }; 55 | dialog.showMessageBox( 56 | focusedWindow, 57 | options, 58 | function() { 59 | console.log('dialog message here'); 60 | } 61 | ); 62 | } 63 | } 64 | } 65 | ] 66 | } 67 | ]; 68 | 69 | if (process.platform === 'darwin') { 70 | const name = app.getName(); 71 | 72 | template.unshift({ 73 | label: name, 74 | submenu: [ 75 | { 76 | label: `About ${name}`, 77 | role: 'about' 78 | }, 79 | { 80 | type: 'separator' 81 | }, 82 | { 83 | label: 'Services', 84 | role: 'services', 85 | submenu: [] 86 | }, 87 | { 88 | type: 'separator' 89 | }, 90 | { 91 | label: `Hide ${name}`, 92 | accelerator: 'Command+H', 93 | role: 'hide' 94 | }, 95 | { 96 | label: 'Hide Others', 97 | accelerator: 'Command+Alt+H', 98 | role: 'hideothers' 99 | }, 100 | { 101 | label: 'Show All', 102 | role: 'unhide' 103 | }, 104 | { 105 | type: 'separator' 106 | }, 107 | { 108 | label: 'Quit', 109 | accelerator: 'Command+Q', 110 | click() { 111 | app.quit(); 112 | } 113 | } 114 | ] 115 | }); 116 | } 117 | 118 | const setMenu = () => { 119 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)); 120 | }; 121 | 122 | module.exports = { 123 | setMenu 124 | }; 125 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hola_client", 3 | "version": "0.1.0", 4 | "author": "percy507 ", 5 | "description": "An electron-vue project", 6 | "license": "MIT", 7 | "main": "./dist/electron/main.js", 8 | "scripts": { 9 | "build": "node .electron-vue/build.js && electron-builder", 10 | "build:dir": "node .electron-vue/build.js && electron-builder --dir", 11 | "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js", 12 | "build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js", 13 | "dev": "node .electron-vue/dev-runner.js", 14 | "lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter src test", 15 | "lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix src test", 16 | "pack": "npm run pack:main && npm run pack:renderer", 17 | "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js", 18 | "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js", 19 | "test": "npm run unit", 20 | "unit": "karma start test/unit/karma.conf.js", 21 | "postinstall": "npm run lint:fix" 22 | }, 23 | "build": { 24 | "productName": "hola", 25 | "appId": "org.simulatedgreg.electron-vue", 26 | "directories": { 27 | "output": "build" 28 | }, 29 | "files": [ 30 | "dist/electron/**/*" 31 | ], 32 | "dmg": { 33 | "contents": [ 34 | { 35 | "x": 410, 36 | "y": 150, 37 | "type": "link", 38 | "path": "/Applications" 39 | }, 40 | { 41 | "x": 130, 42 | "y": 150, 43 | "type": "file" 44 | } 45 | ] 46 | }, 47 | "mac": { 48 | "icon": "build/icons/icon.icns" 49 | }, 50 | "win": { 51 | "icon": "build/icons/icon.ico" 52 | }, 53 | "linux": { 54 | "icon": "build/icons" 55 | } 56 | }, 57 | "dependencies": { 58 | "axios": "^0.16.1", 59 | "electron-store": "^1.3.0", 60 | "js-yaml": "^3.11.0", 61 | "normalize.css": "^8.0.0", 62 | "socket.io-client": "^2.0.4", 63 | "vue": "^2.3.3", 64 | "vue-electron": "^1.0.6", 65 | "vue-router": "^2.5.3", 66 | "vuex": "^2.3.1", 67 | "webrtc-adapter": "^6.1.5" 68 | }, 69 | "devDependencies": { 70 | "babel-core": "^6.25.0", 71 | "babel-eslint": "^7.2.3", 72 | "babel-loader": "^7.1.1", 73 | "babel-plugin-istanbul": "^4.1.1", 74 | "babel-plugin-transform-runtime": "^6.23.0", 75 | "babel-preset-env": "^1.6.0", 76 | "babel-preset-stage-0": "^6.24.1", 77 | "babel-register": "^6.24.1", 78 | "babili-webpack-plugin": "^0.1.2", 79 | "cfonts": "^1.1.3", 80 | "chai": "^4.0.0", 81 | "chalk": "^2.1.0", 82 | "chance": "^1.0.13", 83 | "copy-webpack-plugin": "^4.0.1", 84 | "cross-env": "^5.0.5", 85 | "css-loader": "^0.28.4", 86 | "del": "^3.0.0", 87 | "devtron": "^1.4.0", 88 | "electron": "^1.8.4", 89 | "electron-builder": "^19.19.1", 90 | "electron-debug": "^1.4.0", 91 | "electron-devtools-installer": "^2.2.0", 92 | "eslint": "^4.4.1", 93 | "eslint-config-airbnb-base": "^11.2.0", 94 | "eslint-friendly-formatter": "^3.0.0", 95 | "eslint-import-resolver-webpack": "^0.8.1", 96 | "eslint-loader": "^1.9.0", 97 | "eslint-plugin-html": "^3.1.1", 98 | "eslint-plugin-import": "^2.2.0", 99 | "extract-text-webpack-plugin": "^3.0.0", 100 | "file-loader": "^0.11.2", 101 | "html-webpack-plugin": "^2.30.1", 102 | "inject-loader": "^3.0.0", 103 | "karma": "^1.3.0", 104 | "karma-chai": "^0.1.0", 105 | "karma-coverage": "^1.1.1", 106 | "karma-electron": "^5.1.1", 107 | "karma-mocha": "^1.2.0", 108 | "karma-sourcemap-loader": "^0.3.7", 109 | "karma-spec-reporter": "^0.0.31", 110 | "karma-webpack": "^2.0.1", 111 | "mocha": "^3.0.2", 112 | "multispinner": "^0.2.1", 113 | "node-loader": "^0.6.0", 114 | "style-loader": "^0.18.2", 115 | "stylus": "^0.54.5", 116 | "stylus-loader": "^3.0.2", 117 | "url-loader": "^0.5.9", 118 | "vue-html-loader": "^1.2.4", 119 | "vue-loader": "^13.0.5", 120 | "vue-style-loader": "^3.0.1", 121 | "vue-template-compiler": "^2.4.2", 122 | "webpack": "^3.5.2", 123 | "webpack-dev-server": "^2.7.1", 124 | "webpack-hot-middleware": "^2.18.2", 125 | "webpack-merge": "^4.1.0" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/functions/parts/add-new-contact.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 92 | 93 | 184 | -------------------------------------------------------------------------------- /client/.electron-vue/dev-runner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chalk = require('chalk') 4 | const electron = require('electron') 5 | const path = require('path') 6 | const { say } = require('cfonts') 7 | const { spawn } = require('child_process') 8 | const webpack = require('webpack') 9 | const WebpackDevServer = require('webpack-dev-server') 10 | const webpackHotMiddleware = require('webpack-hot-middleware') 11 | 12 | const mainConfig = require('./webpack.main.config') 13 | const rendererConfig = require('./webpack.renderer.config') 14 | 15 | let electronProcess = null 16 | let manualRestart = false 17 | let hotMiddleware 18 | 19 | function logStats (proc, data) { 20 | let log = '' 21 | 22 | log += chalk.yellow.bold(`┏ ${proc} Process ${new Array((19 - proc.length) + 1).join('-')}`) 23 | log += '\n\n' 24 | 25 | if (typeof data === 'object') { 26 | data.toString({ 27 | colors: true, 28 | chunks: false 29 | }).split(/\r?\n/).forEach(line => { 30 | log += ' ' + line + '\n' 31 | }) 32 | } else { 33 | log += ` ${data}\n` 34 | } 35 | 36 | log += '\n' + chalk.yellow.bold(`┗ ${new Array(28 + 1).join('-')}`) + '\n' 37 | 38 | console.log(log) 39 | } 40 | 41 | function startRenderer () { 42 | return new Promise((resolve, reject) => { 43 | rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer) 44 | 45 | const compiler = webpack(rendererConfig) 46 | hotMiddleware = webpackHotMiddleware(compiler, { 47 | log: false, 48 | heartbeat: 2500 49 | }) 50 | 51 | compiler.plugin('compilation', compilation => { 52 | compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => { 53 | hotMiddleware.publish({ action: 'reload' }) 54 | cb() 55 | }) 56 | }) 57 | 58 | compiler.plugin('done', stats => { 59 | logStats('Renderer', stats) 60 | }) 61 | 62 | const server = new WebpackDevServer( 63 | compiler, 64 | { 65 | contentBase: path.join(__dirname, '../'), 66 | quiet: true, 67 | before (app, ctx) { 68 | app.use(hotMiddleware) 69 | ctx.middleware.waitUntilValid(() => { 70 | resolve() 71 | }) 72 | } 73 | } 74 | ) 75 | 76 | server.listen(9080) 77 | }) 78 | } 79 | 80 | function startMain () { 81 | return new Promise((resolve, reject) => { 82 | mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main) 83 | 84 | const compiler = webpack(mainConfig) 85 | 86 | compiler.plugin('watch-run', (compilation, done) => { 87 | logStats('Main', chalk.white.bold('compiling...')) 88 | hotMiddleware.publish({ action: 'compiling' }) 89 | done() 90 | }) 91 | 92 | compiler.watch({}, (err, stats) => { 93 | if (err) { 94 | console.log(err) 95 | return 96 | } 97 | 98 | logStats('Main', stats) 99 | 100 | if (electronProcess && electronProcess.kill) { 101 | manualRestart = true 102 | process.kill(electronProcess.pid) 103 | electronProcess = null 104 | startElectron() 105 | 106 | setTimeout(() => { 107 | manualRestart = false 108 | }, 5000) 109 | } 110 | 111 | resolve() 112 | }) 113 | }) 114 | } 115 | 116 | function startElectron () { 117 | electronProcess = spawn(electron, ['--inspect=5858', path.join(__dirname, '../dist/electron/main.js')]) 118 | 119 | electronProcess.stdout.on('data', data => { 120 | electronLog(data, 'blue') 121 | }) 122 | electronProcess.stderr.on('data', data => { 123 | electronLog(data, 'red') 124 | }) 125 | 126 | electronProcess.on('close', () => { 127 | if (!manualRestart) process.exit() 128 | }) 129 | } 130 | 131 | function electronLog (data, color) { 132 | let log = '' 133 | data = data.toString().split(/\r?\n/) 134 | data.forEach(line => { 135 | log += ` ${line}\n` 136 | }) 137 | if (/[0-9A-z]+/.test(log)) { 138 | console.log( 139 | chalk[color].bold('┏ Electron -------------------') + 140 | '\n\n' + 141 | log + 142 | chalk[color].bold('┗ ----------------------------') + 143 | '\n' 144 | ) 145 | } 146 | } 147 | 148 | function greeting () { 149 | const cols = process.stdout.columns 150 | let text = '' 151 | 152 | if (cols > 104) text = 'electron-vue' 153 | else if (cols > 76) text = 'electron-|vue' 154 | else text = false 155 | 156 | if (text) { 157 | say(text, { 158 | colors: ['yellow'], 159 | font: 'simple3d', 160 | space: false 161 | }) 162 | } else console.log(chalk.yellow.bold('\n electron-vue')) 163 | console.log(chalk.blue(' getting ready...') + '\n') 164 | } 165 | 166 | function init () { 167 | greeting() 168 | 169 | Promise.all([startRenderer(), startMain()]) 170 | .then(() => { 171 | startElectron() 172 | }) 173 | .catch(err => { 174 | console.error(err) 175 | }) 176 | } 177 | 178 | init() 179 | -------------------------------------------------------------------------------- /docs/接口文档.md: -------------------------------------------------------------------------------- 1 | # socket 通信相关的事件 2 | 3 | ### connect 4 | 5 | ```bash 6 | # user-connect 7 | # 用户登录成功时触发,用于让服务器存储 uid 8 | # 发送: 9 | uid 10 | ``` 11 | 12 | ```bash 13 | # user-disconnect 14 | # 用户退出登录时触发,用于让服务器销毁之前存储的 uid 15 | # 发送: 16 | uid 17 | ``` 18 | 19 | ```bash 20 | # disconnect 21 | # 用户意外退出app时自动触发,用于让服务器销毁之前存储的 uid 22 | ``` 23 | 24 | ### login 25 | 26 | * **尽量使用 uid,而不是直接用手机号** 27 | * 目前仅在,获取验证码和登录时用到了手机号 28 | 29 | 30 | ```bash 31 | # get-authcode 32 | # 获取验证码 33 | # 发送: 34 | phone 35 | # 返回: 36 | { 37 | authcode: 345001 38 | } 39 | 40 | # 开发为了方便,后端没有去调用短信服务商的接口,而是直接 41 | # 将验证码直接返回给前端(前端在console控制台打印出来) 42 | ``` 43 | 44 | ```bash 45 | # login 46 | # 注册/登录 47 | # 发送: 48 | { 49 | phone: 17826887878, 50 | authcode: 212322 51 | } 52 | # 返回: 53 | { 54 | isAllowLogin: true, 55 | uid: 'u12343' 56 | } 57 | ``` 58 | 59 | ```bash 60 | # is-allow-login 61 | # 判断用户能否直接登录,后端设置过期时间 7天 62 | # 发送: 63 | uid 64 | # 返回: 65 | { 66 | isAllowLogin: true 67 | } 68 | ``` 69 | 70 | ```bash 71 | # logout 72 | # 退出登录,清空redis中监听的状态 73 | # 发送: 74 | uid 75 | # 返回: 76 | { 77 | isLogoutSuccess: true 78 | } 79 | ``` 80 | 81 | ### user 82 | 83 | ```bash 84 | # get-user-info 85 | # 获取当前用户的信息 86 | # 发送: 87 | uid 88 | # 返回: 89 | { 90 | ...基本信息 91 | } 92 | ``` 93 | 94 | ### chat 95 | 96 | ```bash 97 | # get-recent-chat-info 98 | # 获取最近联系的用户的信息 99 | # 发送: 100 | { 101 | uid: 'u123123', 102 | recentChatIdArr:[ uid, uid, gid, ... ] 103 | } 104 | # 返回: 105 | [{ 106 | uid:'u12312', 107 | avatar: `http://localhost:9999/_hola/avatars/1.jpg`, 108 | nickname: 'xxx', 109 | alias: '', 110 | lastMessage: 'xxx' 111 | },{ 112 | gid:'g12312', 113 | avatar: `http://localhost:9999/_hola/avatars/1.jpg`, 114 | nickname: 'xxx', 115 | lastMessage: 'xxx' 116 | }, 117 | ... 118 | ] 119 | ``` 120 | 121 | ```bash 122 | # get-history-message 123 | # 获取历史消息 124 | # 发送: 125 | { 126 | isGroup: false, 127 | contactId:'u321222', 128 | userId: 'u332221' 129 | } 130 | # 返回: 131 | [ 132 | { 133 | _id: '5b47404eb8cf362fadc61001', 134 | uuid: '8a581e19-1531396174733', 135 | from: 'u10001', 136 | to: 'u10000', 137 | type: 'text', 138 | content: { text: 'lll' }, 139 | time: 1531396174737, 140 | __v: 0, 141 | }, 142 | { 143 | _id: '5b474085b8cf362fadc61002', 144 | uuid: 'd6f6f64f-1531396229613', 145 | from: 'u10000', 146 | to: 'u10001', 147 | type: 'text', 148 | content: { text: '2222' }, 149 | time: 1531396229616, 150 | __v: 0, 151 | }, 152 | { 153 | _id: '5b47408ab8cf362fadc61003', 154 | uuid: 'd5e26ecf-1531396234426', 155 | from: 'u10001', 156 | to: 'u10000', 157 | type: 'text', 158 | content: { text: '222' }, 159 | time: 1531396234431, 160 | __v: 0, 161 | }, 162 | ... 163 | ] 164 | ``` 165 | 166 | ### contacts 167 | 168 | ```bash 169 | # get-category-list 170 | # 获取联系人列表 171 | # 发送: 172 | uid 173 | # 返回: 174 | [ 175 | { 176 | cid: 0, 177 | groups: [ 178 | { 179 | gid:'g23421', 180 | nickname: "xxx", 181 | avatar: `http://localhost:9999/_hola/avatars/${n}.jpg` 182 | } 183 | ] 184 | }, 185 | { 186 | cid: 1, 187 | friends: [ 188 | { 189 | uid:'u1234', 190 | alias: "14计科全英班-狗蛋", 191 | nickname: "xxx", 192 | avatar: `http://localhost:9999/_hola/avatars/2.jpg`, 193 | signature: "xx" 194 | }, 195 | { 196 | uid: 'u3344', 197 | alias: "", 198 | nickname: "昵称索", 199 | avatar: `http://localhost:9999/_hola/avatars/3.jpg`, 200 | signature: "xxxxx" 201 | } 202 | ] 203 | }, 204 | ... 205 | ] 206 | ``` 207 | 208 | ```bash 209 | # get-friend-info 210 | # 获取一个好友的详细信息 211 | # 发送: 212 | { 213 | uid: 'u10000', 214 | friendUid: 'u102322' 215 | } 216 | # 返回: 217 | { 218 | // 基本的用户信息 219 | // alias: '备注的名称' 220 | } 221 | ``` 222 | 223 | ```bash 224 | # delete-friend 225 | # 删除好友(单方面的删除) 226 | # 发送: 227 | { 228 | uid: 'u10000', 229 | friendUid: 'u102322' 230 | } 231 | # 返回: 232 | { 233 | code: 0, 234 | data: null, 235 | message: '成功删除好友' 236 | } 237 | ``` 238 | 239 | ```bash 240 | # get-group-info 241 | # 获取一个群组的详细信息 242 | # 发送: 243 | gid 244 | # 返回: 245 | { 246 | // 基本信息 247 | } 248 | ``` 249 | 250 | ```bash 251 | # leave-group 252 | # 退出群组 253 | # 发送: 254 | { 255 | uid: 'u10005', 256 | gid: 'g123452' 257 | } 258 | # 返回: 259 | { 260 | code: 0, 261 | data: null, 262 | message: '成功退出群组' 263 | } 264 | ``` 265 | 266 | ```bash 267 | # dissolve-group 268 | # 解散群组 269 | # 发送: 270 | { 271 | uid: 'u10005', 272 | gid: 'g123452' 273 | } 274 | # 返回: 275 | { 276 | code: 0, 277 | data: null, 278 | message: '成功解散群组' 279 | } 280 | ``` 281 | 282 | ### functions 283 | 284 | ```bash 285 | # search-contact 286 | # 搜索可添加的好友或可加入的群组 287 | # 发送: 288 | { 289 | uid: 'u12311', 290 | keyword: 'xxx' 291 | } 292 | # 返回: 293 | { 294 | friendArr: [{ 295 | uid: 'u13241', 296 | avatar: 'xxx', 297 | nickname: 'xxx' 298 | }, 299 | ... 300 | ], 301 | groupArr: [{ 302 | gid: 'g123412', 303 | avatar: 'xxx', 304 | nickname: 'xxx' 305 | }, 306 | ... 307 | ] 308 | } 309 | ``` 310 | 311 | ```bash 312 | # add-friend 313 | # 添加好友 314 | # 发送: 315 | { 316 | uid: 'u31431', 317 | friendUid: 'u10000' 318 | } 319 | # 返回: 320 | { 321 | code: 0, 322 | data: null, 323 | message: 'Success to add friend' 324 | } 325 | ``` 326 | 327 | ```bash 328 | # join-group 329 | # 加入群组 330 | # 发送: 331 | { 332 | uid: 'u10000', 333 | gid: 'g31431', 334 | } 335 | # 返回: 336 | { 337 | code: 0, 338 | data: null, 339 | message: 'Success to join group' 340 | } 341 | ``` 342 | -------------------------------------------------------------------------------- /client/.electron-vue/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'renderer'; 4 | 5 | const path = require('path'); 6 | const { dependencies } = require('../package.json'); 7 | const webpack = require('webpack'); 8 | 9 | const BabiliWebpackPlugin = require('babili-webpack-plugin'); 10 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 11 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 12 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 13 | 14 | /** 15 | * List of node_modules to include in webpack bundle 16 | * 17 | * Required for specific packages like Vue UI libraries 18 | * that provide pure *.vue files that need compiling 19 | * https://simulatedgreg.gitbooks.io/electron-vue/content/en/webpack-configurations.html#white-listing-externals 20 | */ 21 | let whiteListedModules = ['vue']; 22 | 23 | let rendererConfig = { 24 | devtool: '#cheap-module-eval-source-map', 25 | entry: { 26 | renderer: path.join(__dirname, '../src/renderer/main.js') 27 | }, 28 | externals: [ 29 | ...Object.keys(dependencies || {}).filter( 30 | d => !whiteListedModules.includes(d) 31 | ) 32 | ], 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(js|vue)$/, 37 | enforce: 'pre', 38 | exclude: /node_modules/, 39 | use: { 40 | loader: 'eslint-loader', 41 | options: { 42 | formatter: require('eslint-friendly-formatter') 43 | } 44 | } 45 | }, 46 | { 47 | test: /\.css$/, 48 | use: ExtractTextPlugin.extract({ 49 | fallback: 'style-loader', 50 | use: 'css-loader' 51 | }) 52 | }, 53 | { 54 | test: /\.styl$/, 55 | use: ExtractTextPlugin.extract({ 56 | fallback: 'style-loader', 57 | use: 'css-loader!stylus-loader' 58 | }) 59 | }, 60 | { 61 | test: /\.html$/, 62 | use: 'vue-html-loader' 63 | }, 64 | { 65 | test: /\.js$/, 66 | use: 'babel-loader', 67 | exclude: /node_modules/ 68 | }, 69 | { 70 | test: /\.node$/, 71 | use: 'node-loader' 72 | }, 73 | { 74 | test: /\.vue$/, 75 | use: { 76 | loader: 'vue-loader', 77 | options: { 78 | extractCSS: process.env.NODE_ENV === 'production', 79 | loaders: { 80 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 81 | scss: 'vue-style-loader!css-loader!sass-loader', 82 | stylus: 'vue-style-loader!css-loader!stylus-loader' 83 | } 84 | } 85 | } 86 | }, 87 | { 88 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 89 | use: { 90 | loader: 'url-loader', 91 | query: { 92 | limit: 10000, 93 | name: 'imgs/[name]--[folder].[ext]' 94 | } 95 | } 96 | }, 97 | { 98 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 99 | loader: 'url-loader', 100 | options: { 101 | limit: 10000, 102 | name: 'media/[name]--[folder].[ext]' 103 | } 104 | }, 105 | { 106 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 107 | use: { 108 | loader: 'url-loader', 109 | query: { 110 | limit: 10000, 111 | name: 'fonts/[name]--[folder].[ext]' 112 | } 113 | } 114 | } 115 | ] 116 | }, 117 | node: { 118 | __dirname: process.env.NODE_ENV !== 'production', 119 | __filename: process.env.NODE_ENV !== 'production' 120 | }, 121 | plugins: [ 122 | new ExtractTextPlugin('styles.css'), 123 | new HtmlWebpackPlugin({ 124 | filename: 'index.html', 125 | template: path.resolve(__dirname, '../src/index.ejs'), 126 | minify: { 127 | collapseWhitespace: true, 128 | removeComments: true 129 | }, 130 | nodeModules: 131 | process.env.NODE_ENV !== 'production' 132 | ? path.resolve(__dirname, '../node_modules') 133 | : false 134 | }), 135 | new webpack.HotModuleReplacementPlugin(), 136 | new webpack.NoEmitOnErrorsPlugin(), 137 | 138 | // load global stylus variables for every Vue components through webpack config 139 | new webpack.LoaderOptionsPlugin({ 140 | options: { 141 | stylus: { 142 | import: [ 143 | path.resolve(__dirname, '../src/renderer/styles/_variables.styl') 144 | ] 145 | } 146 | } 147 | }) 148 | ], 149 | output: { 150 | filename: '[name].js', 151 | libraryTarget: 'commonjs2', 152 | path: path.join(__dirname, '../dist/electron') 153 | }, 154 | resolve: { 155 | alias: { 156 | '@': path.join(__dirname, '../src/renderer'), 157 | vue$: 'vue/dist/vue.esm.js' 158 | }, 159 | extensions: ['.js', '.vue', '.json', '.css', '.node'] 160 | }, 161 | target: 'electron-renderer' 162 | }; 163 | 164 | /** 165 | * Adjust rendererConfig for development settings 166 | */ 167 | if (process.env.NODE_ENV !== 'production') { 168 | rendererConfig.plugins.push( 169 | new webpack.DefinePlugin({ 170 | __static: `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"` 171 | }) 172 | ); 173 | } 174 | 175 | /** 176 | * Adjust rendererConfig for production settings 177 | */ 178 | if (process.env.NODE_ENV === 'production') { 179 | rendererConfig.devtool = ''; 180 | 181 | rendererConfig.plugins.push( 182 | new BabiliWebpackPlugin(), 183 | new CopyWebpackPlugin([ 184 | { 185 | from: path.join(__dirname, '../static'), 186 | to: path.join(__dirname, '../dist/electron/static'), 187 | ignore: ['.*'] 188 | }, 189 | { 190 | from: path.join(__dirname, '../src/renderer/lang/langs'), 191 | to: path.join(__dirname, '../dist/electron/langs') 192 | } 193 | ]), 194 | new webpack.DefinePlugin({ 195 | 'process.env.NODE_ENV': '"production"' 196 | }), 197 | new webpack.LoaderOptionsPlugin({ 198 | minimize: true 199 | }) 200 | ); 201 | } 202 | 203 | module.exports = rendererConfig; 204 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/parts/friend-info.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 132 | 133 | 276 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/contact-info/parts/group-info.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 136 | 137 | 289 | -------------------------------------------------------------------------------- /client/src/renderer/components/login.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 165 | 166 | -------------------------------------------------------------------------------- /client/src/renderer/components/content/settings/parts/profile-setting.vue: -------------------------------------------------------------------------------- 1 | 108 | 109 | 199 | 200 | 366 | --------------------------------------------------------------------------------