diff --git a/src/actions/index.js b/src/actions/index.js index 7ddd0a3..2202844 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -584,7 +584,7 @@ export function fetchMe(): PromiseAction { function receiveChatHistory( cid: number, history: Array, -) { +): Action { return { type: 'RECEIVE_CHAT_HISTORY', cid, @@ -748,6 +748,48 @@ export function setChatChannel(channelId: number): Action { }; } +export function addChatChannel(channel: Array): Action { + return { + type: 'ADD_CHAT_CHANNEL', + channel, + }; +} + +export function startDm(query): PromiseAction { + return async (dispatch) => { + const response = await fetch('api/startdm', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(query), + }); + + try { + const res = await response.json(); + if (res.errors) { + dispatch(sweetAlert( + 'Direct Message Error', + res.errors[0], + 'error', + 'OK', + )); + } + if (response.ok) { + const { channel } = res; + const channelId = channel[0]; + if (channelId) { + await dispatch(addChatChannel(channel)); + dispatch(setChatChannel(channelId)); + } + } + } catch { + + } + }; +} + export function hideModal(): Action { return { type: 'HIDE_MODAL', diff --git a/src/actions/types.js b/src/actions/types.js index 5b0f6c4..5a086d7 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -70,6 +70,7 @@ export type Action = } | { type: 'RECEIVE_CHAT_HISTORY', cid: number, history: Array } | { type: 'SET_CHAT_CHANNEL', channelId: number } + | { type: 'ADD_CHAT_CHANNEL', channel: Array } | { type: 'SET_CHAT_FETCHING', fetching: boolean } | { type: 'SET_CHAT_INPUT_MSG', message: string } | { type: 'ADD_CHAT_INPUT_MSG', message: string } diff --git a/src/components/ChannelDropDown.jsx b/src/components/ChannelDropDown.jsx index b92ba09..7ffe68c 100644 --- a/src/components/ChannelDropDown.jsx +++ b/src/components/ChannelDropDown.jsx @@ -5,9 +5,11 @@ */ import React, { - useRef, useLayoutEffect, useState, useEffect, + useRef, useState, useEffect, useCallback, } from 'react'; import { connect } from 'react-redux'; +import { MdChat } from 'react-icons/md'; +import { FaUserFriends } from 'react-icons/fa'; import type { State } from '../reducers'; import { @@ -20,55 +22,108 @@ const ChannelDropDown = ({ setChannel, }) => { const [show, setShow] = useState(false); + // 0: global and faction channels + // 1: DMs + const [type, setType] = useState(0); + const [offset, setOffset] = useState(0); + const [chatChannelName, setChatChannelName] = useState('...'); const wrapperRef = useRef(null); + const buttonRef = useRef(null); useEffect(() => { - function handleClickOutside(event) { - if (wrapperRef.current - && !wrapperRef.current.contains(event.target) - ) { - setShow(false); + setOffset(buttonRef.current.clientHeight); + }, [buttonRef]); + + const handleClickOutside = useCallback((event) => { + if (wrapperRef.current + && !wrapperRef.current.contains(event.target) + && !buttonRef.current.contains(event.target) + ) { + setShow(false); + } + }, []); + + useEffect(() => { + if (show) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + } + }, [show]); + + useEffect(() => { + for (let i = 0; i < channels.length; i += 1) { + if (channels[i][0] === chatChannel) { + setChatChannelName(channels[i][1]); } } - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); - }; - }, [wrapperRef]); + }, [chatChannel, channels]); return (
setShow(true)} + onClick={() => setShow(!show)} className="channelbtn" > - {chatChannel} + {chatChannelName}
- { - channels.map((ch) => ( -
- {ch[1]} -
- )) - } +
+ setType(0)} + > + + + | + setType(1)} + > + + +
+
+ { + channels.filter((ch) => { + const chType = ch[2]; + if (type === 1 && chType === 1) { + return true; + } + if (type === 0 && chType !== 1) { + return true; + } + return false; + }).map((ch) => ( +
setChannel(ch[0])} + style={(ch[0] === chatChannel) ? { + fontWeight: 'bold', + fontSize: 17, + } : null} + > + {ch[1]} +
+ )) + } +
); diff --git a/src/components/UserContextMenu.jsx b/src/components/UserContextMenu.jsx index 367b314..4e581a6 100644 --- a/src/components/UserContextMenu.jsx +++ b/src/components/UserContextMenu.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import { hideContextMenu, addToChatInputMessage, - setChatInputMessage, + startDm, } from '../actions'; import type { State } from '../reducers'; @@ -21,24 +21,24 @@ const UserContextMenu = ({ yPos, uid, name, - setInput, addToInput, + dm, close, }) => { const wrapperRef = useRef(null); + useEffect(() => { - function handleClickOutside(event) { + const handleClickOutside = (event) => { if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { close(); } - } - + }; document.addEventListener('mousedown', handleClickOutside); document.addEventListener('touchstart', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); }; }, [wrapperRef]); @@ -60,7 +60,8 @@ const UserContextMenu = ({ role="button" tabIndex={0} onClick={() => { - setInput('loool'); + dm(uid); + close(); }} style={{ borderBottom: 'thin solid' }} > @@ -108,8 +109,8 @@ function mapDispatchToProps(dispatch) { input.select(); } }, - setInput(text) { - dispatch(setChatInputMessage(text)); + dm(userId) { + dispatch(startDm({ userId })); }, close() { dispatch(hideContextMenu()); diff --git a/src/core/ChatProvider.js b/src/core/ChatProvider.js index c0eb7aa..b3d4337 100644 --- a/src/core/ChatProvider.js +++ b/src/core/ChatProvider.js @@ -1,6 +1,4 @@ /* @flow */ -import Sequelize from 'sequelize'; - import logger from './logger'; import redis from '../data/redis'; import User from '../data/models/User'; @@ -58,16 +56,16 @@ export class ChatProvider { const channel = await Channel.findOrCreate({ attributes: [ 'id', + 'type', 'lastMessage', ], where: { name }, defaults: { name, - lastMessage: Sequelize.literal('CURRENT_TIMESTAMP'), }, raw: true, }); - const { id } = channel[0]; + const { id, type, lastMessage } = channel[0]; if (name === 'int') { this.intChannelId = id; } @@ -77,6 +75,8 @@ export class ChatProvider { this.defaultChannels.push([ id, name, + type, + lastMessage, ]); this.defaultChannelIds.push(id); } diff --git a/src/core/event.js b/src/core/event.js index 36271df..0c2951b 100644 --- a/src/core/event.js +++ b/src/core/event.js @@ -156,7 +156,7 @@ class Event { } static broadcastChatMessage(message) { - if (chatProvier.enChannelId && chatProvider.eventUserId) { + if (chatProvider.enChannelId && chatProvider.eventUserId) { chatProvider.broadcastChatMessage( EVENT_USER_NAME, message, diff --git a/src/core/me.js b/src/core/me.js index fd86422..4f1dbf7 100644 --- a/src/core/me.js +++ b/src/core/me.js @@ -30,12 +30,11 @@ export default async function getMe(user) { delete userdata.mailVerified; delete userdata.mcVerified; + userdata.canvases = canvases; userdata.channels = [ ...chatProvider.defaultChannels, ...userdata.channels, ]; - userdata.canvases = canvases; - return userdata; } diff --git a/src/core/passport.js b/src/core/passport.js index 7466812..55444f7 100644 --- a/src/core/passport.js +++ b/src/core/passport.js @@ -25,6 +25,23 @@ import { getIPFromRequest } from '../utils/ip'; const include = [{ model: Channel, as: 'channel', + include: [{ + model: RegUser, + as: 'dmu1', + foreignKey: 'dmu1id', + attributes: [ + 'id', + 'name', + ], + }, { + model: RegUser, + as: 'dmu2', + foreignKey: 'dmu2id', + attributes: [ + 'id', + 'name', + ], + }], }, { model: RegUser, through: UserBlock, diff --git a/src/data/models/Channel.js b/src/data/models/Channel.js index 60cc610..e1104a0 100644 --- a/src/data/models/Channel.js +++ b/src/data/models/Channel.js @@ -7,6 +7,7 @@ */ import DataType from 'sequelize'; + import Model from '../sequelize'; import RegUser from './RegUser'; @@ -37,6 +38,7 @@ const Channel = Model.define('Channel', { lastMessage: { type: DataType.DATE, + defaultValue: DataType.literal('CURRENT_TIMESTAMP'), allowNull: false, }, }, { @@ -49,6 +51,7 @@ const Channel = Model.define('Channel', { * (associating it here allows us too * keep track of users leaving and joining DMs and ending up * in the same conversation) + * dmu1id < dmu2id */ Channel.belongsTo(RegUser, { as: 'dmu1', diff --git a/src/data/models/User.js b/src/data/models/User.js index bc25a45..6120295 100644 --- a/src/data/models/User.js +++ b/src/data/models/User.js @@ -14,7 +14,6 @@ import logger from '../../core/logger'; import Model from '../sequelize'; import RegUser from './RegUser'; import { getIPv6Subnet } from '../../utils/ip'; - import { ADMIN_IDS } from '../../core/config'; @@ -35,7 +34,6 @@ class User { this.wait = null; // following gets populated by passport this.regUser = null; - this.channelIds = []; } static async name2Id(name: string) { @@ -56,12 +54,28 @@ class User { setRegUser(reguser) { this.regUser = reguser; this.id = reguser.id; - for (let i = 0; i < reguser.channel.length; i += 1) { - this.channelIds.push(reguser.channel[i].id); - this.channels.push([ - reguser.channel[i].id, - reguser.channel[i].name, - ]); + if (reguser.channel) { + for (let i = 0; i < reguser.channel.length; i += 1) { + const { + id, + type, + lastMessage, + dmu1, + dmu2, + } = reguser.channel[i]; + // in DMs the name is the name of the other user + let { name } = reguser.channel[i]; + if (type === 1) { + name = (dmu1.id === this.id) ? dmu2.name : dmu1.name; + } + this.channelIds.push(id); + this.channels.push([ + id, + name, + type, + lastMessage, + ]); + } } } diff --git a/src/data/models/UserBlock.js b/src/data/models/UserBlock.js index 4783ccb..14c9571 100644 --- a/src/data/models/UserBlock.js +++ b/src/data/models/UserBlock.js @@ -14,4 +14,16 @@ const UserBlock = Model.define('UserBlock', { timestamps: false, }); +export async function isUserBlockedBy(userId, blockedById) { + const exists = await UserBlock.findOne({ + where: { + uid: userId, + buid: blockedById, + }, + raw: true, + attributes: ['uid'], + }); + return !!exists; +} + export default UserBlock; diff --git a/src/reducers/chat.js b/src/reducers/chat.js index a89864f..707eb57 100644 --- a/src/reducers/chat.js +++ b/src/reducers/chat.js @@ -33,6 +33,18 @@ export default function chat( }; } + case 'ADD_CHAT_CHANNEL': { + const { channel } = action; + const channelId = channel[0]; + const channels = state.channels + .filter((ch) => (ch[0] !== channelId)); + channels.push(channel); + return { + ...state, + channels, + }; + } + case 'SET_CHAT_FETCHING': { const { fetching } = action; return { diff --git a/src/routes/api/index.js b/src/routes/api/index.js index 08458f1..6b674ce 100644 --- a/src/routes/api/index.js +++ b/src/routes/api/index.js @@ -18,6 +18,7 @@ import auth from './auth'; import ranking from './ranking'; import history from './history'; import chatHistory from './chathistory'; +import startDm from './startdm'; const router = express.Router(); @@ -80,6 +81,8 @@ router.post('/mctp', mctp); router.get('/chathistory', chatHistory); +router.post('/startdm', startDm); + router.use('/auth', auth(passport)); export default router; diff --git a/src/routes/api/startdm.js b/src/routes/api/startdm.js new file mode 100644 index 0000000..d46f80c --- /dev/null +++ b/src/routes/api/startdm.js @@ -0,0 +1,131 @@ +/* + * + * starts a DM session + * + * @flow + */ + +import type { Request, Response } from 'express'; + +import logger from '../../core/logger'; +import { Channel, UserChannel, RegUser } from '../../data/models'; +import { isUserBlockedBy } from '../../data/models/UserBlock'; + +async function startDm(req: Request, res: Response) { + let userId = parseInt(req.body.userId, 10); + let { userName } = req.body; + const { user } = req; + + const errors = []; + const query = {}; + if (userId) { + if (userId && Number.isNaN(userId)) { + errors.push('Invalid userId'); + } + query.id = userId; + } + if (userName) { + query.name = userName; + } + if (!userName && !userId) { + errors.push('No userId or userName defined'); + } + if (!user || !user.regUser) { + errors.push('You are not logged in'); + } + if (user && userId && user.id === userId) { + errors.push('You can not start DM to yourself.'); + } + if (errors.length) { + res.status(400); + res.json({ + errors, + }); + return; + } + + const targetUser = await RegUser.findOne({ + where: query, + attributes: [ + 'id', + 'name', + ], + raw: true, + }); + if (!targetUser) { + res.status(401); + res.json({ + errors: ['Target user does not exist'], + }); + return; + } + userId = targetUser.id; + userName = targetUser.name; + + /* + * check if blocked + */ + if (await isUserBlockedBy(user.id, userId)) { + res.status(401); + res.json({ + errors: ['You are blocked by this user'], + }); + return; + } + + logger.info( + `Creating DM Channel between ${user.regUser.name} and ${userName}`, + ); + /* + * start DM session + */ + let dmu1id = null; + let dmu2id = null; + if (user.id > userId) { + dmu1id = userId; + dmu2id = user.id; + } else { + dmu1id = user.id; + dmu2id = userId; + } + + const channel = await Channel.findOrCreate({ + where: { + type: 1, + dmu1id, + dmu2id, + }, + raw: true, + }); + const ChannelId = channel[0].id; + const { lastMessage } = channel[0]; + + const promises = [ + UserChannel.findOrCreate({ + where: { + UserId: dmu1id, + ChannelId, + }, + raw: true, + }), + UserChannel.findOrCreate({ + where: { + UserId: dmu2id, + ChannelId, + }, + raw: true, + }), + ]; + await Promise.all(promises); + + res.json({ + channel: [ + ChannelId, + userName, + 1, + lastMessage, + ], + }); +} + +export default startDm; diff --git a/src/styles/default.css b/src/styles/default.css index 6f603c8..95967c8 100644 --- a/src/styles/default.css +++ b/src/styles/default.css @@ -167,6 +167,13 @@ tr:nth-child(even) { .channeldd { background-color: rgba(226, 226, 226); + width: 90px; +} + +.channeldds { + height: 120px; + overflow-y: scroll; + margin: 2px; } .actionbuttons, .coorbox, .onlinebox, .cooldownbox, #palettebox {