diff --git a/backend/chatServer.js b/backend/chatServer.js index e717aa59..8b7f91e6 100644 --- a/backend/chatServer.js +++ b/backend/chatServer.js @@ -3,6 +3,7 @@ import express from 'express' import { createServer } from 'http' import createChatServer from 'socket.io' import { createChatMessage } from './service/chat' +import { addReaction, removeReaction } from './service/reaction' dotenv() const server = createServer(express()) @@ -10,19 +11,26 @@ const io = createChatServer(server, { cors: { origin: process.env.FRONTEND_HOST, credentials: true }, }) -const namespace = io.of('chat') +const namespace = io.of(/^\/chat\/\w+$/) namespace.use((socket, next) => { // TODO jwt 검증 로직 필요 next() }) namespace.on('connection', socket => { + const { workspaceUserInfoId } = socket.handshake.query + socket.join(workspaceUserInfoId) + socket.on('invite channel', ({ channelId, origin, newMember }) => { + origin + .concat(newMember) + .forEach(member => + namespace.in(member).emit('invited channel', { channelId, newMember }), + ) + }) socket.on('new message', async data => { - // TODO 특정 채널로 전송하도록 변경, db에 저장 필요 (현재는 자신 제외 전체 전송) - const { channelId, creator } = socket.handshake.query - const { contents } = data + const { contents, channelId } = data const { data: result } = await createChatMessage({ - creator, + creator: workspaceUserInfoId, channelId, contents, }) @@ -30,9 +38,38 @@ namespace.on('connection', socket => { message: { ...data, _id: result._id, createdAt: result.createdAt }, }) }) - socket.on('join-room', roomId => { - socket.join(roomId) - console.log('joined', roomId) + socket.on('update reaction', async data => { + const { emoji, chatId, userInfo, channelId, type } = data + //1 = add, 0 = remove + const result = + type === 1 + ? await addReaction({ + workspaceUserInfoId, + chatId, + emoticon: emoji, + }) + : await removeReaction({ + workspaceUserInfoId, + chatId, + emoticon: emoji, + }) + + namespace.in(channelId).emit('update reaction', { + reaction: { + chatId: chatId, + emoji: emoji, + workspaceUserInfoId: userInfo._id, + displayName: userInfo.displayName, + type: result ? type : false, + }, + }) + }) + + socket.on('join-room', (channelList = []) => { + socket.join(channelList) + }) + socket.on('leave-room', roomId => { + socket.leave(roomId) }) }) diff --git a/backend/config/passport.js b/backend/config/passport.js index af553142..bf03add2 100644 --- a/backend/config/passport.js +++ b/backend/config/passport.js @@ -18,7 +18,9 @@ async function gitStrategyLogin(profiles) { const data = await User.create({ OAuthId: profiles.id, fullName: profiles.username, - profileUrl: profiles.photos[0].value, + profileUrl: + profiles?.photos[0]?.value || + 'https://user-images.githubusercontent.com/56837413/102013276-583f6000-3d92-11eb-8184-186bc09f2a98.jpg', isDeleted: false, }) return { diff --git a/backend/config/s3.js b/backend/config/s3.js new file mode 100644 index 00000000..72ec1303 --- /dev/null +++ b/backend/config/s3.js @@ -0,0 +1,15 @@ +import AWS from 'aws-sdk' +require('dotenv').config() + +const S3 = new AWS.S3({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESSKEY, + secretAccessKey: process.env.S3_SECRETKEY, + }, +}) + +const BUCKETNAME = process.env.S3_BUCKETNAME + +module.exports = { S3, BUCKETNAME } diff --git a/backend/controller/file/file.js b/backend/controller/file/file.js new file mode 100644 index 00000000..95ca2092 --- /dev/null +++ b/backend/controller/file/file.js @@ -0,0 +1,24 @@ +import { asyncWrapper } from '../../util' +import service from '../../service/file' + +exports.getFileURL = asyncWrapper(async (req, res) => { + const { code, success, data } = await service.getFileURL({ + ...req.query, + }) + return res.status(code).json({ success, data }) +}) + +exports.uploadFile = asyncWrapper(async (req, res) => { + const { code, success, data } = await service.uploadFile({ + file: req.file, + userId: req.user.id, + }) + return res.status(code).json({ success, data }) +}) + +exports.deleteFile = asyncWrapper(async (req, res) => { + const { code, success } = await service.deleteFile({ + ...req.query, + }) + return res.status(code).json({ success }) +}) diff --git a/backend/controller/file/index.js b/backend/controller/file/index.js new file mode 100644 index 00000000..e4ab4763 --- /dev/null +++ b/backend/controller/file/index.js @@ -0,0 +1,14 @@ +import express from 'express' +const { Auth } = require('../../middleware/auth') +const router = express.Router() +const controller = require('./file') + +const multer = require('multer') +const storage = multer.memoryStorage() +const uploader = multer({ storage: storage }) + +router.get('/', Auth, controller.getFileURL) +router.post('/', Auth, uploader.single('file'), controller.uploadFile) +router.delete('/', Auth, controller.deleteFile) + +module.exports = router diff --git a/backend/controller/index.js b/backend/controller/index.js index d65bf5fe..c4f8cbe7 100644 --- a/backend/controller/index.js +++ b/backend/controller/index.js @@ -1,10 +1,10 @@ import express from 'express' - import channelCotroller from './channel' import searchCotroller from './search' import userController from './user' import workspaceController from './workspace' import chatController from './chat' +import fileController from './file' const router = express.Router() router.use('/channel', channelCotroller) @@ -12,5 +12,6 @@ router.use('/search', searchCotroller) router.use('/user', userController) router.use('/workspace', workspaceController) router.use('/chat', chatController) +router.use('/file', fileController) module.exports = router diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f0a688aa..320e7e60 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1155,6 +1155,161 @@ paths: default: description: Default error sample response + '/api/file/{fileId}': + summary: get chat messages + description: get chat messages + get: + tags: + - file + operationId: '' + parameters: + - name: fileId + in: path + description: fileId + required: true + explode: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + success: + type: string + data: + type: object + properties: + url: + type: string + originalName: + type: string + + description: success response + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: not modified + '401': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: unauthorized + default: + description: Default error sample response + delete: + tags: + - file + operationId: '' + parameters: + - name: fileId + in: path + description: fileId + required: true + explode: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + + description: success response + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: not modified + '401': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: unauthorized + default: + description: Default error sample response + '/api/file': + summary: file upload + description: file upload + post: + tags: + - file + operationId: '' + parameters: + - name: file + in: query + description: file + required: true + explode: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + success: + type: string + data: + type: object + properties: + fileId: + type: string + fileName: + type: string + fileType: + type: string + creator: + type: string + etag: + type: string + description: success response + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: not modified + '401': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: unauthorized + default: + description: Default error sample response + components: schemas: workspaceUserInfo: diff --git a/backend/model/File.js b/backend/model/File.js index 6edd541e..0e0cbe52 100644 --- a/backend/model/File.js +++ b/backend/model/File.js @@ -12,6 +12,9 @@ const fileSchema = mongoose.Schema( name: { type: String, }, + originalName: { + type: String, + }, creator: { type: Schema.Types.ObjectId, ref: 'User', diff --git a/backend/package-lock.json b/backend/package-lock.json index b50f6b46..f9453e06 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1307,6 +1307,11 @@ "picomatch": "^2.0.4" } }, + "append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1348,6 +1353,22 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, + "aws-sdk": { + "version": "2.348.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.348.0.tgz", + "integrity": "sha512-TfguapuOAwk7EG8zhYJPjkCaF4tyGjfgcXLkYbWbuS4O6E8pn0x2K5Yt1KXwLiWxG0fzKCLiiaNA5H7bKAP4YQ==", + "requires": { + "buffer": "4.9.1", + "events": "1.1.1", + "ieee754": "1.1.8", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.1.0", + "xml2js": "0.4.19" + } + }, "babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -1362,6 +1383,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -1544,6 +1570,16 @@ "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==" }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1552,8 +1588,39 @@ "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "busboy": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", + "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", + "requires": { + "dicer": "0.2.5", + "readable-stream": "1.1.x" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } + } }, "bytes": { "version": "3.0.0", @@ -1720,6 +1787,17 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "concurrently": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-5.3.0.tgz", @@ -1917,6 +1995,38 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "dicer": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", + "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", + "requires": { + "readable-stream": "1.1.x", + "streamsearch": "0.1.2" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2513,6 +2623,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, "express": { "version": "4.16.4", "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", @@ -2676,6 +2791,11 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha1-invTcYa23d84E/I4WLV+yq9eQdQ=" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2856,6 +2976,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -3051,6 +3176,11 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3337,7 +3467,6 @@ "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dev": true, "requires": { "minimist": "^1.2.5" } @@ -3434,6 +3563,21 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "multer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.2.tgz", + "integrity": "sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==", + "requires": { + "append-field": "^1.0.0", + "busboy": "^0.2.11", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.1", + "object-assign": "^4.1.1", + "on-finished": "^2.3.0", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3899,6 +4043,11 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4203,6 +4352,11 @@ "sparse-bitfield": "^3.0.3" } }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -4443,6 +4597,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -4658,6 +4817,11 @@ "mime-types": "~2.1.24" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -4794,6 +4958,22 @@ "punycode": "^2.1.0" } }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, "url-parse-lax": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", @@ -4812,6 +4992,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + }, "v8-compile-cache": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", @@ -4949,6 +5134,25 @@ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 358b8c08..5759dbf0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ }, "homepage": "https://github.com/boostcamp-2020/Project12-C-Slack-Web", "dependencies": { + "aws-sdk": "^2.348.0", "concurrently": "^5.3.0", "cookie-parser": "^1.4.5", "core-js": "^3.6.5", @@ -29,6 +30,7 @@ "jsonwebtoken": "^8.5.1", "mongoose": "^5.10.15", "morgan": "~1.9.1", + "multer": "^1.4.2", "nodemon": "^2.0.4", "passport": "^0.4.1", "passport-github": "^1.1.0", diff --git a/backend/service/channel.js b/backend/service/channel.js index 07beb0a6..a2894da0 100644 --- a/backend/service/channel.js +++ b/backend/service/channel.js @@ -77,7 +77,6 @@ const inviteUserDB = async ({ channelId, workspaceUserInfoId }) => { channelId, isMute: false, notification: 0, - sectionName: null, }) channelConfig.save() }) diff --git a/backend/service/chat.js b/backend/service/chat.js index 7e3403d5..67e8791f 100644 --- a/backend/service/chat.js +++ b/backend/service/chat.js @@ -22,12 +22,10 @@ const createChatMessage = async ({ channelId, creator, contents }) => { } const getReplyMessage = async ({ channelId, parentId }) => { - console.log('channelId, parentId', channelId, parentId) verifyRequiredParams(channelId, parentId) const result = await dbErrorHandler(() => Chat.getReplyMessages({ channelId, parentId }), ) - console.log('result', result) return { code: statusCode.OK, data: result, success: true } } diff --git a/backend/service/file.js b/backend/service/file.js new file mode 100644 index 00000000..a43f04c6 --- /dev/null +++ b/backend/service/file.js @@ -0,0 +1,75 @@ +import { verifyRequiredParams, dbErrorHandler } from '../util/' +import statusCode from '../util/statusCode' +import { S3, BUCKETNAME } from '../config/s3' +import { File } from '../model/File' +import mongoose from 'mongoose' +const ObjectId = mongoose.Types.ObjectId + +const getFileURL = async ({ fileId }) => { + verifyRequiredParams(fileId) + + const { name, originalName } = await dbErrorHandler(() => + File.findOne({ + _id: ObjectId(fileId), + }), + ) + + return { + code: statusCode.OK, + data: { + url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKETNAME}/${name}`, + originalName: originalName, + }, + success: true, + } +} + +const uploadFile = async ({ file, userId }) => { + verifyRequiredParams(file, userId) + const fileName = `${file.fieldname}-${Date.now()}-${file.originalname}` + + const result = await S3.putObject({ + Bucket: BUCKETNAME, + Key: fileName, + ACL: 'public-read', + Body: file.buffer, + }).promise() + + const data = await dbErrorHandler(() => + File.create({ + name: fileName, + originalName: file.originalname, + path: '/', + fileType: file.mimetype, + creator: userId, + }), + ) + return { + code: statusCode.OK, + data: { + fileId: data._id, + fileName: data.originalName, + fileType: data.fileType, + creator: data.creator, + }, + success: true, + } +} + +const deleteFile = async ({ fileId }) => { + verifyRequiredParams(fileId) + const { name } = await dbErrorHandler(() => + File.findOneAndDelete({ _id: ObjectId(fileId) }), + ) + await S3.deleteObject({ + Bucket: BUCKETNAME, + Key: name, + }).promise() + return { code: statusCode.OK, success: true } +} + +module.exports = { + uploadFile, + getFileURL, + deleteFile, +} diff --git a/backend/service/reaction.js b/backend/service/reaction.js index 503bce3c..55f2910e 100644 --- a/backend/service/reaction.js +++ b/backend/service/reaction.js @@ -3,18 +3,24 @@ import { verifyRequiredParams, dbErrorHandler } from '../util' const addReaction = async ({ chatId, workspaceUserInfoId, emoticon }) => { verifyRequiredParams(chatId, workspaceUserInfoId, emoticon) - const result = await dbErrorHandler(() => - Reaction.create({ chatId, workspaceUserInfoId, emoticon }), + const isExist = await dbErrorHandler(() => + Reaction.find({ chatId, workspaceUserInfoId, emoticon }), ) - return result + if (isExist.length === 0) { + await dbErrorHandler(() => + Reaction.create({ chatId, workspaceUserInfoId, emoticon }), + ) + } + return isExist.length === 0 } const removeReaction = async ({ chatId, workspaceUserInfoId, emoticon }) => { verifyRequiredParams(chatId, workspaceUserInfoId, emoticon) const result = await dbErrorHandler(() => - Reaction.deleteOne({ chatId, workspaceUserInfoId, emoticon }), + Reaction.findOneAndDelete({ chatId, workspaceUserInfoId, emoticon }), ) - return result + + return result && true } module.exports = { addReaction, removeReaction } diff --git a/backend/service/workspace.js b/backend/service/workspace.js index 00d717cc..1d7f491f 100644 --- a/backend/service/workspace.js +++ b/backend/service/workspace.js @@ -46,7 +46,6 @@ const createWorkspace = async params => { ChannelConfig.create({ channelId: ObjectId(channelData._id), workspaceUserInfoId: ObjectId(channelData.creator), - sectionName: null, }), ) await dbErrorHandler(() => diff --git a/frontend/src/atom/AddReactionButton/AddReactionButton.js b/frontend/src/atom/AddReactionButton/AddReactionButton.js index a672e1d0..d881f4a6 100644 --- a/frontend/src/atom/AddReactionButton/AddReactionButton.js +++ b/frontend/src/atom/AddReactionButton/AddReactionButton.js @@ -3,18 +3,15 @@ import styled, { css } from 'styled-components' import { useRecoilState } from 'recoil' import { modalRecoil } from '../../store' import EmojiModal from '../EmojiModal' + import Icon from '../Icon' import { PLUS, SMILE } from '../../constant/icon' import { COLOR } from '../../constant/style' import calcEmojiModalLocation from '../../util/calculateEmojiModalLocation' -function AddReactionButton() { +function AddReactionButton({ updateReactionHandler }) { const [modal, setModal] = useRecoilState(modalRecoil) - const sendHandler = emoji => { - console.log('TODO: send reaction', emoji.native) - } - const closeHandler = () => { setModal(null) } @@ -24,7 +21,7 @@ function AddReactionButton() { setModal( { return ( - + {children} ) } const StyledButton = styled.button` - font-size: 15px; - height: 36px; - padding: 0 12px 1px; + font-size: ${({ size }) => (size === 'small' ? '8px' : '15px')}; + font-weight: 900; + height: ${({ size }) => (size === 'small' ? '' : '36px')}; + padding: ${({ size }) => (size === 'small' ? '' : '0 12px 1px')}; border-style: none; border-radius: 4px; outline: none; @@ -37,8 +44,6 @@ const StyledButton = styled.button` if (type === 'transparent') return `1px solid ${COLOR.TRANSPARENT_GRAY}` if (type === 'icon') return 'transparent' }}; - font-size: 15px; - font-weight: 900; &:hover { ${({ type, disabled }) => { if (disabled) return diff --git a/frontend/src/atom/Button/Button.stories.js b/frontend/src/atom/Button/Button.stories.js index 9ae4a6d0..92c9256e 100644 --- a/frontend/src/atom/Button/Button.stories.js +++ b/frontend/src/atom/Button/Button.stories.js @@ -1,5 +1,8 @@ import React from 'react' import Button from './Button' +import Icon from '../Icon' +import { CLOSE } from '../../constant/icon' +import { COLOR } from '../../constant/style' export default { title: 'Atom/Button', @@ -18,3 +21,10 @@ Transparent.args = { children: 'Transparent', type: 'transparent', } + +export const SmallIcon = Template.bind({}) +SmallIcon.args = { + children: , + type: 'icon', + size: 'small', +} diff --git a/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js b/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js index f1dbc328..c333ee85 100644 --- a/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js +++ b/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js @@ -9,6 +9,7 @@ import Icon from '../Icon' import { workspaceRecoil } from '../../store' import { STAR, COLOREDSTAR } from '../../constant/icon' import { atom, useRecoilState, useRecoilValue } from 'recoil' +import { isEmpty } from '../../util' import useChannelList from '../../hooks/useChannelList' function ChannelStarBtn({ channel }) { @@ -27,7 +28,7 @@ function ChannelStarBtn({ channel }) { const updateSection = async () => { try { let sectionName = null - if (sectionInfo === null) sectionName = 'Starred' + if (isEmpty(sectionInfo)) sectionName = 'Starred' const { data } = await request.PATCH('/api/channel/section', { workspaceUserInfoId: workspaceUserInfo._id, @@ -49,7 +50,7 @@ function ChannelStarBtn({ channel }) { return ( - {sectionInfo !== null ? ( + {!isEmpty(sectionInfo) ? ( ) : ( diff --git a/frontend/src/atom/ThreadReactionCard/ThreadReactionCard.js b/frontend/src/atom/ThreadReactionCard/ThreadReactionCard.js index 79f4fbb4..fbc3f650 100644 --- a/frontend/src/atom/ThreadReactionCard/ThreadReactionCard.js +++ b/frontend/src/atom/ThreadReactionCard/ThreadReactionCard.js @@ -1,40 +1,43 @@ import React, { useState, useEffect } from 'react' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import { useRecoilState } from 'recoil' import { workspaceRecoil } from '../../store' import { COLOR } from '../../constant/style' -function ThreadReactionCard({ emoji, users }) { +function ThreadReactionCard({ reaction, chatId, updateReactionHandler }) { const [userInfo, setUserInfo] = useRecoilState(workspaceRecoil) const [myReaction, setMyReaction] = useState(false) useEffect(() => { setMyReaction(hasMyReaction()) - }, []) + }, [reaction.users.length]) const hasMyReaction = () => { - const result = users.every(user => { - return user._id !== userInfo._id + if (reaction.users[0] === undefined) { + reaction.set = false + return false + } + const result = reaction.users.every(user => { + return user?._id !== userInfo?._id }) + if (!result) { + reaction.set = true + } else { + reaction.set = false + } return !result } - const removeMyReaction = () => { - console.log('TODO: remove my reaction', emoji) - } - - const addMyReaction = () => { - console.log('TODO: add my reaction', emoji) - } - return ( - - {emoji} - {users.length} - + reaction.users.length !== 0 && ( + updateReactionHandler(reaction.emoji)} + myReaction={myReaction} + > + {reaction.emoji} + {reaction.users.length} + + ) ) } diff --git a/frontend/src/constant/icon.js b/frontend/src/constant/icon.js index 8b8bb3a8..d8eeaa0a 100644 --- a/frontend/src/constant/icon.js +++ b/frontend/src/constant/icon.js @@ -16,6 +16,7 @@ import { faCheck, faPlusSquare, faShare, + faFile, } from '@fortawesome/free-solid-svg-icons' import { faStar, @@ -51,3 +52,4 @@ export const CHECK = faCheck export const PLUSSQURE = faPlusSquare export const SMILE = faSmile export const SHARE = faShare +export const FILE = faFile diff --git a/frontend/src/hooks/useSocket.js b/frontend/src/hooks/useSocket.js new file mode 100644 index 00000000..e59c2b77 --- /dev/null +++ b/frontend/src/hooks/useSocket.js @@ -0,0 +1,73 @@ +import { useEffect } from 'react' +import { socketRecoil, workspaceRecoil } from '../store' +import { useParams } from 'react-router-dom' +import { useRecoilState, useRecoilValue } from 'recoil' +import { isEmpty } from '../util' +import io from 'socket.io-client' +import useChannelInfo from './useChannelInfo' +import useChannelList from './useChannelList' + +const baseURL = + process.env.NODE_ENV === 'development' + ? process.env.REACT_APP_DEV_CHAT_HOST + : process.env.REACT_APP_CHAT_HOST + +const useSocket = () => { + const { workspaceId } = useParams() + const workspaceUserInfo = useRecoilValue(workspaceRecoil) + const [socket, setSocket] = useRecoilState(socketRecoil) + const [channelList, setChannels] = useChannelList() + const [channelInfo, updateChannelInfo] = useChannelInfo() + + useEffect(() => { + if (workspaceId && workspaceUserInfo) { + setSocket( + io(`${baseURL}/${workspaceId}`, { + query: { + workspaceId, + workspaceUserInfoId: workspaceUserInfo._id, + }, + }), + ) + } + }, [workspaceId, workspaceUserInfo]) + + useEffect(() => { + if (socket && !isEmpty(channelList)) { + socket.emit( + 'join-room', + channelList.map(channel => channel.channelId._id), + ) + socket.on('invited channel', ({ channelId, newMember }) => { + console.log('invited') + if (channelId === channelInfo.channelId._id) + updateChannelInfo(channelId) + if (newMember === workspaceUserInfo._id) setChannels() + }) + } + return () => { + if (socket) + socket.emit( + 'leave-room', + channelList.map(channel => channel.channelId._id), + ) + } + }, [socket, channelList]) + + useEffect(() => { + if (socket && !isEmpty(channelInfo) && !isEmpty(workspaceUserInfo)) { + socket.on('invited channel', ({ channelId, newMember }) => { + if (channelId === channelInfo.channelId._id) + updateChannelInfo(channelId) + if (newMember.includes(workspaceUserInfo._id)) setChannels() + }) + } + return () => { + if (socket) socket.off('invited channel') + } + }, [socket, channelInfo, workspaceUserInfo]) + + return [socket] +} + +export default useSocket diff --git a/frontend/src/organism/ActionBar/ActionBar.js b/frontend/src/organism/ActionBar/ActionBar.js index 1132bd1c..5d1f047c 100644 --- a/frontend/src/organism/ActionBar/ActionBar.js +++ b/frontend/src/organism/ActionBar/ActionBar.js @@ -1,8 +1,8 @@ -import React, { useState } from 'react' -import styled, { css } from 'styled-components' +import React from 'react' +import styled from 'styled-components' import EmojiModal from '../../atom/EmojiModal' import Icon from '../../atom/Icon' -import { COLOR, SIZE } from '../../constant/style' +import { COLOR } from '../../constant/style' import { SMILE, COMMENTDOTS, @@ -10,18 +10,13 @@ import { BOOKMARK, ELLIPSISV, } from '../../constant/icon' -import { toast } from 'react-toastify' import calcEmojiModalLocation from '../../util/calculateEmojiModalLocation' import { modalRecoil } from '../../store' import { useRecoilState } from 'recoil' -function ActionBar({ setOpenModal, chatId }) { +function ActionBar({ setOpenModal, chatId, updateReactionHandler }) { const [modal, setModal] = useRecoilState(modalRecoil) - const sendHandler = emoji => { - console.log('TODO: send reaction', emoji.native) - } - const closeHandler = () => { setOpenModal(false) setModal(null) @@ -33,7 +28,7 @@ function ActionBar({ setOpenModal, chatId }) { setOpenModal(true) setModal( - 👍 - 👏 - 😄 + updateReactionHandler('👍')}> + 👍 + + updateReactionHandler('👏')}> + 👏 + + updateReactionHandler('😄')}> + 😄 + diff --git a/frontend/src/organism/ChannelHeader/ChannelHeader.js b/frontend/src/organism/ChannelHeader/ChannelHeader.js index 0ffdbd5b..7e0bfeef 100644 --- a/frontend/src/organism/ChannelHeader/ChannelHeader.js +++ b/frontend/src/organism/ChannelHeader/ChannelHeader.js @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useEffect } from 'react' import styled from 'styled-components' -import { useSetRecoilState } from 'recoil' +import { useSetRecoilState, useRecoilValue } from 'recoil' import Icon from '../../atom/Icon' import { ADDUSER, INFOCIRCLE } from '../../constant/icon' diff --git a/frontend/src/organism/ChannelList/ChannelList.js b/frontend/src/organism/ChannelList/ChannelList.js index b902e822..f0f2aa34 100644 --- a/frontend/src/organism/ChannelList/ChannelList.js +++ b/frontend/src/organism/ChannelList/ChannelList.js @@ -4,34 +4,33 @@ import styled from 'styled-components' import { toast } from 'react-toastify' import SectionLabel from '../SectionLabel' import SideMenuList from '../SideMenuList' -import { useRecoilState } from 'recoil' - +import { useRecoilValue } from 'recoil' +import { isEmpty } from '../../util' import { workspaceRecoil } from '../../store' import useChannelList from '../../hooks/useChannelList' -function ChannelList(props) { +function ChannelList() { const [list, setList] = useState([]) - const [Channels, setChannels] = useChannelList() - const [userInfo, setUserInfo] = useRecoilState(workspaceRecoil) - + const [channels] = useChannelList() + const userInfo = useRecoilValue(workspaceRecoil) const history = useHistory() - let sectionMap = new Map() + const sectionMap = new Map() useEffect(() => { - if (Channels === undefined) return - if (Object.keys(Channels).length !== 0) { + if (channels === undefined) return + if (!isEmpty(channels)) { if (userInfo.sections) - userInfo.sections.map((sectionName, idx) => { + userInfo.sections.map(sectionName => { sectionMap.set(sectionName, []) }) SectionOrganizing() } - }, [Channels]) + }, [channels]) const SectionOrganizing = () => { try { - Channels.map((channel, index) => { + channels.forEach(channel => { if (channel.sectionName == null) { if (channel.channelId.channelType === 2) { checkHasKeyAndSetKeyInMap(sectionMap, 'Direct messages', channel) @@ -51,12 +50,12 @@ function ChannelList(props) { } } - const renderChannelSectionList = list.map((section, index) => { + const renderChannelSectionList = list.map(([sectionName, lists], index) => { return ( ) }) @@ -70,13 +69,8 @@ function ChannelList(props) { } const checkHasKeyAndSetKeyInMap = (map, key, data) => { - if (map.has(key)) { - let value = map.get(key) - value.push(data) - map.set(key, value) - } else { - map.set(key, [data]) - } + if (map.has(key)) map.set(key, [...map.get(key)].concat(data)) + else map.set(key, [data]) } const checkHasDefaultChannel = map => { diff --git a/frontend/src/organism/ChatMessage/ChatMessage.js b/frontend/src/organism/ChatMessage/ChatMessage.js index de058017..5924e19e 100644 --- a/frontend/src/organism/ChatMessage/ChatMessage.js +++ b/frontend/src/organism/ChatMessage/ChatMessage.js @@ -5,16 +5,68 @@ import ChatContent from '../../atom/ChatContent' import ThreadReactionList from '../ThreadReactionList' import ActionBar from '../ActionBar' import { SIZE, COLOR } from '../../constant/style' +import { workspaceRecoil, socketRecoil } from '../../store' +import { useRecoilValue } from 'recoil' +import { useParams } from 'react-router-dom' const ChatMessage = forwardRef( ( { userInfo, reply, reactions, _id, createdAt, contents, type = 'chat' }, ref, ) => { + const { channelId } = useParams() const [openModal, setOpenModal] = useState(false) + const [hover, setHover] = useState(false) + const workspaceUserInfo = useRecoilValue(workspaceRecoil) + const socket = useRecoilValue(socketRecoil) + + const updateReaction = ({ emoji, chatId, channelId, type }) => { + const reaction = { + emoji, + chatId, + channelId, + type, + userInfo: { + _id: workspaceUserInfo._id, + displayName: workspaceUserInfo.displayName, + }, + } + socket.emit('update reaction', reaction) + } + + const updateReactionHandler = emoji => { + let done = false + reactions.map((reaction, idx) => { + if (reaction.emoji === emoji.native || reaction.emoji === emoji) { + if (reaction.set) { + updateReaction({ + emoji: emoji.native || emoji, + chatId: _id, + channelId, + type: 0, + }) + done = true + } + } + }) + if (!done) { + updateReaction({ + emoji: emoji.native || emoji, + chatId: _id, + channelId, + type: 1, + }) + } + } return ( - + setHover(true)} + onMouseLeave={() => setHover(false)} + > - + )} {/* TODO view thread reply 구현 */} @@ -39,9 +95,15 @@ const ChatMessage = forwardRef( )} {/* TODO Action bar 구현 */} - - - + {(hover || openModal) && ( + + + + )} ) }, @@ -54,13 +116,7 @@ const ActionBarStyle = styled.div` top: -15px; right: 10px; border-radius: 5px; - display: none; - &:hover { - display: flex; - } - display: ${({ openModal }) => { - return openModal ? 'flex' : 'none' - }}; + display: flex; ` const MessageContents = styled.div` width: auto; @@ -83,9 +139,6 @@ const StyledMessageContainer = styled.div` }} &:hover { background-color: ${COLOR.HOVER_GRAY}; - ${ActionBarStyle} { - display: flex; - } } ` diff --git a/frontend/src/organism/ChatRoom/ChatRoom.js b/frontend/src/organism/ChatRoom/ChatRoom.js index 5b3c159c..5cec90e9 100644 --- a/frontend/src/organism/ChatRoom/ChatRoom.js +++ b/frontend/src/organism/ChatRoom/ChatRoom.js @@ -1,28 +1,27 @@ import React, { useEffect, useState, useRef, useCallback } from 'react' import styled from 'styled-components' import { useParams } from 'react-router-dom' -import io from 'socket.io-client' import { useRecoilValue } from 'recoil' import ChatMessage from '../ChatMessage' import { COLOR } from '../../constant/style' import { getChatMessage } from '../../api/chat' import MessageEditor from '../messageEditor/MessageEditor' -import { workspaceRecoil } from '../../store' +import { + workspaceRecoil, + socketRecoil, + currentChannelInfoRecoil, +} from '../../store' import ChannelHeader from '../ChannelHeader' -const baseURL = - process.env.NODE_ENV === 'development' - ? process.env.REACT_APP_DEV_CHAT_HOST - : process.env.REACT_APP_CHAT_HOST - const ChatRoom = () => { const viewport = useRef(null) const target = useRef(null) const messageEndRef = useRef(null) const [targetState, setTargetState] = useState() const workspaceUserInfo = useRecoilValue(workspaceRecoil) + const channelInfo = useRecoilValue(currentChannelInfoRecoil) const { workspaceId, channelId } = useParams() - const [socket, setSocket] = useState(null) + const socket = useRecoilValue(socketRecoil) const [messages, setMessages] = useState([]) const load = useRef(false) @@ -50,6 +49,7 @@ const ChatRoom = () => { const sendMessage = message => { const chat = { contents: message, + channelId, userInfo: { _id: workspaceUserInfo._id, displayName: workspaceUserInfo.displayName, @@ -59,35 +59,68 @@ const ChatRoom = () => { socket.emit('new message', chat) } - useEffect(() => { - if (workspaceUserInfo === null) return false - setSocket( - io(baseURL, { query: { channelId, creator: workspaceUserInfo._id } }), - ) - }, [workspaceId, channelId, workspaceUserInfo]) + const chageReactionState = (messages, reaction) => { + let done = false + if (reaction.type === false) { + return messages + } + return messages.map((message, idx) => { + if (message._id === reaction.chatId) { + message.reactions && + message.reactions.map((item, idx) => { + if (item.emoji === reaction.emoji) { + if (reaction.type) { + item.users = [ + ...item.users, + { + _id: reaction.workspaceUserInfoId, + displayName: reaction.displayName, + }, + ] + } else { + item.users.map((user, idx) => { + if (user._id === reaction.workspaceUserInfoId) { + item.users.splice(idx, 1) + } + }) + } + done = true + } + }) + if (!done && reaction.type === 1) { + message.reactions.push({ + emoji: reaction.emoji, + users: [ + { + _id: reaction.workspaceUserInfoId, + displayName: reaction.displayName, + }, + ], + }) + } + } + return message + }) + } useEffect(() => { if (socket) { - socket.on('connect', () => { - console.log('connected') - }) - socket.on('disconnect', () => { - console.log('disconnected') - }) - socket.emit('join-room', channelId) socket.on('new message', ({ message }) => { - setMessages(messages => [...messages, message]) + if (message.channelId === channelId) + setMessages(messages => [...messages, message]) if (message.userInfo._id === workspaceUserInfo._id) scrollTo() }) + socket.on('update reaction', ({ reaction }) => { + setMessages(messages => chageReactionState(messages, reaction)) + }) } return () => { if (socket) { - socket.off('connect') - socket.off('disconnect') socket.off('new message') + socket.off('update reaction') } } - }, [socket]) + }, [socket, channelId]) useEffect(() => { const option = { diff --git a/frontend/src/organism/FilePreview/FilePreview.js b/frontend/src/organism/FilePreview/FilePreview.js new file mode 100644 index 00000000..2292c62f --- /dev/null +++ b/frontend/src/organism/FilePreview/FilePreview.js @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from 'react' +import styled from 'styled-components' +import request from '../../util/request' +import Icon from '../../atom/Icon' +import { CLOSE, FILE } from '../../constant/icon' +import { COLOR } from '../../constant/style' +import Button from '../../atom/Button/Button' + +function FilePreview({ type, fileId, setIsRender }) { + const [fileData, setFileData] = useState({}) + const [isHover, setIsHover] = useState(false) + + useEffect(() => { + ;(async () => { + const { data } = (await request.GET('/api/file', { fileId })) || {} + setFileData(data?.data) + })() + }, [fileId]) + + const enterMouseHandle = () => { + setIsHover(true) + } + + const leaveMouseHandle = () => { + setIsHover(false) + } + + const handleDelete = async () => { + setIsRender(false) + await request.DELETE('/api/file', { fileId }) + } + + const deleteButton = () => { + return ( + + + + ) + } + + const downloadButton = () => { + return ( + { + if (fileData) window.open(fileData.url, '_blank') + }} + > + Click to Download + + ) + } + return ( + + + + + {fileData?.originalName} + + {isHover && + (type === 'input' + ? deleteButton() + : type === 'message' + ? downloadButton() + : null)} + + + ) +} + +const StyledDiv = styled.div` + display: inline-block; + position: relative; + border: 1px solid ${COLOR.LIGHT_GRAY}; + border-radius: 4px; + min-width: 200px; +` + +const FlexDiv = styled.div` + display: flex; + justify-content: left; + padding: 5px 5px 5px 10px; +` + +const ButtonDiv = styled.div` + display: inline-block; + position: absolute; + right: 0; + top: 0; + background: white; + width: -webkit-fit-content; + height: -webkit-fit-content; + box-sizing: border-box; +` + +const DownloadDiv = styled.div` + position: absolute; + top: 0; + width: 100%; + height: 100%; + cursor: pointer; +` + +const ClickToDownloadSpan = styled.span` + position: absolute; + bottom: 0px; + font-size: 12px; + color: ${COLOR.GRAY}; +` + +const DescriptionDiv = styled.div` + padding: 10px 15px 10px 20px; +` + +export default FilePreview diff --git a/frontend/src/organism/FilePreview/FilePreview.stories.js b/frontend/src/organism/FilePreview/FilePreview.stories.js new file mode 100644 index 00000000..04d6acbd --- /dev/null +++ b/frontend/src/organism/FilePreview/FilePreview.stories.js @@ -0,0 +1,24 @@ +import React from 'react' +import FilePreview from './FilePreview' + +export default { + title: 'Organism/FilePreview', + component: FilePreview, +} +let isRender = true +const Template = args => <>{isRender && } + +export const inputFilePreview = Template.bind({}) +inputFilePreview.args = { + type: 'input', // input, message + fileId: '5fd6ea342d026a63752cd31b', + setIsRender: () => { + isRender = false + }, +} + +export const messageFilePreview = Template.bind({}) +messageFilePreview.args = { + type: 'message', // input, message + fileId: '5fd6ea342d026a63752cd31b', +} diff --git a/frontend/src/organism/FilePreview/index.js b/frontend/src/organism/FilePreview/index.js new file mode 100644 index 00000000..008b6f38 --- /dev/null +++ b/frontend/src/organism/FilePreview/index.js @@ -0,0 +1 @@ +export { default } from './FilePreview' diff --git a/frontend/src/organism/FileUploader/FileUploader.js b/frontend/src/organism/FileUploader/FileUploader.js new file mode 100644 index 00000000..1a330e28 --- /dev/null +++ b/frontend/src/organism/FileUploader/FileUploader.js @@ -0,0 +1,30 @@ +import React, { useState } from 'react' +import request from '../../util/request' + +const fileContentType = 'multipart/form-data' + +function FileUploader() { + const [selectedFile, setSelectedFile] = useState() + + const handleFileInput = e => { + setSelectedFile(e.target.files[0]) + } + const handlePost = async () => { + const formData = new FormData() + formData.append('file', selectedFile) + await request.POST('/api/file', formData, fileContentType) + } + + return ( + <> + handleFileInput(e)} + > + + + ) +} + +export default FileUploader diff --git a/frontend/src/organism/FileUploader/FileUploader.stories.js b/frontend/src/organism/FileUploader/FileUploader.stories.js new file mode 100644 index 00000000..b477fa6c --- /dev/null +++ b/frontend/src/organism/FileUploader/FileUploader.stories.js @@ -0,0 +1,10 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import FileUploader from './FileUploader' + +const stories = storiesOf('Organism', module) + +const TestComponent = () => { + return +} +stories.add('FileUploader', () => ) diff --git a/frontend/src/organism/FileUploader/index.js b/frontend/src/organism/FileUploader/index.js new file mode 100644 index 00000000..75f372b2 --- /dev/null +++ b/frontend/src/organism/FileUploader/index.js @@ -0,0 +1 @@ +export { default } from './FileUploader.js' diff --git a/frontend/src/organism/ImgPreview/ImgPreview.js b/frontend/src/organism/ImgPreview/ImgPreview.js new file mode 100644 index 00000000..b479de4d --- /dev/null +++ b/frontend/src/organism/ImgPreview/ImgPreview.js @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react' +import styled from 'styled-components' +import request from '../../util/request' +import Icon from '../../atom/Icon' +import { CLOSE } from '../../constant/icon' +import { COLOR } from '../../constant/style' +import Button from '../../atom/Button' + +function ImgPreview({ type, fileId, setIsRender }) { + const [fileData, setFileData] = useState({}) + const [isHover, setIsHover] = useState(false) + + useEffect(() => { + ;(async () => { + const { data } = (await request.GET('/api/file', { fileId })) || {} + setFileData(data?.data) + })() + }, [fileId]) + + const enterMouseHandle = () => { + setIsHover(true) + } + + const leaveMouseHandle = () => { + setIsHover(false) + } + + const handleDelete = async () => { + setIsRender(false) + await request.DELETE('/api/file', { fileId }) + } + + const deleteButton = () => { + return ( + + + + ) + } + + const downloadButton = () => { + return ( + { + if (fileData) window.open(fileData.url, '_blank') + }} + > + Click to Download + + ) + } + + return ( + + + {isHover && + (type === 'input' + ? deleteButton() + : type === 'message' + ? downloadButton() + : null)} + + ) +} + +const StyledDiv = styled.div` + display: inline-block; + position: relative; +` + +const StyledImg = styled.img` + max-width: ${({ type }) => { + return type === 'input' ? '50px' : '300px' + }}; + height: auto; + border-radius: 2%; + background: white; +` + +const ButtonDiv = styled.div` + display: inline-block; + position: absolute; + right: 0; + top: 0; + background: white; + width: -webkit-fit-content; + height: -webkit-fit-content; + box-sizing: border-box; +` + +const DownloadDiv = styled.div` + position: absolute; + top: 0; + width: 100%; + height: 100%; + cursor: pointer; +` + +const ClickToDownloadSpan = styled.span` + position: absolute; + bottom: 5px; + left: 10px; + color: ${COLOR.GRAY}; +` + +export default ImgPreview diff --git a/frontend/src/organism/ImgPreview/ImgPreview.stories.js b/frontend/src/organism/ImgPreview/ImgPreview.stories.js new file mode 100644 index 00000000..9e5ea405 --- /dev/null +++ b/frontend/src/organism/ImgPreview/ImgPreview.stories.js @@ -0,0 +1,24 @@ +import React from 'react' +import ImgPreview from './ImgPreview' + +export default { + title: 'Organism/ImgPreview', + component: ImgPreview, +} +let isRender = true +const Template = args => <>{isRender && } + +export const inputImgPreview = Template.bind({}) +inputImgPreview.args = { + type: 'input', // input, message + fileId: '5fd5dc7d8c8a82245fa0ab38', + setIsRender: () => { + isRender = false + }, +} + +export const messageImgPreview = Template.bind({}) +messageImgPreview.args = { + type: 'message', // input, message + fileId: '5fd5dc7d8c8a82245fa0ab38', +} diff --git a/frontend/src/organism/ImgPreview/index.js b/frontend/src/organism/ImgPreview/index.js new file mode 100644 index 00000000..11607d6e --- /dev/null +++ b/frontend/src/organism/ImgPreview/index.js @@ -0,0 +1 @@ +export { default } from './ImgPreview' diff --git a/frontend/src/organism/InviteUserToChannelModal/InviteUserToChannelModal.js b/frontend/src/organism/InviteUserToChannelModal/InviteUserToChannelModal.js index b34293da..7e91ddd2 100644 --- a/frontend/src/organism/InviteUserToChannelModal/InviteUserToChannelModal.js +++ b/frontend/src/organism/InviteUserToChannelModal/InviteUserToChannelModal.js @@ -1,7 +1,7 @@ import React, { useState, useRef } from 'react' import styled from 'styled-components' -import { useSetRecoilState } from 'recoil' -import { modalRecoil } from '../../store' +import { useSetRecoilState, useRecoilValue } from 'recoil' +import { modalRecoil, socketRecoil } from '../../store' import { useParams } from 'react-router-dom' import Button from '../../atom/Button' import Icon from '../../atom/Icon' @@ -16,7 +16,7 @@ import useChannelInfo from '../../hooks/useChannelInfo' function InviteUserToChannelModal({ handleClose }) { const [channelInfo, updateChannelInfo] = useChannelInfo() const setModal = useSetRecoilState(modalRecoil) - + const socket = useRecoilValue(socketRecoil) const [searchResult, setSearchResult] = useState(null) const [inviteUserList, setInviteUserList] = useState([]) const { workspaceId } = useParams() @@ -39,7 +39,11 @@ function InviteUserToChannelModal({ handleClose }) { }) if (data.success) { - updateChannelInfo(channelInfo.channelId._id) + socket.emit('invite channel', { + channelId: channelInfo.channelId._id, + origin: channelInfo.member.map(user => user._id), + newMember: inviteUserList.map(user => user._id), + }) setModal(null) } } diff --git a/frontend/src/organism/ThreadReactionList/ThreadReactionList.js b/frontend/src/organism/ThreadReactionList/ThreadReactionList.js index 03aa6bf5..d1c3bc85 100644 --- a/frontend/src/organism/ThreadReactionList/ThreadReactionList.js +++ b/frontend/src/organism/ThreadReactionList/ThreadReactionList.js @@ -3,15 +3,25 @@ import styled, { css } from 'styled-components' import ThreadReactionCard from '../../atom/ThreadReactionCard' import AddReactionButton from '../../atom/AddReactionButton' -function ThreadReactionList({ reactions }) { +function ThreadReactionList({ reactions, chatId, updateReactionHandler }) { const renderReactionCard = reactions.map((reaction, idx) => { - return + return ( + + ) }) return ( {renderReactionCard} - + ) } diff --git a/frontend/src/page/WorkspacePage/WorkspacePage.js b/frontend/src/page/WorkspacePage/WorkspacePage.js index 9c74928f..a77f8e0d 100644 --- a/frontend/src/page/WorkspacePage/WorkspacePage.js +++ b/frontend/src/page/WorkspacePage/WorkspacePage.js @@ -12,12 +12,14 @@ import { COLOR } from '../../constant/style' import Icon from '../../atom/Icon' import { TOOLS } from '../../constant/icon' import useWorkspace from '../../hooks/useWorkspace' +import useSocket from '../../hooks/useSocket' function WorkspacePage() { const { channelId } = useParams() const [lineWidth, setLineWidth] = useState(20) - const Modal = useRecoilValue(modalRecoil) + const modal = useRecoilValue(modalRecoil) useWorkspace() + useSocket() const moveLine = e => { if (e.pageX === 0) return false let mouse = e.pageX @@ -50,7 +52,7 @@ function WorkspacePage() { return ( - {Modal} + {modal} 글로벌 헤더 위치 diff --git a/frontend/src/store.js b/frontend/src/store.js index 69ec4b25..4d2c3aca 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -11,11 +11,17 @@ export const currentChannelInfoRecoil = atom({ }) export const channelsRecoil = atom({ - key: 'Channels', - default: {}, + key: 'channels', + default: [], }) export const modalRecoil = atom({ - key: 'Modal', + key: 'modal', default: null, }) + +export const socketRecoil = atom({ + key: 'socket', + default: null, + dangerouslyAllowMutability: true, +})