diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..51d2fe04 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: deploy + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the main branch +on: + push: + branches: master + +jobs: + build: + name: Build and Deploy + runs-on: ubuntu-latest + steps: + - name: Deploy backend server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.BACKEND_HOST }} + username: ${{ secrets.BACKEND_USERNAME }} + password: ${{ secrets.BACKEND_PASSWORD }} + script: | + bash deploy-backend.sh + + - name: Deploy frontend server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.FRONTEND_HOST }} + username: ${{ secrets.FRONTEND_USERNAME }} + password: ${{ secrets.FRONTEND_PASSWORD }} + script: | + bash deploy-frontend.sh diff --git a/backend/chatServer.js b/backend/chatServer.js index 8b7f91e6..0f59b4bd 100644 --- a/backend/chatServer.js +++ b/backend/chatServer.js @@ -2,7 +2,7 @@ import { config as dotenv } from 'dotenv' import express from 'express' import { createServer } from 'http' import createChatServer from 'socket.io' -import { createChatMessage } from './service/chat' +import { createChatMessage, createReplyMessage } from './service/chat' import { addReaction, removeReaction } from './service/reaction' dotenv() @@ -28,18 +28,43 @@ namespace.on('connection', socket => { ) }) socket.on('new message', async data => { - const { contents, channelId } = data + const { contents, channelId, file } = data const { data: result } = await createChatMessage({ creator: workspaceUserInfoId, channelId, contents, + file, }) namespace.in(channelId).emit('new message', { - message: { ...data, _id: result._id, createdAt: result.createdAt }, + message: { + ...data, + _id: result._id, + createdAt: result.createdAt, + reactions: [], + }, + }) + }) + socket.on('new reply', async data => { + const { contents, channelId, parentId, file } = data + const { data: result } = await createReplyMessage({ + creator: workspaceUserInfoId, + channelId, + contents, + parentId, + file, + }) + namespace.in(channelId).emit('new reply', { + message: { + ...data, + _id: result._id, + createdAt: result.createdAt, + chatId: parentId, + reactions: [], + }, }) }) socket.on('update reaction', async data => { - const { emoji, chatId, userInfo, channelId, type } = data + const { emoji, chatId, userInfo, channelId, type, parentId } = data //1 = add, 0 = remove const result = type === 1 @@ -61,6 +86,7 @@ namespace.on('connection', socket => { workspaceUserInfoId: userInfo._id, displayName: userInfo.displayName, type: result ? type : false, + parentId: parentId, }, }) }) diff --git a/backend/controller/channel/channel.js b/backend/controller/channel/channel.js index c7a1ba73..d5b82f86 100644 --- a/backend/controller/channel/channel.js +++ b/backend/controller/channel/channel.js @@ -9,6 +9,15 @@ const getChannelList = asyncWrapper(async (req, res) => { return res.status(code).json({ success, result }) }) +const getChannelBrowserData = asyncWrapper(async (req, res) => { + const { workspaceUserInfoId, workspaceId } = req.query + const { code, success, result } = await service.getChannelBrowserData({ + workspaceUserInfoId, + workspaceId, + }) + return res.status(code).json({ success, result }) +}) + const getChannelHeaderInfo = asyncWrapper(async (req, res) => { const channelId = req.params.channelId const workspaceUserInfoId = req.query.workspaceUserInfoId @@ -57,6 +66,20 @@ const createChannel = asyncWrapper(async (req, res) => { return res.status(code).json({ success, data }) }) +const leaveChannel = asyncWrapper(async (req, res) => { + const { code, success } = await service.leaveChannel({ + ...req.body, + }) + return res.status(code).json({ success }) +}) + +const joinChannel = asyncWrapper(async (req, res) => { + const { code, success } = await service.joinChannel({ + ...req.body, + }) + return res.status(code).json({ success }) +}) + const checkDuplicate = asyncWrapper(async (req, res) => { const { code, success, data } = await service.checkDuplicate({ title: req.query.title, @@ -65,12 +88,23 @@ const checkDuplicate = asyncWrapper(async (req, res) => { return res.status(code).json({ success, data }) }) +const findChannelIdByName = asyncWrapper(async (req, res) => { + const { code, success, data } = await service.findChannelIdByName({ + ...req.query, + }) + return res.status(code).json({ success, data }) +}) + module.exports = { getChannelList, + getChannelBrowserData, getChannelHeaderInfo, inviteUser, muteChannel, updateChannelSection, createChannel, checkDuplicate, + leaveChannel, + joinChannel, + findChannelIdByName, } diff --git a/backend/controller/channel/index.js b/backend/controller/channel/index.js index 59ada502..97553760 100644 --- a/backend/controller/channel/index.js +++ b/backend/controller/channel/index.js @@ -6,13 +6,22 @@ const { Auth } = require('../../middleware/auth') /* GET /api/channle get channel list */ router.get('/', controller.getChannelList) +/* get /api/channle/browser update channel section */ +router.get('/browser', controller.getChannelBrowserData) + router.get('/check-duplicate-name', controller.checkDuplicate) router.post('/', Auth, controller.createChannel) +router.post('/leave', Auth, controller.leaveChannel) + +router.post('/join', Auth, controller.joinChannel) + /* GET /api/channle/{channelId}/info get channel header info */ router.get('/:channelId/info', controller.getChannelHeaderInfo) +router.get('/info', controller.findChannelIdByName) + /* POST /api/channle/invite invite user to channel */ router.post('/invite', controller.inviteUser) diff --git a/backend/controller/file/file.js b/backend/controller/file/file.js index 95ca2092..5e75dfbb 100644 --- a/backend/controller/file/file.js +++ b/backend/controller/file/file.js @@ -1,13 +1,6 @@ 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, diff --git a/backend/controller/file/index.js b/backend/controller/file/index.js index e4ab4763..35d07ddc 100644 --- a/backend/controller/file/index.js +++ b/backend/controller/file/index.js @@ -7,7 +7,6 @@ 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) diff --git a/backend/controller/workspace/index.js b/backend/controller/workspace/index.js index 19624d9c..07c0f2fc 100644 --- a/backend/controller/workspace/index.js +++ b/backend/controller/workspace/index.js @@ -1,13 +1,14 @@ import express from 'express' -const { Auth } = require('../../middleware/auth') +const { Auth, InviteAuth } = require('../../middleware/auth') const router = express.Router() const controller = require('./workspace') router.get('/', Auth, controller.getWorkspaces) router.post('/', Auth, controller.createWorkspace) router.post('/invite', Auth, controller.invite) -router.get('/invite/:code', Auth, controller.invited) +router.get('/invite/:code', InviteAuth, controller.invited) router.get('/check-duplicate-name', Auth, controller.checkDuplicateName) router.get('/info/:workspaceId', Auth, controller.getWorkspaceUserInfo) +router.get('/info', Auth, controller.getWorkspaceUserInfoByInfoId) module.exports = router diff --git a/backend/controller/workspace/workspace.js b/backend/controller/workspace/workspace.js index cb0fd5c8..3a947f52 100644 --- a/backend/controller/workspace/workspace.js +++ b/backend/controller/workspace/workspace.js @@ -25,15 +25,15 @@ exports.invite = (req, res) => { } exports.invited = asyncWrapper(async (req, res) => { - const { code, success, data } = await service.invited({ + const { success, data } = await service.invited({ ...req.params, userId: req.user.id, }) - const workspaceRedirectURL = `${process.env.FRONTEND_HOST}/workspace/${data.workspaceId}/${data.default_channel}` if (success) { - return res.status(code).redirect(workspaceRedirectURL) + const workspaceRedirectURL = `${process.env.FRONTEND_HOST}/workspace/${data.workspaceId}/${data.default_channel}` + return res.redirect(workspaceRedirectURL) } - return res.status(code).redirect(process.env.FRONTEND_HOST) + return res.redirect(process.env.FRONTEND_HOST) }) exports.checkDuplicateName = asyncWrapper(async (req, res) => { @@ -50,3 +50,10 @@ exports.getWorkspaceUserInfo = asyncWrapper(async (req, res) => { }) return res.status(code).json({ success, data }) }) + +exports.getWorkspaceUserInfoByInfoId = asyncWrapper(async (req, res) => { + const { code, success, data } = await service.getWorkspaceUserInfoByInfoId({ + ...req.query, + }) + return res.status(code).json({ success, data }) +}) diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 320e7e60..f61e9895 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -607,7 +607,7 @@ paths: get: tags: - channel - summary: get channel list //get userId from JWT + summary: get channel list description: get channel list operationId: '' parameters: @@ -632,27 +632,68 @@ paths: items: type: object properties: + _id: + type: object channelId: - type: string - title: - type: string - channelType: - type: number - section: type: object properties: - sectionId: + _id: + type: object + title: + type: string + description: + type: string + topic: type: string - name: + channelType: + type: integer + creator: + type: object + isDeleted: + type: boolean + createdAt: + format: date-time + type: string + updatedAt: + format: date-time type: string + member: + type: array + items: + type: object + properties: + workspaceUserInfoId: + type: object + displayName: + type: string + profileUrl: + type: string + isActive: + type: boolean + sectionName: + type: string readChatId: type: string isMute: type: boolean notification: type: number - + createdAt: + format: date-time + type: string + updatedAt: + format: date-time + type: string description: success response + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: bad request /api/user/sign-in/github: summary: workspace user login @@ -768,30 +809,76 @@ paths: result: type: object properties: - channelId: - type: string - title: - type: string - topic: + _id: type: string - channelType: - type: number - pinnedNum: - type: number - memberNum: - type: number + channelId: + type: object + properties: + _id: + type: string + title: + type: string + description: + type: string + topic: + type: string + channelType: + type: integer + creator: + type: string + isDeleted: + type: boolean + createdAt: + format: date-time + type: string + updatedAt: + format: date-time + type: string member: type: array items: type: object properties: - userId: + _id: + type: string + fullName: + type: string + displayName: type: string profileUrl: type: string - workspaceURL: + isActive: + type: boolean + userId: type: string + sectionName: + type: string + readChatId: + type: string + isMute: + type: boolean + notification: + type: number + createdAt: + format: date-time + type: string + updatedAt: + format: date-time + type: string + workspaceUserInfoId: + type: object + pinnedCount: + type: number description: get channel info for channel header + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: bad request /api/channel/invite: summary: invite user to channel @@ -873,13 +960,13 @@ paths: description: update channel mute option /api/search/userId: - summary: invite user to channel - description: invite user to channel + summary: search user for inviting + description: search user for inviting post: tags: - search - summary: invite user to channel - description: invite user to channel + summary: search user for inviting + description: search user for inviting operationId: '' parameters: - name: keyword @@ -890,6 +977,22 @@ paths: explode: true schema: type: string + - name: workspaceId + in: query + description: workspaceId + required: true + style: form + explode: true + schema: + type: string + - name: channelId + in: query + description: channelId + required: true + style: form + explode: true + schema: + type: string responses: '200': content: @@ -904,7 +1007,9 @@ paths: items: type: object properties: - workspaceUserInfoId: + _id: + type: string + fullName: type: string displayName: type: string @@ -912,7 +1017,16 @@ paths: type: string isActive: type: boolean - description: invite user to channel + description: search user for inviting + '400': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: not modified /api/direct-message: summary: get users last dm @@ -1026,6 +1140,7 @@ paths: type: number description: get user mention and reaction + '/api/chat/{workspaceId}/{channelId}': summary: get chat messages description: get chat messages @@ -1155,17 +1270,26 @@ paths: default: description: Default error sample response - '/api/file/{fileId}': - summary: get chat messages - description: get chat messages + /api/channel/browser: + summary: get channel list in workspace + description: get channel list in workspace get: tags: - - file + - channel + summary: get channel list in workspace + description: get channel list in workspace operationId: '' parameters: - - name: fileId - in: path - description: fileId + - name: workspaceId + in: query + description: workspaceId + required: true + explode: true + schema: + type: string + - name: workspaceUserInfoId + in: query + description: workspaceUserInfoId required: true explode: true schema: @@ -1178,15 +1302,53 @@ paths: type: object properties: success: - type: string + type: boolean data: - type: object - properties: - url: - type: string - originalName: - type: string + type: array + items: + type: object + properties: + _id: + type: string + title: + type: string + channelType: + type: integer + joined: + type: boolean + description: success response + '/api/channel/leave': + summary: leave channel + description: leave channel + post: + tags: + - channel + operationId: '' + parameters: + - name: workspaceId + in: query + description: workspaceId + required: true + explode: true + schema: + type: string + - name: workspaceUserInfoId + in: query + description: workspaceUserInfoId + required: true + explode: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean description: success response '400': content: @@ -1208,14 +1370,25 @@ paths: description: unauthorized default: description: Default error sample response - delete: + + '/api/channel/join': + summary: join channel + description: join channel + post: tags: - - file + - channel operationId: '' parameters: - - name: fileId - in: path - description: fileId + - name: workspaceId + in: query + description: workspaceId + required: true + explode: true + schema: + type: string + - name: workspaceUserInfoId + in: query + description: workspaceUserInfoId required: true explode: true schema: @@ -1229,7 +1402,90 @@ paths: 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/chat/{workspaceId}/{channelId}/reply': + summary: get chat messages + description: get chat messages + get: + tags: + - chat + operationId: '' + parameters: + - name: workspaceId + in: path + description: workspaceId + required: true + explode: true + schema: + type: string + - name: channelId + in: path + description: channelId + required: true + explode: true + schema: + type: string + - name: parentId + in: query + description: parent chat Id + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: array + items: + type: object + properties: + _id: + type: string + isDelete: + type: boolean + contents: + type: string + createdAt: + format: date-time + type: string + updatedAt: + format: date-time + type: string + userInfo: + type: object + properties: + _id: + type: string + displayName: + type: string + profileUrl: + type: string description: success response '400': content: @@ -1251,6 +1507,7 @@ paths: description: unauthorized default: description: Default error sample response + '/api/file': summary: file upload description: file upload @@ -1261,7 +1518,7 @@ paths: parameters: - name: file in: query - description: file + description: input form으로 생성된 file object required: true explode: true schema: @@ -1278,15 +1535,15 @@ paths: data: type: object properties: - fileId: + name: type: string - fileName: + originalName: type: string fileType: type: string creator: type: string - etag: + url: type: string description: success response '400': @@ -1309,6 +1566,49 @@ paths: description: unauthorized default: description: Default error sample response + delete: + tags: + - file + operationId: '' + parameters: + - name: name + in: query + description: file name + 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 components: schemas: diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index fd8b2dcb..83bed42b 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -14,3 +14,15 @@ exports.Auth = (req, res, next) => { next() })(req, res, next) } + +exports.InviteAuth = (req, res, next) => { + passport.authenticate('jwt', { session: false }, (err, user) => { + if (err || !user || !user.success) { + const redirectURL = `${process.env.FRONTEND_HOST}/login?invitecode=${req.params.code}` + res.status(statusCode.UNAUTHORIZED).redirect(redirectURL) + return + } + req.user = user + next() + })(req, res, next) +} diff --git a/backend/model/Channel.js b/backend/model/Channel.js index 975d447f..e53066a1 100644 --- a/backend/model/Channel.js +++ b/backend/model/Channel.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose') const Schema = mongoose.Schema +const ObjectId = mongoose.Types.ObjectId const channelSchema = mongoose.Schema( { @@ -18,6 +19,10 @@ const channelSchema = mongoose.Schema( ref: 'WorkspaceUserInfo', required: true, }, + workspaceId: { + type: Schema.Types.ObjectId, + ref: 'workspace', + }, channelType: { type: Number, required: true, @@ -29,6 +34,62 @@ const channelSchema = mongoose.Schema( { timestamps: true }, ) +channelSchema.statics.getChannelBrowserData = async function ( + workspaceId, + workspaceUserInfoId, +) { + try { + const channel = this + + const result = await channel.aggregate([ + { + $match: { + workspaceId: ObjectId(workspaceId), + channelType: { $lt: 2 }, + }, + }, + { + $lookup: { + from: 'channelconfigs', + let: { + channelId: '$_id', + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$channelId', '$$channelId'] }, + { + $eq: [ + '$workspaceUserInfoId', + ObjectId(workspaceUserInfoId), + ], + }, + ], + }, + }, + }, + ], + as: 'join', + }, + }, + { + $project: { + title: 1, + channelType: 1, + joined: { + $cond: [{ $gte: [{ $size: '$join' }, 1] }, true, false], + }, + }, + }, + ]) + + return result + } catch (err) { + return err + } +} const Channel = mongoose.model('Channel', channelSchema) module.exports = { Channel } diff --git a/backend/model/Chat.js b/backend/model/Chat.js index c138b0cb..f4fd83d7 100644 --- a/backend/model/Chat.js +++ b/backend/model/Chat.js @@ -27,6 +27,9 @@ const chatSchema = mongoose.Schema( type: Boolean, default: false, }, + file: { + type: Object, + }, }, { timestamps: true }, ) @@ -91,7 +94,7 @@ chatSchema.statics.getChatMessages = ({ channelId, currentCursor, fromDate }) => { $match: { $expr: { $eq: ['$_id', '$$workspaceUserInfoId'] } }, }, - { $project: { profileUrl: 1, displayName: 1, _id: 1 } }, + { $project: { displayName: 1, _id: 1 } }, ], as: 'workspaceUserInfoId', }, @@ -118,12 +121,123 @@ chatSchema.statics.getChatMessages = ({ channelId, currentCursor, fromDate }) => { $limit: MAX_CHAT_MESSAGE }, ]) -chatSchema.statics.getReplyMessages = ({ channelId, parentId }) => - Chat.aggregate([ +chatSchema.statics.getReplyMessages = ({ channelId, parentId }) => { + return Chat.aggregate([ { $match: { - channel: ObjectId(channelId), - parentId: ObjectId(parentId), + _id: ObjectId(parentId), + }, + }, + { + $lookup: { + from: 'chats', + let: { chatId: '$_id' }, + pipeline: [ + { + $match: { + channel: ObjectId(channelId), + parentId: ObjectId(parentId), + }, + }, + { + $lookup: { + from: 'workspaceuserinfos', + let: { creator: '$creator' }, + pipeline: [ + { $match: { $expr: { $eq: ['$_id', '$$creator'] } } }, + { $project: { profileUrl: 1, displayName: 1, _id: 1 } }, + ], + as: 'userInfo', + }, + }, + { $unwind: '$userInfo' }, + { + $lookup: { + from: 'reactions', + let: { chatId: '$_id' }, + pipeline: [ + { $match: { $expr: { $eq: ['$chatId', '$$chatId'] } } }, + { + $lookup: { + from: 'workspaceuserinfos', + let: { workspaceUserInfoId: '$workspaceUserInfoId' }, + pipeline: [ + { + $match: { + $expr: { + $eq: ['$_id', '$$workspaceUserInfoId'], + }, + }, + }, + { + $project: { + profileUrl: 1, + displayName: 1, + _id: 1, + }, + }, + ], + as: 'workspaceUserInfoId', + }, + }, + { $unwind: '$workspaceUserInfoId' }, + { + $group: { + _id: { emotion: '$emoticon' }, + users: { $push: '$workspaceUserInfoId' }, + }, + }, + { + $project: { + _id: 0, + emoji: '$_id.emotion', + users: 1, + }, + }, + ], + as: 'reactions', + }, + }, + { $sort: { createdAt: 1 } }, + ], + as: 'reply', + }, + }, + { + $lookup: { + from: 'reactions', + let: { chatId: '$_id' }, + pipeline: [ + { $match: { $expr: { $eq: ['$chatId', '$$chatId'] } } }, + { + $lookup: { + from: 'workspaceuserinfos', + let: { workspaceUserInfoId: '$workspaceUserInfoId' }, + pipeline: [ + { + $match: { $expr: { $eq: ['$_id', '$$workspaceUserInfoId'] } }, + }, + { $project: { displayName: 1, _id: 1 } }, + ], + as: 'workspaceUserInfoId', + }, + }, + { $unwind: '$workspaceUserInfoId' }, + { + $group: { + _id: { emotion: '$emoticon' }, + users: { $push: '$workspaceUserInfoId' }, + }, + }, + { + $project: { + _id: 0, + emoji: '$_id.emotion', + users: 1, + }, + }, + ], + as: 'reactions', }, }, { @@ -138,16 +252,8 @@ chatSchema.statics.getReplyMessages = ({ channelId, parentId }) => }, }, { $unwind: '$userInfo' }, - { - $project: { - isDelete: 1, - contents: 1, - createdAt: 1, - updatedAt: 1, - userInfo: 1, - }, - }, ]) +} const Chat = mongoose.model('Chat', chatSchema) module.exports = { Chat } diff --git a/backend/model/File.js b/backend/model/File.js deleted file mode 100644 index 0e0cbe52..00000000 --- a/backend/model/File.js +++ /dev/null @@ -1,26 +0,0 @@ -const mongoose = require('mongoose') -const Schema = mongoose.Schema - -const fileSchema = mongoose.Schema( - { - path: { - type: String, - }, - fileType: { - type: String, - }, - name: { - type: String, - }, - originalName: { - type: String, - }, - creator: { - type: Schema.Types.ObjectId, - ref: 'User', - }, - }, - { timestamps: true }, -) -const File = mongoose.model('File', fileSchema) -module.exports = { File } diff --git a/backend/service/channel.js b/backend/service/channel.js index a2894da0..ea267cc2 100644 --- a/backend/service/channel.js +++ b/backend/service/channel.js @@ -55,6 +55,18 @@ const getChannelListDB = async ({ workspaceUserInfoId }) => { } } +const getChannelBrowserData = async ({ workspaceId, workspaceUserInfoId }) => { + verifyRequiredParams(workspaceId, workspaceUserInfoId) + const channelList = await dbErrorHandler(() => + Channel.getChannelBrowserData(workspaceId, workspaceUserInfoId), + ) + return { + code: statusCode.OK, + result: channelList, + success: true, + } +} + const getChannelHeaderInfoDB = async ({ channelId, workspaceUserInfoId }) => { verifyRequiredParams(channelId, workspaceUserInfoId) const [result] = await dbErrorHandler(() => @@ -129,6 +141,49 @@ const updateChannelSectionDB = async ({ } } +const leaveChannel = async ({ channelId, workspaceUserInfoId }) => { + verifyRequiredParams(channelId, workspaceUserInfoId) + + await dbErrorHandler(() => + ChannelConfig.findOneAndDelete({ + workspaceUserInfoId, + channelId, + }), + ) + + return { + code: statusCode.OK, + success: true, + } +} + +const joinChannel = async ({ channelId, workspaceUserInfoId }) => { + verifyRequiredParams(channelId, workspaceUserInfoId) + await dbErrorHandler(() => + ChannelConfig.create({ + workspaceUserInfoId, + channelId, + isMute: false, + notification: 0, + }), + ) + + return { + code: statusCode.OK, + success: true, + } +} +const findChannelIdByName = async ({ title }) => { + verifyRequiredParams(title) + const channelData = await dbErrorHandler(() => Channel.findOne({ title })) + + return { + code: statusCode.OK, + data: channelData?._id, + success: true, + } +} + module.exports = { createChannel, checkDuplicate, @@ -137,4 +192,8 @@ module.exports = { inviteUserDB, muteChannelDB, updateChannelSectionDB, + getChannelBrowserData, + leaveChannel, + joinChannel, + findChannelIdByName, } diff --git a/backend/service/chat.js b/backend/service/chat.js index 67e8791f..161acbaa 100644 --- a/backend/service/chat.js +++ b/backend/service/chat.js @@ -13,10 +13,34 @@ const getChatMessages = async ({ channelId, currentCursor, fromDate }) => { success: true, } } -const createChatMessage = async ({ channelId, creator, contents }) => { - verifyRequiredParams(channelId, creator, contents) +const createChatMessage = async ({ channelId, creator, contents, file }) => { + verifyRequiredParams(channelId, creator, contents || file) const result = await dbErrorHandler(() => - Chat.create({ channel: channelId, creator, contents }), + Chat.create({ + channel: channelId, + creator, + contents, + file: file === null ? undefined : file, + }), + ) + return { data: result } +} +const createReplyMessage = async ({ + channelId, + creator, + contents, + parentId, + file, +}) => { + verifyRequiredParams(channelId, creator, contents, parentId) + const result = await dbErrorHandler(() => + Chat.create({ + channel: channelId, + parentId: parentId, + creator, + contents, + file: file === null ? undefined : file, + }), ) return { data: result } } @@ -29,4 +53,9 @@ const getReplyMessage = async ({ channelId, parentId }) => { return { code: statusCode.OK, data: result, success: true } } -module.exports = { getChatMessages, createChatMessage, getReplyMessage } +module.exports = { + getChatMessages, + createChatMessage, + getReplyMessage, + createReplyMessage, +} diff --git a/backend/service/file.js b/backend/service/file.js index a43f04c6..94a05bba 100644 --- a/backend/service/file.js +++ b/backend/service/file.js @@ -1,66 +1,34 @@ -import { verifyRequiredParams, dbErrorHandler } from '../util/' +import { verifyRequiredParams } 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({ + await S3.putObject({ Bucket: BUCKETNAME, Key: fileName, ACL: 'public-read', Body: file.buffer, }).promise() + const url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKETNAME}/${fileName}` - const data = await dbErrorHandler(() => - File.create({ + return { + code: statusCode.OK, + data: { 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, + url: url, }, success: true, } } -const deleteFile = async ({ fileId }) => { - verifyRequiredParams(fileId) - const { name } = await dbErrorHandler(() => - File.findOneAndDelete({ _id: ObjectId(fileId) }), - ) +const deleteFile = async ({ name }) => { + verifyRequiredParams(name) await S3.deleteObject({ Bucket: BUCKETNAME, Key: name, @@ -70,6 +38,5 @@ const deleteFile = async ({ fileId }) => { module.exports = { uploadFile, - getFileURL, deleteFile, } diff --git a/backend/service/workspace.js b/backend/service/workspace.js index 1d7f491f..e86e0fd6 100644 --- a/backend/service/workspace.js +++ b/backend/service/workspace.js @@ -33,12 +33,14 @@ const createWorkspace = async params => { fullName: findedUser.fullName, displayName: findedUser.fullName, profileUrl: findedUser.profileUrl, + isActive: false, }), ) const channelData = await dbErrorHandler(() => Channel.create({ creator: workspaceUserInfoData._id, title: params.channelName, + workspaceId: workspaceData._id, channelType: 1, }), ) @@ -118,6 +120,7 @@ const invited = async ({ userId, code }) => { fullName: findedUser?.fullName, displayName: findedUser?.fullName, profileUrl: findedUser?.profileUrl, + isActive: false, }), ) const workspaceData = await dbErrorHandler(() => @@ -175,6 +178,17 @@ const getWorkspaceUserInfo = async ({ userId, workspaceId }) => { } } +const getWorkspaceUserInfoByInfoId = async ({ workspaceUserInfoId }) => { + const workspaceUserInfoData = await dbErrorHandler(() => + WorkspaceUserInfo.getWorkspaceUserInfo(workspaceUserInfoId), + ) + return { + code: statusCode.OK, + data: workspaceUserInfoData[0], + success: true, + } +} + module.exports = { createWorkspace, getWorkspaces, @@ -182,4 +196,5 @@ module.exports = { invited, checkDuplicateName, getWorkspaceUserInfo, + getWorkspaceUserInfoByInfoId, } diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 53f3601e..32ea0760 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -1,5 +1,5 @@ import { addDecorator } from '@storybook/react' -import GlobalStyle from '../src/atom/GlobalStyle/GlobalStyle' +import GlobalStyle from '../src/presenter/GlobalStyle/GlobalStyle' import { RecoilRoot } from 'recoil' import { MemoryRouter } from 'react-router-dom' export const parameters = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb053d74..684f2990 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7775,6 +7775,14 @@ "warning": "^4.0.3" } }, + "cross-fetch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz", + "integrity": "sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==", + "requires": { + "node-fetch": "2.6.1" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -8178,6 +8186,11 @@ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, + "decorate-component-with-props": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decorate-component-with-props/-/decorate-component-with-props-1.1.0.tgz", + "integrity": "sha512-tTYQojixN64yK3/WBODMfvss/zbmyUx9HQXhzSxZiSiofeekVeRyyuToy9BCiTMrVEIKWxTcla2t3y5qdaUF7Q==" + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -8613,6 +8626,244 @@ } } }, + "draft": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/draft/-/draft-0.2.3.tgz", + "integrity": "sha1-9ELdhr1R2B87/Vtf3OD9mcxpzJ4=" + }, + "draft-js": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.11.7.tgz", + "integrity": "sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg==", + "requires": { + "fbjs": "^2.0.0", + "immutable": "~3.7.4", + "object-assign": "^4.1.1" + } + }, + "draft-js-checkable-list-item": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/draft-js-checkable-list-item/-/draft-js-checkable-list-item-2.0.6.tgz", + "integrity": "sha512-YHnGr3rKSFfqXGcHqp8SGees5Y/KAsHoyknoDRM1Gyal3B+duiFjmYvxIZlSYnWIcHgH4pQpPDQJJ9aaPmsvNw==", + "requires": { + "draft-js-modifiers": "^0.1.5" + } + }, + "draft-js-markdown-plugin": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/draft-js-markdown-plugin/-/draft-js-markdown-plugin-3.0.5.tgz", + "integrity": "sha512-DNqKUJw2eHFGQL/GOKTrDqqe4pwOoH6wzJHkJ7APNQCoCHTrG4pD5ueiO9AuNMbaQTaeCtsj9QwUMs1YBwAQCg==", + "requires": { + "decorate-component-with-props": "^1.1.0", + "draft-js": "^0.10.4", + "draft-js-checkable-list-item": "^2.0.6", + "draft-js-prism-plugin": "^0.1.3", + "immutable": "~3.7.4", + "react-click-outside": "^3.0.1" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "draft-js": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.10.5.tgz", + "integrity": "sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg==", + "requires": { + "fbjs": "^0.8.15", + "immutable": "~3.7.4", + "object-assign": "^4.1.0" + } + }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + } + } + }, + "draft-js-markdown-shortcuts-plugin": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/draft-js-markdown-shortcuts-plugin/-/draft-js-markdown-shortcuts-plugin-0.6.1.tgz", + "integrity": "sha512-VZbq6WATsNNHH8wqUbYuY0ijHcb1o2V9cN2eeMEAHgr92RJmGGi2UfgxI9O7ycWhTwgS9KZ17AE76C1R/rYR6Q==", + "requires": { + "decorate-component-with-props": "^1.1.0", + "draft-js": "~0.11.5", + "draft-js-checkable-list-item": "^3.0.4", + "draft-js-prism-plugin": "^0.1.3", + "immutable": "~3.8.2" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "draft-js-checkable-list-item": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/draft-js-checkable-list-item/-/draft-js-checkable-list-item-3.0.4.tgz", + "integrity": "sha512-rHTErKYTYoBRtSCxKfSz+PivbtXp0KqmjC2Ms3u9QHlx9c40qsbajdSz+1TfPXaWP8FAB5EeynZ2qYPRgltPeQ==", + "requires": { + "draft-js-modifiers": "~0.2.2", + "immutable": "~3.7.4" + }, + "dependencies": { + "immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=" + } + } + }, + "draft-js-modifiers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/draft-js-modifiers/-/draft-js-modifiers-0.2.2.tgz", + "integrity": "sha512-64zeY74S4um+NO5HI66scZAX6fufdpZlr6htI3ttvNkTw5Jx/3r7+ETKJ/PTnGWtzUTC7TVkeF6mP7oqtUBUQw==", + "requires": { + "draft-js": "~0.10.5", + "immutable": "~3.7.4" + }, + "dependencies": { + "draft-js": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.10.5.tgz", + "integrity": "sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg==", + "requires": { + "fbjs": "^0.8.15", + "immutable": "~3.7.4", + "object-assign": "^4.1.0" + } + }, + "immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=" + } + } + }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=" + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + } + } + }, + "draft-js-modifiers": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/draft-js-modifiers/-/draft-js-modifiers-0.1.5.tgz", + "integrity": "sha512-UVbTvlbFSOlJ4LHNK68yflR1k6UyBge0o89DM0/YA8w5PXI+bExMTkByRcwhV5XIWYDgboHWLQSXjao02dW4mQ==", + "requires": { + "draft-js": "~0.10.0", + "immutable": "~3.7.4" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "draft-js": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.10.5.tgz", + "integrity": "sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg==", + "requires": { + "fbjs": "^0.8.15", + "immutable": "~3.7.4", + "object-assign": "^4.1.0" + } + }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + } + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + } + } + }, + "draft-js-plugins-editor": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/draft-js-plugins-editor/-/draft-js-plugins-editor-3.0.0.tgz", + "integrity": "sha512-bFEL0FUIPg9VK3KSeBZ3D+uMqQEVe4Cv7++LWCMASRH02jy6x2f87NRxSZLzTQES5+oL6Qg+OEUlaTn409145A==", + "requires": { + "immutable": "~3.7.4", + "prop-types": "^15.5.8" + } + }, + "draft-js-prism": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/draft-js-prism/-/draft-js-prism-1.0.6.tgz", + "integrity": "sha512-9iNPPr6/vaC9K60DtVes1JGDZ9uD0vZ7/8i6de5cZEzbftOj/ijIGplEV0dTFT/q8U+bY1uR1ikQevjRh2pEpQ==", + "requires": { + "extend": "^3.0.0", + "immutable": "*" + } + }, + "draft-js-prism-plugin": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/draft-js-prism-plugin/-/draft-js-prism-plugin-0.1.3.tgz", + "integrity": "sha512-w/agtisitO7SCHBBqa5vfkwy7DlhfKMCak6ivE4GpSV97MYLUIuRtafP1ei5tCMwGkJxJDKMv3Bgrd5miG55ZA==", + "requires": { + "draft-js-prism": "^1.0.6", + "react": "*" + } + }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -8752,6 +9003,24 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -9991,6 +10260,36 @@ "bser": "2.1.1" } }, + "fbjs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-2.0.0.tgz", + "integrity": "sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ==", + "requires": { + "core-js": "^3.6.4", + "cross-fetch": "^3.0.4", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + }, + "dependencies": { + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + } + } + }, + "fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, "figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -11530,9 +11829,14 @@ "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" }, "immer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz", - "integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.0.tgz", + "integrity": "sha512-jm87NNBAIG4fHwouilCHIecFXp5rMGkiFrAuhVO685UnMAlOneEAnOyzPt8OnP47TC11q/E7vpzZe0WvwepFTg==" + }, + "immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=" }, "import-cwd": { "version": "2.1.0", @@ -11620,9 +11924,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "inline-style-parser": { "version": "0.1.1", @@ -12134,6 +12438,26 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + }, + "dependencies": { + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + } + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -14183,8 +14507,7 @@ "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", - "dev": true + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, "node-forge": { "version": "0.10.0", @@ -16502,6 +16825,21 @@ "whatwg-fetch": "^3.4.1" } }, + "react-click-outside": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-click-outside/-/react-click-outside-3.0.1.tgz", + "integrity": "sha512-d0KWFvBt+esoZUF15rL2UBB7jkeAqLU8L/Ny35oLK6fW6mIbOv/ChD+ExF4sR9PD26kVx+9hNfD0FTIqRZEyRQ==", + "requires": { + "hoist-non-react-statics": "^2.1.1" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + } + } + }, "react-color": { "version": "2.19.3", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", @@ -16617,6 +16955,11 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, + "immer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz", + "integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==" + }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -19962,6 +20305,11 @@ "is-typedarray": "^1.0.0" } }, + "ua-parser-js": { + "version": "0.7.23", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.23.tgz", + "integrity": "sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA==" + }, "unfetch": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 01166c21..09ecdd78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,13 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "axios": "^0.21.0", + "draft": "^0.2.3", + "draft-js": "^0.11.7", + "draft-js-markdown-plugin": "^3.0.5", + "draft-js-markdown-shortcuts-plugin": "^0.6.1", + "draft-js-plugins-editor": "^3.0.0", "emoji-mart": "^3.0.0", + "immer": "^8.0.0", "qs": "^6.9.4", "react": "^17.0.1", "react-dom": "^17.0.1", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc..c3219dc2 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 94b634da..478a9652 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -24,10 +24,10 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Slack-겁나 빠른 슬랙 프로젝트 - +
- - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file diff --git a/frontend/src/atom/ChannelCard/locker.svg b/frontend/src/atom/ChannelCard/locker.svg deleted file mode 100644 index 49c955c6..00000000 --- a/frontend/src/atom/ChannelCard/locker.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - - diff --git a/frontend/src/atom/ChatContent/ChatContent.js b/frontend/src/atom/ChatContent/ChatContent.js deleted file mode 100644 index caec6c80..00000000 --- a/frontend/src/atom/ChatContent/ChatContent.js +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { COLOR } from '../../constant/style' -const ChatContent = ({ - displayName, - createdAt, - contents, - handleProfileModal, -}) => { - return ( - - - - {displayName} - - {createdAt} - - {contents} - - ) -} -const StyledChatContent = styled.div` - width: 100%; - display: flex; - flex-direction: column; - - margin-left: 15px; -` -const ChatHeader = styled.div` - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; -` - -const StyledDate = styled.span` - color: ${COLOR.GRAY}; - font-size: 12px; -` -const StyledUserName = styled.div` - display: inline-block; - font-size: 15px; - line-height: 1.46668; - font-weight: 900; - word-break: break-word; - margin-right: 5px; - &:hover { - cursor: pointer; - text-decoration: underline; - } -` - -const ChatContentArea = styled.div` - word-break: break-all; -` -export default ChatContent diff --git a/frontend/src/atom/GlobalStyle/GlobalStyle.js b/frontend/src/atom/GlobalStyle/GlobalStyle.js deleted file mode 100644 index bd626392..00000000 --- a/frontend/src/atom/GlobalStyle/GlobalStyle.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createGlobalStyle } from 'styled-components' - -const GlobalStyle = createGlobalStyle` - body { - padding: 0px; - margin: 0px; - height: 100%; - width: 100%; - } -` - -export default GlobalStyle diff --git a/frontend/src/constant/icon.js b/frontend/src/constant/icon.js index d8eeaa0a..4a90a8b8 100644 --- a/frontend/src/constant/icon.js +++ b/frontend/src/constant/icon.js @@ -17,6 +17,8 @@ import { faPlusSquare, faShare, faFile, + faPaperclip, + faPaperPlane, } from '@fortawesome/free-solid-svg-icons' import { faStar, @@ -53,3 +55,5 @@ export const PLUSSQURE = faPlusSquare export const SMILE = faSmile export const SHARE = faShare export const FILE = faFile +export const CLIP = faPaperclip +export const PAPERPLANE = faPaperPlane diff --git a/frontend/src/organism/ActionBar/ActionBar.js b/frontend/src/container/ActionBar/ActionBar.js similarity index 57% rename from frontend/src/organism/ActionBar/ActionBar.js rename to frontend/src/container/ActionBar/ActionBar.js index 5d1f047c..1a7770be 100644 --- a/frontend/src/organism/ActionBar/ActionBar.js +++ b/frontend/src/container/ActionBar/ActionBar.js @@ -1,21 +1,17 @@ import React from 'react' +import { useParams, Link } from 'react-router-dom' import styled from 'styled-components' -import EmojiModal from '../../atom/EmojiModal' -import Icon from '../../atom/Icon' +import EmojiModal from '../../presenter/EmojiModal' +import Icon from '../../presenter/Icon' import { COLOR } from '../../constant/style' -import { - SMILE, - COMMENTDOTS, - SHARE, - BOOKMARK, - ELLIPSISV, -} from '../../constant/icon' +import { SMILE, COMMENTDOTS, ELLIPSISV } from '../../constant/icon' import calcEmojiModalLocation from '../../util/calculateEmojiModalLocation' import { modalRecoil } from '../../store' -import { useRecoilState } from 'recoil' +import { useSetRecoilState } from 'recoil' -function ActionBar({ setOpenModal, chatId, updateReactionHandler }) { - const [modal, setModal] = useRecoilState(modalRecoil) +function ActionBar({ setOpenModal, updateReactionHandler, type, chatId }) { + const setModal = useSetRecoilState(modalRecoil) + const { workspaceId, channelId } = useParams() const closeHandler = () => { setOpenModal(false) @@ -38,27 +34,29 @@ function ActionBar({ setOpenModal, chatId, updateReactionHandler }) { return ( - updateReactionHandler('👍')}> - 👍 - - updateReactionHandler('👏')}> - 👏 - - updateReactionHandler('😄')}> - 😄 - + {type !== 'reply' && ( + <> + updateReactionHandler('👍')}> + 👍 + + updateReactionHandler('👏')}> + 👏 + + updateReactionHandler('😄')}> + 😄 + + + )} - - - - - - - - - + {type !== 'reply' && ( + + + + + + )} diff --git a/frontend/src/organism/ActionBar/index.js b/frontend/src/container/ActionBar/index.js similarity index 100% rename from frontend/src/organism/ActionBar/index.js rename to frontend/src/container/ActionBar/index.js diff --git a/frontend/src/atom/AddReactionButton/AddReactionButton.js b/frontend/src/container/AddReactionButton/AddReactionButton.js similarity index 83% rename from frontend/src/atom/AddReactionButton/AddReactionButton.js rename to frontend/src/container/AddReactionButton/AddReactionButton.js index d881f4a6..35b60a8d 100644 --- a/frontend/src/atom/AddReactionButton/AddReactionButton.js +++ b/frontend/src/container/AddReactionButton/AddReactionButton.js @@ -1,16 +1,16 @@ import React from 'react' -import styled, { css } from 'styled-components' -import { useRecoilState } from 'recoil' +import styled from 'styled-components' +import { useSetRecoilState } from 'recoil' import { modalRecoil } from '../../store' -import EmojiModal from '../EmojiModal' +import EmojiModal from '../../presenter/EmojiModal' -import Icon from '../Icon' +import Icon from '../../presenter/Icon' import { PLUS, SMILE } from '../../constant/icon' import { COLOR } from '../../constant/style' import calcEmojiModalLocation from '../../util/calculateEmojiModalLocation' function AddReactionButton({ updateReactionHandler }) { - const [modal, setModal] = useRecoilState(modalRecoil) + const setModal = useSetRecoilState(modalRecoil) const closeHandler = () => { setModal(null) diff --git a/frontend/src/atom/AddReactionButton/index.js b/frontend/src/container/AddReactionButton/index.js similarity index 100% rename from frontend/src/atom/AddReactionButton/index.js rename to frontend/src/container/AddReactionButton/index.js diff --git a/frontend/src/organism/ChannelHeader/ChannelHeader.js b/frontend/src/container/ChannelHeader/ChannelHeader.js similarity index 86% rename from frontend/src/organism/ChannelHeader/ChannelHeader.js rename to frontend/src/container/ChannelHeader/ChannelHeader.js index 7e0bfeef..352a5737 100644 --- a/frontend/src/organism/ChannelHeader/ChannelHeader.js +++ b/frontend/src/container/ChannelHeader/ChannelHeader.js @@ -1,16 +1,16 @@ -import React, { useEffect } from 'react' +import React from 'react' import styled from 'styled-components' -import { useSetRecoilState, useRecoilValue } from 'recoil' +import { useSetRecoilState } from 'recoil' -import Icon from '../../atom/Icon' +import Icon from '../../presenter/Icon' import { ADDUSER, INFOCIRCLE } from '../../constant/icon' -import ChannelCard from '../../atom/ChannelCard' -import ChannelStarBtn from '../../atom/ChannelStarBtn' -import ChannelPinBtn from '../../atom/ChannelPinBtn' -import ChannelTopicBtn from '../../atom/ChannelTopicBtn' -import ChannelMemberThumbnail from '../../atom/ChannelMemberThumbnail' +import ChannelCard from '../../presenter/ChannelCard' +import ChannelStarBtn from '../ChannelStarBtn' +import ChannelPinBtn from '../../presenter/ChannelPinBtn' +import ChannelTopicBtn from '../../presenter/ChannelTopicBtn' +import ChannelMemberThumbnail from '../../presenter/ChannelMemberThumbnail' import { modalRecoil } from '../../store' -import InviteUserToChannelModal from '../InviteUserToChannelModal' +import InviteUserToChannelModal from '../Modal/InviteUserToChannelModal' import { COLOR } from '../../constant/style' import useChannelInfo from '../../hooks/useChannelInfo' import { isEmpty } from '../../util' @@ -66,7 +66,7 @@ function ChannelHeader() { const ChannelHeaderStyle = styled.div` width: 100%; height: auto; - margin: auto 20px; + margin: 0 20px; display: flex; flex-direction: row; justify-content: space-between; diff --git a/frontend/src/organism/ChannelHeader/index.js b/frontend/src/container/ChannelHeader/index.js similarity index 100% rename from frontend/src/organism/ChannelHeader/index.js rename to frontend/src/container/ChannelHeader/index.js diff --git a/frontend/src/organism/ChannelList/ChannelList.js b/frontend/src/container/ChannelList/ChannelList.js similarity index 97% rename from frontend/src/organism/ChannelList/ChannelList.js rename to frontend/src/container/ChannelList/ChannelList.js index f0f2aa34..b5888214 100644 --- a/frontend/src/organism/ChannelList/ChannelList.js +++ b/frontend/src/container/ChannelList/ChannelList.js @@ -3,7 +3,7 @@ import { useHistory } from 'react-router' import styled from 'styled-components' import { toast } from 'react-toastify' import SectionLabel from '../SectionLabel' -import SideMenuList from '../SideMenuList' +import SideMenuList from '../../presenter/SideMenuList' import { useRecoilValue } from 'recoil' import { isEmpty } from '../../util' import { workspaceRecoil } from '../../store' diff --git a/frontend/src/organism/ChannelList/index.js b/frontend/src/container/ChannelList/index.js similarity index 100% rename from frontend/src/organism/ChannelList/index.js rename to frontend/src/container/ChannelList/index.js diff --git a/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js b/frontend/src/container/ChannelStarBtn/ChannelStarBtn.js similarity index 93% rename from frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js rename to frontend/src/container/ChannelStarBtn/ChannelStarBtn.js index c333ee85..b33cc373 100644 --- a/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.js +++ b/frontend/src/container/ChannelStarBtn/ChannelStarBtn.js @@ -5,17 +5,17 @@ import styled from 'styled-components' import { toast } from 'react-toastify' import { COLOR } from '../../constant/style' -import Icon from '../Icon' +import Icon from '../../presenter/Icon' import { workspaceRecoil } from '../../store' import { STAR, COLOREDSTAR } from '../../constant/icon' -import { atom, useRecoilState, useRecoilValue } from 'recoil' +import { useRecoilValue } from 'recoil' import { isEmpty } from '../../util' import useChannelList from '../../hooks/useChannelList' function ChannelStarBtn({ channel }) { const section = channel.sectionName const { channelId } = useParams() - const [Channels, setChannels] = useChannelList() + const [, setChannels] = useChannelList() const [sectionInfo, setSectionInfo] = useState(section) const workspaceUserInfo = useRecoilValue(workspaceRecoil) diff --git a/frontend/src/atom/ChannelStarBtn/ChannelStarBtn.stories.js b/frontend/src/container/ChannelStarBtn/ChannelStarBtn.stories.js similarity index 100% rename from frontend/src/atom/ChannelStarBtn/ChannelStarBtn.stories.js rename to frontend/src/container/ChannelStarBtn/ChannelStarBtn.stories.js diff --git a/frontend/src/atom/ChannelStarBtn/index.js b/frontend/src/container/ChannelStarBtn/index.js similarity index 100% rename from frontend/src/atom/ChannelStarBtn/index.js rename to frontend/src/container/ChannelStarBtn/index.js diff --git a/frontend/src/organism/ChatMessage/ChatMessage.js b/frontend/src/container/ChatMessage/ChatMessage.js similarity index 67% rename from frontend/src/organism/ChatMessage/ChatMessage.js rename to frontend/src/container/ChatMessage/ChatMessage.js index 5924e19e..4c8702da 100644 --- a/frontend/src/organism/ChatMessage/ChatMessage.js +++ b/frontend/src/container/ChatMessage/ChatMessage.js @@ -1,20 +1,33 @@ import React, { useState, forwardRef } from 'react' +import { NavLink, useParams } from 'react-router-dom' import styled from 'styled-components' -import UserProfileImg from '../../atom/UserProfileImg' -import ChatContent from '../../atom/ChatContent' -import ThreadReactionList from '../ThreadReactionList' +import { useRecoilValue } from 'recoil' +import UserProfileImg from '../../presenter/UserProfileImg' +import ChatContent from '../../presenter/ChatContent' +import ThreadReactionList from '../../presenter/ThreadReactionList' import ActionBar from '../ActionBar' +import ViewThreadButton from '../../presenter/Button/ViewThreadButton' +import { isEmpty, isImage } from '../../util' import { SIZE, COLOR } from '../../constant/style' import { workspaceRecoil, socketRecoil } from '../../store' -import { useRecoilValue } from 'recoil' -import { useParams } from 'react-router-dom' +import FilePreview from '../FilePreview' const ChatMessage = forwardRef( ( - { userInfo, reply, reactions, _id, createdAt, contents, type = 'chat' }, + { + userInfo, + reply, + reactions, + _id, + createdAt, + parentId, + contents, + type = 'chat', + file, + }, ref, ) => { - const { channelId } = useParams() + const { workspaceId, channelId } = useParams() const [openModal, setOpenModal] = useState(false) const [hover, setHover] = useState(false) const workspaceUserInfo = useRecoilValue(workspaceRecoil) @@ -25,6 +38,7 @@ const ChatMessage = forwardRef( emoji, chatId, channelId, + parentId, type, userInfo: { _id: workspaceUserInfo._id, @@ -36,8 +50,8 @@ const ChatMessage = forwardRef( const updateReactionHandler = emoji => { let done = false - reactions.map((reaction, idx) => { - if (reaction.emoji === emoji.native || reaction.emoji === emoji) { + reactions.map(reaction => { + if (reaction.emoji === emoji) { if (reaction.set) { updateReaction({ emoji: emoji.native || emoji, @@ -59,6 +73,11 @@ const ChatMessage = forwardRef( } } + const renderFilePreview = () => { + if (isEmpty(file)) return + return + } + return ( - {/* TODO thread Reaction 구현 */} - {reactions && reactions.length !== 0 && ( + + {!isEmpty(reactions) && ( )} - {/* TODO view thread reply 구현 */} - {reply && reply.length !== 0 && ( - view thread + + {type !== 'reply' && !isEmpty(reply) && ( + + + + + )} - {/* TODO Action bar 구현 */} {(hover || openModal) && ( - + )} @@ -111,9 +135,11 @@ const ChatMessage = forwardRef( const ActionBarStyle = styled.div` position: absolute; - width: 300px; + ${({ type }) => { + if (type === 'reply') return 'width: 100px;' + return 'width: 300px;' + }} height: 30px; - top: -15px; right: 10px; border-radius: 5px; display: flex; @@ -144,7 +170,6 @@ const StyledMessageContainer = styled.div` const ViewThreadBarStyle = styled.div` width: auto; - height: 30px; display: flex; flex-direction: row; ` @@ -159,4 +184,16 @@ const ThreadReactionStyle = styled.div` padding: 5px 10px; border-radius: 5px; ` + +const StyleLink = styled(NavLink)` + text-decoration: none; + color: ${COLOR.STARBLUE}; + background-color: ${COLOR.WHITE}; + padding: 5px; + margin-left: 15px; + &:hover { + border: 1px solid ${COLOR.LIGHT_GRAY}; + border-radius: 5px; + } +` export default ChatMessage diff --git a/frontend/src/organism/ChatMessage/ChatMessage.stories.js b/frontend/src/container/ChatMessage/ChatMessage.stories.js similarity index 100% rename from frontend/src/organism/ChatMessage/ChatMessage.stories.js rename to frontend/src/container/ChatMessage/ChatMessage.stories.js diff --git a/frontend/src/organism/ChatMessage/index.js b/frontend/src/container/ChatMessage/index.js similarity index 100% rename from frontend/src/organism/ChatMessage/index.js rename to frontend/src/container/ChatMessage/index.js diff --git a/frontend/src/container/ChatRoom/ChatRoom.js b/frontend/src/container/ChatRoom/ChatRoom.js new file mode 100644 index 00000000..7f29858c --- /dev/null +++ b/frontend/src/container/ChatRoom/ChatRoom.js @@ -0,0 +1,193 @@ +import React, { useEffect, useState, useRef, useCallback } from 'react' +import styled from 'styled-components' +import { useParams } from 'react-router-dom' +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, socketRecoil } from '../../store' +import ChannelHeader from '../ChannelHeader' +import { isEmpty } from '../../util' +import { hasMyReaction, chageReactionState } from '../../util/reactionUpdate' +import useChannelInfo from '../../hooks/useChannelInfo' + +const ChatRoom = ({ width }) => { + const viewport = useRef(null) + const observerTargetNode = useRef(null) + const messageEndRef = useRef(null) + const previousReadMessage = useRef(null) + const isLoading = useRef(false) + const isAllMessageFetched = useRef(false) + const workspaceUserInfo = useRecoilValue(workspaceRecoil) + const [channelInfo] = useChannelInfo() + const { workspaceId, channelId } = useParams() + const params = useParams() + const socket = useRecoilValue(socketRecoil) + const [messages, setMessages] = useState([]) + const [previousReadMessageIndex, setPreviousReadMessageIndex] = useState(0) + + const loadMessage = useCallback( + async (workspaceId, channelId, currentCursor) => { + isLoading.current = true + const newMessages = await getChatMessage({ + workspaceId, + channelId, + currentCursor, + }) + if (!newMessages.length) isAllMessageFetched.current = true + if (!isEmpty(messages)) setPreviousReadMessageIndex(newMessages.length) + setMessages(messages => [ + ...hasMyReaction(newMessages, workspaceUserInfo), + ...messages, + ]) + if (previousReadMessage.current) scrollTo(previousReadMessage.current) + if (!previousReadMessage.current && newMessages.length !== 0) scrollTo() + isLoading.current = false + }, + [messages], + ) + + useEffect(() => { + setMessages([]) + isLoading.current = false + isAllMessageFetched.current = false + loadMessage(workspaceId, channelId, new Date()) + }, [workspaceId, channelId]) + + const scrollTo = (target = messageEndRef.current) => { + target.scrollIntoView() + } + + const sendMessage = (message, file) => { + const chat = { + contents: message, + channelId, + file: file, + userInfo: { + _id: workspaceUserInfo._id, + displayName: workspaceUserInfo.displayName, + profileUrl: workspaceUserInfo.profileUrl, + }, + } + socket.emit('new message', chat) + } + + useEffect(() => { + setMessages(messages => [...hasMyReaction(messages, workspaceUserInfo)]) + }, [workspaceUserInfo]) + + useEffect(() => { + if (socket) { + socket.on('new message', ({ message }) => { + if (message.channelId === channelId) + setMessages(messages => [ + ...messages, + ...hasMyReaction([message], workspaceUserInfo), + ]) + + if (document.hidden) { + new Notification('새로운 메시지가 왔습니다.', { + body: `${message.userInfo.displayName} : ${message.contents}`, + }) + } + + if (message.userInfo._id === workspaceUserInfo._id) scrollTo() + }) + socket.on('update reaction', ({ reaction }) => { + setMessages(messages => chageReactionState(messages, reaction)) + }) + } + return () => { + if (socket) { + socket.off('new message') + socket.off('update reaction') + } + } + }, [socket, channelId, document.hidden, params]) + + useEffect(() => { + const handleIntersection = (entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + if (!isLoading.current && !isAllMessageFetched.current) { + loadMessage(workspaceId, channelId, observerTargetNode.current.id) + observer.unobserve(entry.target) + observer.observe(observerTargetNode.current) + } + } + }) + } + const IO = new IntersectionObserver(handleIntersection, { + root: viewport.current, + threshold: 0, + }) + if (observerTargetNode.current) IO.observe(observerTargetNode.current) + return () => IO && IO.disconnect() + }, [channelId, workspaceId, loadMessage]) + + const setRef = useCallback( + index => { + if (index === 0) return node => (observerTargetNode.current = node) + if (index === previousReadMessageIndex) + return node => (previousReadMessage.current = node) + }, + [previousReadMessageIndex], + ) + + return ( + + + + + + {messages && + messages.map((message, i) => { + return ( + + ) + })} +
+
+ +
+ ) +} +const ChatArea = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: calc(${props => props.width}% - 2px); + background: ${COLOR.HOVER_GRAY}; +` + +const ChatHeader = styled.div` + display: flex; + width: 100%; + height: 60px; + background: ${COLOR.BACKGROUND_CONTENTS}; + border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; +` + +const ChatContents = styled.div` + display: flex; + flex-direction: column; + height: calc(100% - 100px); + overflow-x: hidden; + overflow-y: auto; + background: ${COLOR.WHITE}; + border: 1px solid rgba(255, 255, 255, 0.1); +` + +export default ChatRoom diff --git a/frontend/src/organism/ChatRoom/index.js b/frontend/src/container/ChatRoom/index.js similarity index 100% rename from frontend/src/organism/ChatRoom/index.js rename to frontend/src/container/ChatRoom/index.js diff --git a/frontend/src/organism/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js b/frontend/src/container/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js similarity index 90% rename from frontend/src/organism/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js rename to frontend/src/container/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js index eabc55f4..b500bcf9 100644 --- a/frontend/src/organism/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js +++ b/frontend/src/container/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.js @@ -1,12 +1,12 @@ -import React, { useState, useRef } from 'react' +import React, { useState } from 'react' import styled from 'styled-components' -import Description from '../../atom/Description/Description' -import MainDescription from '../../atom/MainDescription' -import Button from '../../atom/Button' +import Description from '../../presenter/Description/Description' +import MainDescription from '../../presenter/MainDescription' +import Button from '../../presenter/Button' import request from '../../util/request' import { useHistory } from 'react-router' import { toast } from 'react-toastify' -import Input from '../../atom/Input' +import Input from '../../presenter/Input' import { COLOR } from '../../constant/style' const MAX_CHANNEL_NAME = 80 diff --git a/frontend/src/organism/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.stories.js b/frontend/src/container/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.stories.js similarity index 100% rename from frontend/src/organism/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.stories.js rename to frontend/src/container/CreateWorkspaceInitChannel/CreateWorkspaceInitChannel.stories.js diff --git a/frontend/src/organism/CreateWorkspaceInitChannel/index.js b/frontend/src/container/CreateWorkspaceInitChannel/index.js similarity index 100% rename from frontend/src/organism/CreateWorkspaceInitChannel/index.js rename to frontend/src/container/CreateWorkspaceInitChannel/index.js diff --git a/frontend/src/organism/CreateWorkspaceName/CreateWorkspaceName.js b/frontend/src/container/CreateWorkspaceName/CreateWorkspaceName.js similarity index 92% rename from frontend/src/organism/CreateWorkspaceName/CreateWorkspaceName.js rename to frontend/src/container/CreateWorkspaceName/CreateWorkspaceName.js index 44c81e01..397fdd68 100644 --- a/frontend/src/organism/CreateWorkspaceName/CreateWorkspaceName.js +++ b/frontend/src/container/CreateWorkspaceName/CreateWorkspaceName.js @@ -1,9 +1,9 @@ import React, { useState, useRef } from 'react' import styled from 'styled-components' -import Description from '../../atom/Description/Description' -import MainDescription from '../../atom/MainDescription' -import Button from '../../atom/Button/' -import Input from '../../atom/Input' +import Description from '../../presenter/Description/Description' +import MainDescription from '../../presenter/MainDescription' +import Button from '../../presenter/Button/' +import Input from '../../presenter/Input' import { debounce } from '../../util' import { checkDuplicateWorkspaceName } from '../../api/createWorkspace' import { COLOR } from '../../constant/style' diff --git a/frontend/src/organism/CreateWorkspaceName/CreateWorkspaceName.stories.js b/frontend/src/container/CreateWorkspaceName/CreateWorkspaceName.stories.js similarity index 100% rename from frontend/src/organism/CreateWorkspaceName/CreateWorkspaceName.stories.js rename to frontend/src/container/CreateWorkspaceName/CreateWorkspaceName.stories.js diff --git a/frontend/src/organism/CreateWorkspaceName/index.js b/frontend/src/container/CreateWorkspaceName/index.js similarity index 100% rename from frontend/src/organism/CreateWorkspaceName/index.js rename to frontend/src/container/CreateWorkspaceName/index.js diff --git a/frontend/src/container/FilePreview/FilePreview.js b/frontend/src/container/FilePreview/FilePreview.js new file mode 100644 index 00000000..8daa36ac --- /dev/null +++ b/frontend/src/container/FilePreview/FilePreview.js @@ -0,0 +1,161 @@ +import React, { useState } from 'react' +import styled from 'styled-components' +import request from '../../util/request' +import Icon from '../../presenter/Icon' +import { CLOSE, FILE } from '../../constant/icon' +import { COLOR } from '../../constant/style' +import Button from '../../presenter/Button' +import { isImage } from '../../util' + +function FilePreview({ type, setIsRender, file, setFile }) { + const [isHover, setIsHover] = useState(false) + + const enterMouseHandle = () => { + setIsHover(true) + } + + const leaveMouseHandle = () => { + setIsHover(false) + } + + const handleDelete = async () => { + setIsRender(false) + await request.DELETE('/api/file', { name: file.name }) + setFile(null) + } + + const deleteButton = () => { + return ( + + + + ) + } + + const downloadButton = () => { + return ( + { + if (file) window.open(file.url, '_blank') + }} + > + Click to Download + + ) + } + + const renderImgPreview = () => { + return ( + + + {isHover && + (type === 'input' + ? deleteButton() + : type === 'message' + ? downloadButton() + : null)} + + ) + } + + const renderFilePreview = () => { + return ( + + + + + {file?.originalName} + + {isHover && + (type === 'input' + ? deleteButton() + : type === 'message' + ? downloadButton() + : null)} + + + ) + } + + return isImage(file?.fileType) ? renderImgPreview() : renderFilePreview() +} + +const StyledImgDiv = styled.div` + display: inline-block; + position: relative; +` + +const StyledFileDiv = styled.div` + display: inline-block; + position: relative; + border: 1px solid ${COLOR.LIGHT_GRAY}; + border-radius: 4px; + min-width: 200px; + max-width: 300px; +` + +const FlexDiv = styled.div` + display: flex; + justify-content: left; + padding: 5px 5px 5px 10px; +` + +const StyledImg = styled.img` + max-width: ${({ type }) => { + return type === 'input' ? '80px' : '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}; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 5px; + padding: 0 4px; +` + +const DescriptionDiv = styled.div` + max-width: 200px; + padding: 10px 15px 10px 20px; + text-overflow: ellipsis; + overflow: hidden; +` + +export default FilePreview diff --git a/frontend/src/container/FilePreview/FilePreview.stories.js b/frontend/src/container/FilePreview/FilePreview.stories.js new file mode 100644 index 00000000..886a108e --- /dev/null +++ b/frontend/src/container/FilePreview/FilePreview.stories.js @@ -0,0 +1,71 @@ +import React from 'react' +import FilePreview from './FilePreview' + +export default { + title: 'Organism/FilePreview', + component: FilePreview, +} +let isRender = true +const Template = args => <>{isRender && } + +export const inputImgPreview = Template.bind({}) +inputImgPreview.args = { + type: 'input', // input, message + file: { + fileId: '5fda2fe2e882860557e592fd', + name: 'file-1608140339770-슬기.jpg', + originalName: '슬기.jpg', + fileType: 'image/jpeg', + creator: '5fd81c4d630674160961baf4', + url: + 'https://kr.object.ncloudstorage.com/slack-clone-files/file-1608140339770-슬기.jpg', + }, + setIsRender: () => { + isRender = false + }, +} + +export const messageImgPreview = Template.bind({}) +messageImgPreview.args = { + type: 'message', // input, message + file: { + fileId: '5fda2fe2e882860557e592fd', + name: 'file-1608140339770-슬기.jpg', + originalName: '슬기.jpg', + fileType: 'image/jpeg', + creator: '5fd81c4d630674160961baf4', + url: + 'https://kr.object.ncloudstorage.com/slack-clone-files/file-1608140339770-슬기.jpg', + }, +} + +export const inputFilePreview = Template.bind({}) +inputFilePreview.args = { + type: 'input', // input, message + file: { + fileId: '5fda2fe2e882860557e592fd', + name: 'file-1608140450582-test.zip', + originalName: 'test.zip', + fileType: 'application/x-zip-compressed', + creator: '5fd81c4d630674160961baf4', + url: + 'https://kr.object.ncloudstorage.com/slack-clone-files/file-1608140450582-test.zip', + }, + setIsRender: () => { + isRender = false + }, +} + +export const messageFilePreview = Template.bind({}) +messageFilePreview.args = { + type: 'message', // input, message + file: { + fileId: '5fda2fe2e882860557e592fd', + name: 'file-1608140450582-test.zip', + originalName: 'test.zip', + fileType: 'application/x-zip-compressed', + creator: '5fd81c4d630674160961baf4', + url: + 'https://kr.object.ncloudstorage.com/slack-clone-files/file-1608140450582-test.zip', + }, +} diff --git a/frontend/src/organism/FilePreview/index.js b/frontend/src/container/FilePreview/index.js similarity index 100% rename from frontend/src/organism/FilePreview/index.js rename to frontend/src/container/FilePreview/index.js diff --git a/frontend/src/container/FileUploader/FileUploader.js b/frontend/src/container/FileUploader/FileUploader.js new file mode 100644 index 00000000..47f19e61 --- /dev/null +++ b/frontend/src/container/FileUploader/FileUploader.js @@ -0,0 +1,60 @@ +import React, { useRef } from 'react' +import request from '../../util/request' +import styled from 'styled-components' +import Button from '../../presenter/Button' +import Icon from '../../presenter/Icon' +import { CLIP } from '../../constant/icon' +import { toast } from 'react-toastify' +import { isEmpty } from '../../util' + +const fileContentType = 'multipart/form-data' + +function FileUploader({ file, setFile }) { + const fileInput = useRef(null) + const handleFileInput = async e => { + if (!e.target.files[0]) return + if (e.target.files[0].size > 8192000) { + toast.error('8MB 이하의 파일만 업로드 할 수 있습니다!') + return + } + if (!isEmpty(file)) { + await request.DELETE('/api/file', { name: file.name }) + setFile(null) + } + await handlePost(e.target.files[0]) + e.target.value = '' + } + const handlePost = async selectedFile => { + if (selectedFile) { + const formData = new FormData() + formData.append('file', selectedFile) + const { data } = await request.POST( + '/api/file', + formData, + fileContentType, + ) + setFile(data.data) + } + } + + return ( + <> + + handleFileInput(e)} + > + + ) +} + +const StyeldInput = styled.input` + width: 1px; + height: 1px; +` + +export default FileUploader diff --git a/frontend/src/container/FileUploader/FileUploader.stories.js b/frontend/src/container/FileUploader/FileUploader.stories.js new file mode 100644 index 00000000..b5901067 --- /dev/null +++ b/frontend/src/container/FileUploader/FileUploader.stories.js @@ -0,0 +1,45 @@ +import React, { useState, useEffect } from 'react' +import { storiesOf } from '@storybook/react' +import FileUploader from './FileUploader' +import styled from 'styled-components' +import FilePreview from '../FilePreview' +import { ToastContainer } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.css' + +const stories = storiesOf('Organism', module) + +const TestComponent = () => { + const [file, setFile] = useState(null) + const [isRender, setIsRender] = useState(false) + + useEffect(() => { + if (file) setIsRender(true) + }, [file]) + + const renderPreview = () => { + return ( + + ) + } + + return ( + <> + + {isRender && renderPreview()} + + + ) +} +stories.add('FileUploader', () => ) + +const StyledDiv = styled.div` + min-width: 300px; + min-height: 200px; + background: gray; +` diff --git a/frontend/src/organism/FileUploader/index.js b/frontend/src/container/FileUploader/index.js similarity index 100% rename from frontend/src/organism/FileUploader/index.js rename to frontend/src/container/FileUploader/index.js diff --git a/frontend/src/container/MessageEditor/MessageEditor.js b/frontend/src/container/MessageEditor/MessageEditor.js new file mode 100644 index 00000000..59341bc3 --- /dev/null +++ b/frontend/src/container/MessageEditor/MessageEditor.js @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react' +import { COLOR } from '../../constant/style' +import Input from '../../presenter/Input' +import FileUploader from '../FileUploader' +import FilePreview from '../FilePreview' +import styled from 'styled-components' +import Icon from '../../presenter/Icon' +import { PAPERPLANE } from '../../constant/icon' +import Button from '../../presenter/Button' + +function MessageEditor({ sendMessage, placeholder }) { + const [message, setMessage] = useState('') + const [file, setFile] = useState(null) + const [isRender, setIsRender] = useState(false) + const [isSend, setIsSend] = useState(false) + + useEffect(() => { + if (file) { + setIsRender(true) + setIsSend(true) + } + }, [file]) + + const handleInput = e => { + setMessage(e.target.value) + if (e.target.value.length > 0) setIsSend(true) + else setIsSend(false) + } + + const sendMessageHanle = () => { + sendMessage(message, file) + setMessage('') + setFile(null) + setIsRender(false) + setIsSend(false) + } + + const handleKey = e => { + if (e.key === 'Enter' && (e.target.value || file)) { + sendMessageHanle() + } + } + + const renderPreview = () => { + return ( +
+ +
+ ) + } + + return ( + + + + {isRender && renderPreview()} + + + + + + {/* TODO markdown, chat action 적용 필요 */} + + ) +} + +const MessageEditorContainer = styled.div` + padding: 20px; + background-color: ${COLOR.WHITE}; +` + +const StyledDiv = styled.div` + float: right; + margin-top: 5px; +` +const EditorArea = styled.div` + &:focus-within { + box-shadow: 0 0 0 1px rgba(18, 100, 163, 1), + 0 0 0 5px rgba(29, 155, 209, 0.3); + border-radius: 4px; + border-color: transparent; + } + &:focus-within > div { + display: block; + } + border: 1px solid ${COLOR.TRANSPARENT_GRAY}; + border-radius: 4px; +` +export default MessageEditor diff --git a/frontend/src/container/MessageEditor/index.js b/frontend/src/container/MessageEditor/index.js new file mode 100644 index 00000000..41b1b24b --- /dev/null +++ b/frontend/src/container/MessageEditor/index.js @@ -0,0 +1 @@ +export { default } from './MessageEditor' diff --git a/frontend/src/container/Modal/ChannelBrowserModal/ChannelBrowserModal.js b/frontend/src/container/Modal/ChannelBrowserModal/ChannelBrowserModal.js new file mode 100644 index 00000000..8af634a3 --- /dev/null +++ b/frontend/src/container/Modal/ChannelBrowserModal/ChannelBrowserModal.js @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from 'react' +import styled from 'styled-components' +import { useRecoilValue } from 'recoil' +import ChannelBrowserCard from '../../../presenter/ChannelBrowserCard' +import { useParams } from 'react-router-dom' + +import { workspaceRecoil } from '../../../store' +import Modal from '../../../presenter/Modal' +import Title from '../../../presenter/Title' +import Icon from '../../../presenter/Icon' +import { getChannelBrowserData } from '../../../api/channel' +import Button from '../../../presenter/Button' +import { CLOSE } from '../../../constant/icon' +import { COLOR } from '../../../constant/style' + +function ChannelBrowserModal({ handleClose }) { + const [channelList, setChannelList] = useState([]) + const workspaceUserInfo = useRecoilValue(workspaceRecoil) + const { workspaceId } = useParams() + + useEffect(() => { + if (workspaceUserInfo) { + ;(async () => { + const channelList = await getChannelBrowserData({ + workspaceUserInfoId: workspaceUserInfo._id, + workspaceId, + }) + setChannelList(channelList) + })() + } + }, [workspaceUserInfo]) + + return ( + + +
+ Channel Browser + +
+ + {channelList?.map((el, idx) => ( + + ))} + + + - +
diff --git a/frontend/src/organism/InviteWorkspaceModal/InviteWorkspaceModal.stories.js b/frontend/src/container/Modal/InviteWorkspaceModal/InviteWorkspaceModal.stories.js similarity index 100% rename from frontend/src/organism/InviteWorkspaceModal/InviteWorkspaceModal.stories.js rename to frontend/src/container/Modal/InviteWorkspaceModal/InviteWorkspaceModal.stories.js diff --git a/frontend/src/organism/InviteWorkspaceModal/index.js b/frontend/src/container/Modal/InviteWorkspaceModal/index.js similarity index 100% rename from frontend/src/organism/InviteWorkspaceModal/index.js rename to frontend/src/container/Modal/InviteWorkspaceModal/index.js diff --git a/frontend/src/organism/MyWorkspaceSection/MyWorkspaceSection.js b/frontend/src/container/MyWorkspaceSection/MyWorkspaceSection.js similarity index 86% rename from frontend/src/organism/MyWorkspaceSection/MyWorkspaceSection.js rename to frontend/src/container/MyWorkspaceSection/MyWorkspaceSection.js index 1e4323ee..3aab3b21 100644 --- a/frontend/src/organism/MyWorkspaceSection/MyWorkspaceSection.js +++ b/frontend/src/container/MyWorkspaceSection/MyWorkspaceSection.js @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' -import MainDescription from '../../atom/MainDescription/MainDescription' -import MyWorkspace from '../MyWorkspace' +import MainDescription from '../../presenter/MainDescription/MainDescription' +import MyWorkspace from '../../presenter/MyWorkspace' import request from '../../util/request' const MyWorkspaceSection = () => { diff --git a/frontend/src/organism/MyWorkspaceSection/MyWorkspaceSection.stories.js b/frontend/src/container/MyWorkspaceSection/MyWorkspaceSection.stories.js similarity index 100% rename from frontend/src/organism/MyWorkspaceSection/MyWorkspaceSection.stories.js rename to frontend/src/container/MyWorkspaceSection/MyWorkspaceSection.stories.js diff --git a/frontend/src/organism/MyWorkspaceSection/index.js b/frontend/src/container/MyWorkspaceSection/index.js similarity index 100% rename from frontend/src/organism/MyWorkspaceSection/index.js rename to frontend/src/container/MyWorkspaceSection/index.js diff --git a/frontend/src/atom/SearchUserCard/SearchUserCard.js b/frontend/src/container/SearchUserCard/SearchUserCard.js similarity index 95% rename from frontend/src/atom/SearchUserCard/SearchUserCard.js rename to frontend/src/container/SearchUserCard/SearchUserCard.js index 79750e45..b55f9758 100644 --- a/frontend/src/atom/SearchUserCard/SearchUserCard.js +++ b/frontend/src/container/SearchUserCard/SearchUserCard.js @@ -1,9 +1,8 @@ import React, { useState, useEffect } from 'react' import styled, { css } from 'styled-components' -import UserActive from '../UserActive' -import UserProfileImg from '../UserProfileImg' +import UserActive from '../../presenter/UserActive' import { COLOR } from '../../constant/style' -import Icon from '../Icon' +import Icon from '../../presenter/Icon' import { CHECK } from '../../constant/icon' function SearchUserCard({ userInfo, state, setState }) { diff --git a/frontend/src/atom/SearchUserCard/index.js b/frontend/src/container/SearchUserCard/index.js similarity index 100% rename from frontend/src/atom/SearchUserCard/index.js rename to frontend/src/container/SearchUserCard/index.js diff --git a/frontend/src/organism/SectionLabel/SectionLabel.js b/frontend/src/container/SectionLabel/SectionLabel.js similarity index 87% rename from frontend/src/organism/SectionLabel/SectionLabel.js rename to frontend/src/container/SectionLabel/SectionLabel.js index 6a16dcc9..122be318 100644 --- a/frontend/src/organism/SectionLabel/SectionLabel.js +++ b/frontend/src/container/SectionLabel/SectionLabel.js @@ -1,24 +1,25 @@ import React, { useState } from 'react' import { useParams } from 'react-router' import styled, { keyframes, css } from 'styled-components' -import Icon from '../../atom/Icon' +import Icon from '../../presenter/Icon' import { ELLIPSISV, PLUS } from '../../constant/icon' import { Link } from 'react-router-dom' import { COLOR, SIZE } from '../../constant/style' -import ChannelCard from '../../atom/ChannelCard' -import DirectMessageCard from '../../atom/DirectMessageCard' -import AddButton from '../../atom/Button/AddButton' +import ChannelCard from '../../presenter/ChannelCard' +import DirectMessageCard from '../../presenter/DirectMessageCard' +import AddButton from '../../presenter/Button/AddButton' import { modalRecoil } from '../../store' import { useSetRecoilState } from 'recoil' -import InviteWorkspaceModal from '../InviteWorkspaceModal/' -import CreateChannelModal from '../CreateChannelModal/CreateChannelModal' +import InviteWorkspaceModal from '../Modal/InviteWorkspaceModal' +import CreateChannelModal from '../Modal/CreateChannelModal/CreateChannelModal' +import ChannelBrowserModal from '../Modal/ChannelBrowserModal' +import InviteUserToChannelModal from '../Modal/InviteUserToChannelModal' function SectionLabel(props) { const [isOpen, setIsOpen] = useState(true) const { sectionName, lists } = props const { channelId, workspaceId } = useParams() const setModal = useSetRecoilState(modalRecoil) - const openSection = () => { setIsOpen(!isOpen) } @@ -27,6 +28,13 @@ function SectionLabel(props) { e.stopPropagation() } + const openAddUserModal = e => { + e.stopPropagation() + setModal( + setModal(null)} />, + ) + } + const renderChannelCards = lists.length !== 0 ? ( lists.map((list, idx) => { @@ -67,6 +75,11 @@ function SectionLabel(props) { setModal( setModal(null)} />) } + const openChannelBrowserModal = e => { + e.stopPropagation() + setModal( setModal(null)} />) + } + const addButtons = sectionName === 'Direct messages' ? ( {sectionName === 'Channels' && ( - + )} {sectionName === 'Direct messages' && ( - + { + const [data] = await getChatReplyMessage({ + workspaceId, + channelId, + chatId, + }) + if (data) { + setReplyContent(hasMyReaction(data.reply, workspaceUserInfo)) + setSidebarChat(hasMyReaction([data], workspaceUserInfo)) + } + if (data === false) history.push(`/workspace/${workspaceId}/${channelId}`) + } + + const closeSideBar = () => { + history.push(`/workspace/${workspaceId}/${channelId}`) + } + + const scrollTo = (targetRef = messageEndRef.current) => { + if (targetRef) targetRef.scrollIntoView() + } + + useEffect(() => { + if (socket) { + socket.on('new reply', ({ message }) => { + if (message.chatId === chatId) { + setReplyContent(messages => [...messages, message]) + } + scrollTo() + }) + socket.on('update reaction', ({ reaction }) => { + if (reaction.chatId === chatId) + setSidebarChat(chat => chageReactionState(chat, reaction)) + if (reaction.parentId) { + setReplyContent(reply => chageReactionState(reply, reaction)) + } + }) + } + return () => { + if (socket) { + socket.off('new message') + socket.off('update reaction') + } + } + }, [socket, chatId]) + + const sendReply = (message, file) => { + const reply = { + contents: message, + parentId: chatId, + channelId, + file: file, + userInfo: { + _id: workspaceUserInfo._id, + displayName: workspaceUserInfo.displayName, + profileUrl: workspaceUserInfo.profileUrl, + }, + } + socket.emit('new reply', reply) + } + + useEffect(() => { + setReplyContent(reply => hasMyReaction(reply, workspaceUserInfo)) + setSidebarChat(chat => hasMyReaction(chat, workspaceUserInfo)) + scrollTo() + }, [workspaceUserInfo]) + + useEffect(() => { + if (chatId !== undefined) loadReplyMessage(workspaceId, channelId, chatId) + }, [chatId]) + + return ( + + + Thread + + + + + + + {sidebarChat && } + + + {replyContent && ( + + + {`${replyContent.length} ${ + replyContent.length === 1 ? 'reply' : 'replies' + } `} + + + + )} + {replyContent && + replyContent.map((message, i) => { + return + })} +
+ + + + ) +} + +const SideThreadBarStyle = styled.div` + width: auto; + height: 100%; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; +` + +const SideBarHeader = styled.div` + position: relative; + width: auto; + height: 60px; + color: ${COLOR.LABEL_SELECT_TEXT}; + padding-left: 20px; + font-size: 16px; + font-weight: 600; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; + border-right: 0; + background-color: ${COLOR.BACKGROUND_CONTENTS}; +` + +const CloseBtn = styled.div` + position: absolute; + right: 20px; + cursor: pointer; +` + +const SideBarContents = styled.div` + width: auto; + height: calc(100% - 60px); + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-right: 0; + box-sizing: border-box; +` + +const ChatContent = styled.div` + width: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-right: 0; + overflow-y: auto; +` +const Separator = styled.div` + border-bottom: 1px solid ${COLOR.GRAY}; + width: 100%; +` +const CountReply = styled.div` + min-width: max-content; + margin-right: 5px; +` +const CountReplyArea = styled.div` + font-size: 13px; + font-weight: 400; + color: ${COLOR.GRAY}; + display: flex; + padding: 16px; + align-items: center; +` +export default SideThreadBar diff --git a/frontend/src/container/SideThreadBar/index.js b/frontend/src/container/SideThreadBar/index.js new file mode 100644 index 00000000..56473813 --- /dev/null +++ b/frontend/src/container/SideThreadBar/index.js @@ -0,0 +1 @@ +export { default } from './SideThreadBar' diff --git a/frontend/src/hooks/GithubOAuth.js b/frontend/src/hooks/GithubOAuth.js index c07edb73..9362a952 100644 --- a/frontend/src/hooks/GithubOAuth.js +++ b/frontend/src/hooks/GithubOAuth.js @@ -4,7 +4,12 @@ import QueryString from 'qs' import { toast } from 'react-toastify' import { useHistory } from 'react-router' -export default function GithubOAuth(Component, loginRequired) { +const baseURL = + process.env.NODE_ENV === 'development' + ? process.env.REACT_APP_DEV_API_URL + : process.env.REACT_APP_API_URL + +export default function GithubOAuth(Component) { function Authentication(props) { const [loading, setloading] = useState(true) const history = useHistory() @@ -14,15 +19,16 @@ export default function GithubOAuth(Component, loginRequired) { const query = QueryString.parse(props.location.search, { ignoreQueryPrefix: true, }) + const invitecode = localStorage.getItem('invitecode') await request.GET( '/api/user/sign-in/github/callback?code=' + query.code, ) - if (loginRequired) { - history.push('/login') - } - if (!loginRequired) { - history.push('/') + if (invitecode) { + localStorage.removeItem('invitecode') + window.location.href = `${baseURL}/api/workspace/invite/${invitecode}` + return } + history.push('/') setloading(false) } catch (err) { toast.error('인증이 실패하였습니다', { diff --git a/frontend/src/index.js b/frontend/src/index.js index 83a8164b..addac976 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,13 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom' -import { createGlobalStyle } from 'styled-components' import './index.css' import WorkspacePage from './page/WorkspacePage' -import { BrowserRouter, Route, Switch } from 'react-router-dom' +import { BrowserRouter, Route } from 'react-router-dom' import reportWebVitals from './reportWebVitals' import LoginPage from './page/login/Login' import CreateWorkspace from './page/createWorkspace/CreateWorkspace' import SelectWorkspace from './page/selectWorkspace/SelectWorkspace' +import GlobalStyle from './presenter/GlobalStyle' import Auth from './hooks/Auth' import GithubOAuth from './hooks/GithubOAuth' import { RecoilRoot } from 'recoil' @@ -22,37 +22,23 @@ const App = () => { - - + + - - - + ) } -const GlobalStyle = createGlobalStyle` - body { - padding: 0px; - margin: 0px; - height: 100%; - width: 100%; - } -` - ReactDOM.render(, document.getElementById('root')) // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/frontend/src/organism/ChatRoom/ChatRoom.js b/frontend/src/organism/ChatRoom/ChatRoom.js deleted file mode 100644 index 5cec90e9..00000000 --- a/frontend/src/organism/ChatRoom/ChatRoom.js +++ /dev/null @@ -1,197 +0,0 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react' -import styled from 'styled-components' -import { useParams } from 'react-router-dom' -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, - socketRecoil, - currentChannelInfoRecoil, -} from '../../store' -import ChannelHeader from '../ChannelHeader' - -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 = useRecoilValue(socketRecoil) - const [messages, setMessages] = useState([]) - const load = useRef(false) - - const loadMessage = async (workspaceId, channelId, currentCursor) => { - load.current = true - const newMessages = await getChatMessage({ - workspaceId, - channelId, - currentCursor, - }) - setMessages(messages => [...newMessages, ...messages]) - load.current = false - } - - useEffect(() => { - setMessages([]) - loadMessage(workspaceId, channelId, new Date()) - scrollTo() - }, [workspaceId, channelId]) - - const scrollTo = (targetRef = messageEndRef.current) => { - targetRef.scrollIntoView() - } - - const sendMessage = message => { - const chat = { - contents: message, - channelId, - userInfo: { - _id: workspaceUserInfo._id, - displayName: workspaceUserInfo.displayName, - profileUrl: workspaceUserInfo.profileUrl, - }, - } - socket.emit('new message', chat) - } - - 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('new message', ({ 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('new message') - socket.off('update reaction') - } - } - }, [socket, channelId]) - - useEffect(() => { - const option = { - root: viewport.current, - threshold: 0, - } - const handleIntersection = (entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - if (!load.current) { - loadMessage(workspaceId, channelId, target.current.id) - observer.unobserve(entry.target) - observer.observe(target.current) - } - } - }) - } - const IO = new IntersectionObserver(handleIntersection, option) - if (target.current) IO.observe(target.current) - return () => IO && IO.disconnect() - }, [viewport, targetState]) - - const setTarget = useCallback(node => { - target.current = node - setTargetState(node) - }, []) - - return ( - - - - - - {messages.map((message, i) => ( - - ))} -
-
- -
- ) -} -const ChatArea = styled.div` - display: flex; - flex-direction: column; - height: 100%; - width: 70%; - background: ${COLOR.HOVER_GRAY}; -` - -const ChatHeader = styled.div` - display: flex; - width: 100%; - height: 60px; - background: ${COLOR.BACKGROUND_CONTENTS}; - border: 1px solid rgba(255, 255, 255, 0.1); -` - -const ChatContents = styled.div` - display: flex; - flex-direction: column; - height: calc(100% - 100px); - overflow-x: hidden; - overflow-y: auto; - background: ${COLOR.WHITE}; - border: 1px solid rgba(255, 255, 255, 0.1); -` - -export default ChatRoom diff --git a/frontend/src/organism/FilePreview/FilePreview.js b/frontend/src/organism/FilePreview/FilePreview.js deleted file mode 100644 index 2292c62f..00000000 --- a/frontend/src/organism/FilePreview/FilePreview.js +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 04d6acbd..00000000 --- a/frontend/src/organism/FilePreview/FilePreview.stories.js +++ /dev/null @@ -1,24 +0,0 @@ -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/FileUploader/FileUploader.js b/frontend/src/organism/FileUploader/FileUploader.js deleted file mode 100644 index 1a330e28..00000000 --- a/frontend/src/organism/FileUploader/FileUploader.js +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index b477fa6c..00000000 --- a/frontend/src/organism/FileUploader/FileUploader.stories.js +++ /dev/null @@ -1,10 +0,0 @@ -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/ImgPreview/ImgPreview.js b/frontend/src/organism/ImgPreview/ImgPreview.js deleted file mode 100644 index b479de4d..00000000 --- a/frontend/src/organism/ImgPreview/ImgPreview.js +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 9e5ea405..00000000 --- a/frontend/src/organism/ImgPreview/ImgPreview.stories.js +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 11607d6e..00000000 --- a/frontend/src/organism/ImgPreview/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ImgPreview' diff --git a/frontend/src/organism/index.js b/frontend/src/organism/index.js deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/organism/messageEditor/MessageEditor.js b/frontend/src/organism/messageEditor/MessageEditor.js deleted file mode 100644 index 6516a811..00000000 --- a/frontend/src/organism/messageEditor/MessageEditor.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useState } from 'react' - -import Input from '../../atom/Input' - -function MessageEditor({ channelTitle, sendMessage }) { - const [message, setMessage] = useState('') - const handleInput = e => { - setMessage(e.target.value) - } - const handleKey = e => { - if (e.key === 'Enter' && e.target.value) { - sendMessage(message) - setMessage('') - } - } - return ( -
- - {/* TODO markdown, chat action 적용 필요 */} -
- ) -} - -export default MessageEditor diff --git a/frontend/src/page/WorkspacePage/WorkspacePage.js b/frontend/src/page/WorkspacePage/WorkspacePage.js index a77f8e0d..a04ea402 100644 --- a/frontend/src/page/WorkspacePage/WorkspacePage.js +++ b/frontend/src/page/WorkspacePage/WorkspacePage.js @@ -1,25 +1,33 @@ -import React, { useState } from 'react' -import styled from 'styled-components' -import { useParams } from 'react-router-dom' +import React, { useState, useEffect } from 'react' +import styled, { css } from 'styled-components' +import { useParams, Route } from 'react-router-dom' import { useRecoilValue } from 'recoil' import { modalRecoil } from '../../store' import { throttle } from '../../util' -import ChannelList from '../../organism/ChannelList' -import ChannelListHeader from '../../atom/ChannelListHeader' -import ChatRoom from '../../organism/ChatRoom' +import ChannelList from '../../container/ChannelList' +import ChannelListHeader from '../../presenter/ChannelListHeader' +import ChatRoom from '../../container/ChatRoom' +import SideThreadBar from '../../container/SideThreadBar' import { COLOR } from '../../constant/style' -import Icon from '../../atom/Icon' +import Icon from '../../presenter/Icon' import { TOOLS } from '../../constant/icon' import useWorkspace from '../../hooks/useWorkspace' import useSocket from '../../hooks/useSocket' function WorkspacePage() { - const { channelId } = useParams() + const { channelId, chatId } = useParams() const [lineWidth, setLineWidth] = useState(20) + const [sideBarWidth, setSideBarWidth] = useState(30) const modal = useRecoilValue(modalRecoil) - useWorkspace() + const [workspaceUserInfo] = useWorkspace() useSocket() + + useEffect(() => { + if (chatId !== undefined) setSideBarWidth(30) + else setSideBarWidth(0) + }, [chatId]) + const moveLine = e => { if (e.pageX === 0) return false let mouse = e.pageX @@ -32,21 +40,25 @@ function WorkspacePage() { setLineWidth(width) } } - + useEffect(() => { + if (Notification.permission !== 'denied') { + Notification.requestPermission() + } + }, []) const switching = () => { switch (channelId) { case 'threads': - return ConstructionPage() + return ConstructionPage(100 - sideBarWidth) case 'all-dms': - return ConstructionPage() + return ConstructionPage(100 - sideBarWidth) case 'saved-page': - return ConstructionPage() + return ConstructionPage(100 - sideBarWidth) case 'activity-page': - return ConstructionPage() + return ConstructionPage(100 - sideBarWidth) case 'more': - return ConstructionPage() + return ConstructionPage(100 - sideBarWidth) default: - return + return } } @@ -57,28 +69,29 @@ function WorkspacePage() { - + - throttle(moveLine(e), 100)} /> + {switching()} - - - - + + ) } -const ConstructionPage = () => { +const ConstructionPage = SideBarWidth => { return ( - +

@@ -88,8 +101,7 @@ const ConstructionPage = () => { } const PageStyle = styled.div` - width: 100vw; - height: 100vh; + height: 100%; display: flex; flex-direction: column; ` @@ -120,6 +132,7 @@ const ChannelListHeaderArea = styled.div` background: ${COLOR.BACKGROUND_CHANNEL_LIST}; color: ${COLOR.LABEL_DEFAULT_TEXT}; border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; ` const ChannelListArea = styled.div` @@ -168,7 +181,7 @@ const ContentsArea = styled.div` ` const SwitchContentsArea = styled.div` height: 100%; - width: 70%; + width: ${props => props.width}%; font-size: 20px; color: ${COLOR.LABEL_DEFAULT_TEXT}; display: flex; @@ -179,27 +192,4 @@ const SwitchContentsArea = styled.div` background: ${COLOR.BACKGROUND_CONTENTS}; ` -const SideBarArea = styled.div` - display: flex; - flex-direction: column; - width: 30%; - background: ${COLOR.BACKGROUND_CONTENTS}; - border: 1px solid rgba(255, 255, 255, 0.1); -` - -const SideBarHeader = styled.div` - display: flex; - width: 100%; - height: 60px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-right: 0; -` - -const SideBarContents = styled.div` - width: 100%; - height: calc(100% - 60px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-right: 0; -` - export default WorkspacePage diff --git a/frontend/src/page/createWorkspace/CreateWorkspace.js b/frontend/src/page/createWorkspace/CreateWorkspace.js index 17ddaf4b..ff1ad332 100644 --- a/frontend/src/page/createWorkspace/CreateWorkspace.js +++ b/frontend/src/page/createWorkspace/CreateWorkspace.js @@ -1,7 +1,7 @@ import React, { useState } from 'react' import styled from 'styled-components' -import CreateWorkspacecName from '../../organism/CreateWorkspaceName' -import CreateWorkspaceInitChannel from '../../organism/CreateWorkspaceInitChannel' +import CreateWorkspacecName from '../../container/CreateWorkspaceName' +import CreateWorkspaceInitChannel from '../../container/CreateWorkspaceInitChannel' const CreateWorkspace = () => { const [workspaceName, setWorkspaceName] = useState('') diff --git a/frontend/src/page/login/Login.js b/frontend/src/page/login/Login.js index 6861aeb5..ff7b8af7 100644 --- a/frontend/src/page/login/Login.js +++ b/frontend/src/page/login/Login.js @@ -1,19 +1,29 @@ -import React from 'react' +import React, { useEffect } from 'react' import { useHistory } from 'react-router-dom' -import LoginButton from '../../atom/LoginButton/LoginButton' +import LoginButton from '../../presenter/LoginButton/LoginButton' import { GITHUB } from '../../constant/icon' -import Icon from '../../atom/Icon' +import Icon from '../../presenter/Icon' import styled from 'styled-components' -import SlackIcon from '../../atom/SlackImage' +import SlackIcon from '../../presenter/SlackImage' +import { isEmpty } from '../../util' +import QueryString from 'qs' const baseURL = process.env.NODE_ENV === 'development' ? process.env.REACT_APP_DEV_API_URL : process.env.REACT_APP_API_URL -const LoginPage = () => { +const LoginPage = props => { const history = useHistory() const githubIcon = + const query = QueryString.parse(props.location.search, { + ignoreQueryPrefix: true, + }) + useEffect(() => { + if (!isEmpty(query.invitecode)) { + localStorage.setItem('invitecode', query.invitecode) + } + }, []) const gohomeHandle = () => { history.push('/') diff --git a/frontend/src/page/selectWorkspace/SelectWorkspace.js b/frontend/src/page/selectWorkspace/SelectWorkspace.js index bd0ab7fe..53771a42 100644 --- a/frontend/src/page/selectWorkspace/SelectWorkspace.js +++ b/frontend/src/page/selectWorkspace/SelectWorkspace.js @@ -1,7 +1,7 @@ import React from 'react' import styled from 'styled-components' -import NewWorkspaceSection from '../../organism/NewWorkspaceSection' -import MyWorkspaceSection from '../../organism/MyWorkspaceSection' +import NewWorkspaceSection from '../../presenter/NewWorkspaceSection' +import MyWorkspaceSection from '../../container/MyWorkspaceSection' const SelectWorkspace = () => { return ( diff --git a/frontend/src/atom/Button/AddButton/AddButton.js b/frontend/src/presenter/Button/AddButton/AddButton.js similarity index 100% rename from frontend/src/atom/Button/AddButton/AddButton.js rename to frontend/src/presenter/Button/AddButton/AddButton.js diff --git a/frontend/src/atom/Button/AddButton/Addbutton.stories.js b/frontend/src/presenter/Button/AddButton/Addbutton.stories.js similarity index 100% rename from frontend/src/atom/Button/AddButton/Addbutton.stories.js rename to frontend/src/presenter/Button/AddButton/Addbutton.stories.js diff --git a/frontend/src/atom/Button/AddButton/index.js b/frontend/src/presenter/Button/AddButton/index.js similarity index 100% rename from frontend/src/atom/Button/AddButton/index.js rename to frontend/src/presenter/Button/AddButton/index.js diff --git a/frontend/src/atom/Button/Button.js b/frontend/src/presenter/Button/Button.js similarity index 95% rename from frontend/src/atom/Button/Button.js rename to frontend/src/presenter/Button/Button.js index cb404bfb..487289e9 100644 --- a/frontend/src/atom/Button/Button.js +++ b/frontend/src/presenter/Button/Button.js @@ -31,6 +31,7 @@ const StyledButton = styled.button` color: ${({ type, disabled }) => { if (disabled) return COLOR.GRAY if (type === 'transparent') return COLOR.GRAY + if (type === 'leave') return COLOR.WHITE return COLOR.WHITE }}; cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; @@ -38,6 +39,7 @@ const StyledButton = styled.button` if (disabled) return COLOR.LIGHT_GRAY if (type === 'transparent') return 'transparent' if (type === 'icon') return 'transparent' + if (type === 'leave') return '#D91D57' return COLOR.GREEN }}; border: ${({ type }) => { diff --git a/frontend/src/atom/Button/Button.stories.js b/frontend/src/presenter/Button/Button.stories.js similarity index 100% rename from frontend/src/atom/Button/Button.stories.js rename to frontend/src/presenter/Button/Button.stories.js diff --git a/frontend/src/atom/Button/ToggleButton/ToggleButton.js b/frontend/src/presenter/Button/ToggleButton/ToggleButton.js similarity index 100% rename from frontend/src/atom/Button/ToggleButton/ToggleButton.js rename to frontend/src/presenter/Button/ToggleButton/ToggleButton.js diff --git a/frontend/src/atom/Button/ToggleButton/ToggleButton.stories.js b/frontend/src/presenter/Button/ToggleButton/ToggleButton.stories.js similarity index 100% rename from frontend/src/atom/Button/ToggleButton/ToggleButton.stories.js rename to frontend/src/presenter/Button/ToggleButton/ToggleButton.stories.js diff --git a/frontend/src/atom/Button/ToggleButton/index.js b/frontend/src/presenter/Button/ToggleButton/index.js similarity index 100% rename from frontend/src/atom/Button/ToggleButton/index.js rename to frontend/src/presenter/Button/ToggleButton/index.js diff --git a/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.js b/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.js new file mode 100644 index 00000000..002baa0e --- /dev/null +++ b/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.js @@ -0,0 +1,45 @@ +import React, { memo } from 'react' +import styled from 'styled-components' +import UserProfileImg from '../../UserProfileImg' +import calculateTime from '../../../util/calculateTime' +import { go, Lazy, take, map } from '../../../util/fx' +import { COLOR } from '../../../constant/style' +const MAX_NUMBER_OF_PROFILES = 5 +const SMALL_SIZE = 24 +const ViewThreadButton = memo(({ reply = [] }) => { + const [lastReply] = reply.slice(-1) + return ( + + {go( + reply, + Lazy.map(item => item?.userInfo?.profileUrl), + Lazy.takeNoneDuplicate, + take(MAX_NUMBER_OF_PROFILES), + map((item, index) => ( + + )), + )} + + {reply.length} {reply.length === 1 ? 'reply ' : 'replies '} + + {calculateTime(lastReply?.createdAt)} + + + + ) +}) + +const ViewThreadContainer = styled.div` + display: flex; +` +const ReplyCounts = styled.div` + margin-left: 5px; +` +const LastModifiedTime = styled.span` + color: ${COLOR.GRAY}; +` +export default ViewThreadButton diff --git a/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.stories.js b/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.stories.js new file mode 100644 index 00000000..5100dd36 --- /dev/null +++ b/frontend/src/presenter/Button/ViewThreadButton/ViewThreadButton.stories.js @@ -0,0 +1,29 @@ +import React from 'react' +import ViewThreadButton from './ViewThreadButton' + +export default { + title: 'Presenter/Button/ViewThreadButton', + component: ViewThreadButton, +} + +const Template = args => + +export const Default = Template.bind({}) +Default.args = { + reply: [ + { + createdAt: '2020-12-15T04:08:39.960Z', + creator: '5fcf09b2bc3a524ae4db6035', + isDelete: false, + updatedAt: '2020-12-15T04:08:39.960Z', + userInfo: { + _id: '5fcf09b2bc3a524ae4db6035', + displayName: 'solo95', + profileUrl: + 'https://avatars0.githubusercontent.com/u/26921508?…00&u=3bd5e08ac086212ee3219297d4e02e10a5e275a5&v=4', + }, + __v: 0, + _id: '5fd836c752de571e30405e09', + }, + ], +} diff --git a/frontend/src/presenter/Button/ViewThreadButton/index.js b/frontend/src/presenter/Button/ViewThreadButton/index.js new file mode 100644 index 00000000..daef7cb0 --- /dev/null +++ b/frontend/src/presenter/Button/ViewThreadButton/index.js @@ -0,0 +1 @@ +export { default } from './ViewThreadButton' diff --git a/frontend/src/atom/Button/index.js b/frontend/src/presenter/Button/index.js similarity index 100% rename from frontend/src/atom/Button/index.js rename to frontend/src/presenter/Button/index.js diff --git a/frontend/src/presenter/ChannelBrowserCard/ChannelBrowserCard.js b/frontend/src/presenter/ChannelBrowserCard/ChannelBrowserCard.js new file mode 100644 index 00000000..218eb3f2 --- /dev/null +++ b/frontend/src/presenter/ChannelBrowserCard/ChannelBrowserCard.js @@ -0,0 +1,83 @@ +import React from 'react' +import styled from 'styled-components' +import Button from '../../presenter/Button' +import { useHistory, useParams } from 'react-router' +import { leaveChannel, joinChannel } from '../../api/channel' +import useChannelList from '../../hooks/useChannelList' +import useWorkspace from '../../hooks/useWorkspace' + +function ChannelBrowserCard({ + _id, + title, + joined, + channelType, + handleClose, + workspaceUserInfoId, +}) { + const { workspaceId, channelId } = useParams() + const [channelList, setChannels] = useChannelList() + const [workspaceUserInfo] = useWorkspace() + const history = useHistory() + + const defaultChannel = workspaceUserInfo.workspaceInfo.default_channel + + const clickEvent = async () => { + if (joined) { + await leaveChannel({ + workspaceUserInfoId, + channelId: _id, + }) + if (channelId === _id) { + history.push(`/workspace/${workspaceId}/${defaultChannel}`) + } + } else { + await joinChannel({ + workspaceUserInfoId, + channelId: _id, + }) + } + + setChannels() + handleClose() + } + + return ( + + {title} + + - ) -} - -Button.propTypes = { - /** - * Is this the principal call to action on the page? - */ - primary: PropTypes.bool, - /** - * What background color to use - */ - backgroundColor: PropTypes.string, - /** - * How large should the button be? - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * Button contents - */ - label: PropTypes.string.isRequired, - /** - * Optional click handler - */ - onClick: PropTypes.func, -} - -Button.defaultProps = { - backgroundColor: null, - primary: false, - size: 'medium', - onClick: undefined, -} diff --git a/frontend/src/stories/Button.stories.js b/frontend/src/stories/Button.stories.js deleted file mode 100644 index 3d78ceda..00000000 --- a/frontend/src/stories/Button.stories.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react' - -import { Button } from './Button' - -export default { - title: 'Example/Button', - component: Button, - argTypes: { - backgroundColor: { control: 'color' }, - }, -} - -const Template = args =>
- - -) - -Header.propTypes = { - user: PropTypes.shape({}), - onLogin: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, - onCreateAccount: PropTypes.func.isRequired, -} - -Header.defaultProps = { - user: null, -} diff --git a/frontend/src/stories/Header.stories.js b/frontend/src/stories/Header.stories.js deleted file mode 100644 index a709af05..00000000 --- a/frontend/src/stories/Header.stories.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' - -import { Header } from './Header' - -export default { - title: 'Example/Header', - component: Header, -} - -const Template = args =>
- -export const LoggedIn = Template.bind({}) -LoggedIn.args = { - user: {}, -} - -export const LoggedOut = Template.bind({}) -LoggedOut.args = {} diff --git a/frontend/src/stories/Introduction.stories.mdx b/frontend/src/stories/Introduction.stories.mdx deleted file mode 100644 index 0a5d450a..00000000 --- a/frontend/src/stories/Introduction.stories.mdx +++ /dev/null @@ -1,207 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; -import Code from './assets/code-brackets.svg'; -import Colors from './assets/colors.svg'; -import Comments from './assets/comments.svg'; -import Direction from './assets/direction.svg'; -import Flow from './assets/flow.svg'; -import Plugin from './assets/plugin.svg'; -import Repo from './assets/repo.svg'; -import StackAlt from './assets/stackalt.svg'; - - - - - -# Welcome to Storybook - -Storybook helps you build UI components in isolation from your app's business logic, data, and context. -That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA. - -Browse example stories now by navigating to them in the sidebar. -View their code in the `src/storybook-examples` directory to learn how they work. -We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages. - -
Configure
- - - -
Learn
- - - -
- TipEdit the Markdown in{' '} - src/storybook-examples/welcome.mdx -
diff --git a/frontend/src/stories/Page.js b/frontend/src/stories/Page.js deleted file mode 100644 index daada1df..00000000 --- a/frontend/src/stories/Page.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { Header } from './Header' -import './page.css' - -export const Page = ({ user, onLogin, onLogout, onCreateAccount }) => ( -
-
- -
-

Pages in Storybook

-

- We recommend building UIs with a{' '} - - component-driven - {' '} - process starting with atomic components and ending with pages. -

-

- Render pages with mock data. This makes it easy to build and review page - states without needing to navigate to them in your app. Here are some - handy patterns for managing page data in Storybook: -

-
    -
  • - Use a higher-level connected component. Storybook helps you compose - such data from the "args" of child component stories -
  • -
  • - Assemble data in the page component from your services. You can mock - these services out using Storybook. -
  • -
-

- Get a guided tutorial on component-driven development at{' '} - - Learn Storybook - - . Read more in the{' '} - - docs - - . -

-
- Tip Adjust the width of the canvas with the{' '} - - - - - - Viewports addon in the toolbar -
-
-
-) -Page.propTypes = { - user: PropTypes.shape({}), - onLogin: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, - onCreateAccount: PropTypes.func.isRequired, -} - -Page.defaultProps = { - user: null, -} diff --git a/frontend/src/stories/Page.stories.js b/frontend/src/stories/Page.stories.js deleted file mode 100644 index a50a73c9..00000000 --- a/frontend/src/stories/Page.stories.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' - -import { Page } from './Page' -import * as HeaderStories from './Header.stories' - -export default { - title: 'Example/Page', - component: Page, -} - -const Template = args => - -export const LoggedIn = Template.bind({}) -LoggedIn.args = { - ...HeaderStories.LoggedIn.args, -} - -export const LoggedOut = Template.bind({}) -LoggedOut.args = { - ...HeaderStories.LoggedOut.args, -} diff --git a/frontend/src/stories/assets/code-brackets.svg b/frontend/src/stories/assets/code-brackets.svg deleted file mode 100644 index 73de9477..00000000 --- a/frontend/src/stories/assets/code-brackets.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/code-brackets \ No newline at end of file diff --git a/frontend/src/stories/assets/colors.svg b/frontend/src/stories/assets/colors.svg deleted file mode 100644 index 17d58d51..00000000 --- a/frontend/src/stories/assets/colors.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/colors \ No newline at end of file diff --git a/frontend/src/stories/assets/comments.svg b/frontend/src/stories/assets/comments.svg deleted file mode 100644 index 6493a139..00000000 --- a/frontend/src/stories/assets/comments.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/comments \ No newline at end of file diff --git a/frontend/src/stories/assets/direction.svg b/frontend/src/stories/assets/direction.svg deleted file mode 100644 index 65676ac2..00000000 --- a/frontend/src/stories/assets/direction.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/direction \ No newline at end of file diff --git a/frontend/src/stories/assets/flow.svg b/frontend/src/stories/assets/flow.svg deleted file mode 100644 index 8ac27db4..00000000 --- a/frontend/src/stories/assets/flow.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/flow \ No newline at end of file diff --git a/frontend/src/stories/assets/plugin.svg b/frontend/src/stories/assets/plugin.svg deleted file mode 100644 index 29e5c690..00000000 --- a/frontend/src/stories/assets/plugin.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/plugin \ No newline at end of file diff --git a/frontend/src/stories/assets/repo.svg b/frontend/src/stories/assets/repo.svg deleted file mode 100644 index f386ee90..00000000 --- a/frontend/src/stories/assets/repo.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/repo \ No newline at end of file diff --git a/frontend/src/stories/assets/stackalt.svg b/frontend/src/stories/assets/stackalt.svg deleted file mode 100644 index 9b7ad274..00000000 --- a/frontend/src/stories/assets/stackalt.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/stackalt \ No newline at end of file diff --git a/frontend/src/stories/button.css b/frontend/src/stories/button.css deleted file mode 100644 index dc91dc76..00000000 --- a/frontend/src/stories/button.css +++ /dev/null @@ -1,30 +0,0 @@ -.storybook-button { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-weight: 700; - border: 0; - border-radius: 3em; - cursor: pointer; - display: inline-block; - line-height: 1; -} -.storybook-button--primary { - color: white; - background-color: #1ea7fd; -} -.storybook-button--secondary { - color: #333; - background-color: transparent; - box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; -} -.storybook-button--small { - font-size: 12px; - padding: 10px 16px; -} -.storybook-button--medium { - font-size: 14px; - padding: 11px 20px; -} -.storybook-button--large { - font-size: 16px; - padding: 12px 24px; -} diff --git a/frontend/src/stories/header.css b/frontend/src/stories/header.css deleted file mode 100644 index acadc9ec..00000000 --- a/frontend/src/stories/header.css +++ /dev/null @@ -1,26 +0,0 @@ -.wrapper { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - padding: 15px 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -svg { - display: inline-block; - vertical-align: top; -} - -h1 { - font-weight: 900; - font-size: 20px; - line-height: 1; - margin: 6px 0 6px 10px; - display: inline-block; - vertical-align: top; -} - -button + button { - margin-left: 10px; -} diff --git a/frontend/src/stories/page.css b/frontend/src/stories/page.css deleted file mode 100644 index 51c9d099..00000000 --- a/frontend/src/stories/page.css +++ /dev/null @@ -1,69 +0,0 @@ -section { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 24px; - padding: 48px 20px; - margin: 0 auto; - max-width: 600px; - color: #333; -} - -h2 { - font-weight: 900; - font-size: 32px; - line-height: 1; - margin: 0 0 4px; - display: inline-block; - vertical-align: top; -} - -p { - margin: 1em 0; -} - -a { - text-decoration: none; - color: #1ea7fd; -} - -ul { - padding-left: 30px; - margin: 1em 0; -} - -li { - margin-bottom: 8px; -} - -.tip { - display: inline-block; - border-radius: 1em; - font-size: 11px; - line-height: 12px; - font-weight: 700; - background: #e7fdd8; - color: #66bf3c; - padding: 4px 12px; - margin-right: 10px; - vertical-align: top; -} - -.tip-wrapper { - font-size: 13px; - line-height: 20px; - margin-top: 40px; - margin-bottom: 40px; -} - -.tip-wrapper svg { - display: inline-block; - height: 12px; - width: 12px; - margin-right: 4px; - vertical-align: top; - margin-top: 3px; -} - -.tip-wrapper svg path { - fill: #1ea7fd; -} diff --git a/frontend/src/util/calculateTime.js b/frontend/src/util/calculateTime.js index 4a3e2808..ad162d6e 100644 --- a/frontend/src/util/calculateTime.js +++ b/frontend/src/util/calculateTime.js @@ -4,7 +4,7 @@ export default function calculateTime(time) { let diff = (now - timeData) / 1000 if (diff < 60) { - return Math.floor(diff) + ' < 1 minute ago' + return ' < 1 minute ago' } else if ((diff /= 60) < 60) { return singularOrPlural(diff, 'minute') } else if ((diff /= 60) < 24) { diff --git a/frontend/src/util/fx.js b/frontend/src/util/fx.js new file mode 100644 index 00000000..9251d4b5 --- /dev/null +++ b/frontend/src/util/fx.js @@ -0,0 +1,55 @@ +export const curry = action => (value, ...iterator) => + iterator.length + ? action(value, ...iterator) + : (...iterator) => action(value, ...iterator) + +export const Lazy = {} + +Lazy.map = curry(function* (action, iterator) { + for (const value of iterator) yield action(value) +}) + +Lazy.range = curry(function* (max) { + let i = 0 + while (i < max) yield i++ +}) + +Lazy.filter = curry(function* (action, iterator) { + for (const value of iterator) if (action(value)) yield value +}) + +Lazy.takeNoneDuplicate = function* (iterator) { + let res = [] + for (const value of iterator) { + if (!res.includes(value)) { + res.push(value) + yield value + } + } +} + +export const reduce = curry((action, acc, iterator) => { + if (!iterator) { + iterator = acc[Symbol.iterator]() + acc = iterator.next().value + } + for (const value of iterator) acc = action(acc, value) + return acc +}) + +export const take = curry((length, iterator) => { + let res = [] + iterator = iterator[Symbol.iterator]() + for (const value of iterator) { + res.push(value) + if (res.length === length) return res + } + return res +}) + +export const go = (...iterator) => + reduce((value, action) => action(value), iterator) + +export const map = curry((action, iterator) => { + return iterator.map(action) +}) diff --git a/frontend/src/util/index.js b/frontend/src/util/index.js index 26bc6f26..7618930e 100644 --- a/frontend/src/util/index.js +++ b/frontend/src/util/index.js @@ -9,15 +9,15 @@ const debounce = (callback, wait) => { function throttle(func, delay) { let lastFunc let lastRan - return function (...args) { + return function (event) { if (!lastRan) { - func() + func(event) lastRan = Date.now() } else { if (lastFunc) clearTimeout(lastFunc) lastFunc = setTimeout(function () { if (Date.now() - lastRan >= delay) { - func() + func(event) lastRan = Date.now() } }, delay - (Date.now() - lastRan)) @@ -33,4 +33,8 @@ const isEmpty = value => { return false } -export { debounce, throttle, isEmpty } +const isImage = type => { + return type?.includes('image/') +} + +export { debounce, throttle, isEmpty, isImage } diff --git a/frontend/src/util/reactionUpdate.js b/frontend/src/util/reactionUpdate.js new file mode 100644 index 00000000..8cf16b2c --- /dev/null +++ b/frontend/src/util/reactionUpdate.js @@ -0,0 +1,62 @@ +import produce from 'immer' + +export const chageReactionState = (messages, reaction) => + produce(messages, draft => { + let done = false + if (reaction.type === false) return + + draft.forEach(chat => { + if (chat._id === reaction.chatId) { + chat.reactions.forEach((element, index) => { + if (element.emoji === reaction.emoji) { + if (reaction.type) { + element.users.push({ + _id: reaction.workspaceUserInfoId, + displayName: reaction.displayName, + }) + element.set = true + } else { + element.users.forEach((user, idx) => { + if (user._id === reaction.workspaceUserInfoId) { + element.users.splice(idx, 1) + element.set = false + } + }) + if (element.users.length === 0) { + chat.reactions.splice(index, 1) + } + } + done = true + } + }) + if (!done && reaction.type === 1) { + chat.reactions.push({ + emoji: reaction.emoji, + users: [ + { + _id: reaction.workspaceUserInfoId, + displayName: reaction.displayName, + }, + ], + set: true, + }) + } + } + }) + }) + +export const hasMyReaction = (messages, workspaceUserInfo) => + produce(messages, draft => { + draft?.forEach(message => { + if (message?.reactions?.length === 0) return + message.reactions.forEach(reaction => { + reaction.set = false + if (reaction.users.length === 0) return + + const result = reaction.users.every(user => { + return user?._id !== workspaceUserInfo?._id + }) + if (!result) reaction.set = true + }) + }) + })