diff --git a/src/actions/index.js b/src/actions/index.js index 44a7657..7ddd0a3 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -199,6 +199,7 @@ export function receiveChatMessage( text: string, country: string, channel: number, + user: number, isPing: boolean, ): Action { return { @@ -207,6 +208,7 @@ export function receiveChatMessage( text, country, channel, + user, isPing, }; } @@ -705,6 +707,35 @@ export function showCanvasSelectionModal(): Action { return showModal('CANVAS_SELECTION'); } +export function showContextMenu( + menuType: string, + xPos: number, + yPos: number, + args: Object, +): Action { + return { + type: 'SHOW_CONTEXT_MENU', + menuType, + xPos, + yPos, + args, + }; +} + +export function setChatInputMessage(message: string): Action { + return { + type: 'SET_CHAT_INPUT_MSG', + message, + }; +} + +export function addToChatInputMessage(message: string): Action { + return { + type: 'ADD_CHAT_INPUT_MSG', + message, + }; +} + export function showChatModal(forceModal: boolean = false): Action { if (window.innerWidth > 604 && !forceModal) { return toggleChatBox(); } return showModal('CHAT'); @@ -723,6 +754,12 @@ export function hideModal(): Action { }; } +export function hideContextMenu(): Action { + return { + type: 'HIDE_CONTEXT_MENU', + }; +} + export function reloadUrl(): Action { return { type: 'RELOAD_URL', diff --git a/src/actions/types.js b/src/actions/types.js index 0add20d..5b0f6c4 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -65,11 +65,14 @@ export type Action = text: string, country: string, channel: number, + user: number, isPing: boolean, } | { type: 'RECEIVE_CHAT_HISTORY', cid: number, history: Array } | { type: 'SET_CHAT_CHANNEL', channelId: number } | { type: 'SET_CHAT_FETCHING', fetching: boolean } + | { type: 'SET_CHAT_INPUT_MSG', message: string } + | { type: 'ADD_CHAT_INPUT_MSG', message: string } | { type: 'RECEIVE_ME', name: string, waitSeconds: number, @@ -90,7 +93,14 @@ export type Action = | { type: 'SET_MAILREG', mailreg: boolean } | { type: 'REM_FROM_MESSAGES', message: string } | { type: 'SHOW_MODAL', modalType: string } + | { type: 'SHOW_CONTEXT_MENU', + menuType: string, + xPos: number, + yPos: number, + args: Object, + } | { type: 'HIDE_MODAL' } + | { type: 'HIDE_CONTEXT_MENU' } | { type: 'RELOAD_URL' } | { type: 'SET_HISTORICAL_TIME', date: string, time: string } | { type: 'ON_VIEW_FINISH_CHANGE' }; diff --git a/src/client.js b/src/client.js index 57d5a1b..7f6e5f3 100644 --- a/src/client.js +++ b/src/client.js @@ -52,9 +52,22 @@ function init() { ProtocolClient.on('setWsName', (name) => { nameRegExp = new RegExp(`(^|\\s+)(@${name})(\\s+|$)`, 'g'); }); - ProtocolClient.on('chatMessage', (name, text, country, channelId) => { + ProtocolClient.on('chatMessage', ( + name, + text, + country, + channelId, + userId, + ) => { const isPing = (nameRegExp && text.match(nameRegExp)); - store.dispatch(receiveChatMessage(name, text, country, channelId, isPing)); + store.dispatch(receiveChatMessage( + name, + text, + country, + channelId, + userId, + isPing, + )); }); ProtocolClient.on('changedMe', () => { store.dispatch(fetchMe()); diff --git a/src/components/Chat.jsx b/src/components/Chat.jsx index 83fb44a..995e9c0 100644 --- a/src/components/Chat.jsx +++ b/src/components/Chat.jsx @@ -17,6 +17,7 @@ import { showUserAreaModal, setChatChannel, fetchChatMessages, + setChatInputMessage, } from '../actions'; import ProtocolClient from '../socket/ProtocolClient'; import { saveSelection, restoreSelection } from '../utils/storeSelection'; @@ -32,13 +33,13 @@ const Chat = ({ chatChannel, ownName, open, + inputMessage, + setInputMessage, setChannel, fetchMessages, fetching, }) => { const listRef = useRef(); - const inputRef = useRef(); - const [inputMessage, setInputMessage] = useState(''); const [selection, setSelection] = useState(null); const [nameRegExp, setNameRegExp] = useState(null); @@ -56,13 +57,15 @@ const Chat = ({ stayScrolled(); }, [channelMessages.length]); + /* + * TODO this removes focus from chat box, fix this + * useEffect(() => { - // TODO this removes focus from chat box, fix this - return; if (channelMessages.length === MAX_CHAT_MESSAGES) { restoreSelection(selection); } }, [channelMessages]); + */ useEffect(() => { const regExp = (ownName) @@ -71,16 +74,6 @@ const Chat = ({ setNameRegExp(regExp); }, [ownName]); - function padToInputMessage(txt) { - const lastChar = inputMessage.substr(-1); - const pad = (lastChar && lastChar !== ' '); - let newMsg = inputMessage; - if (pad) newMsg += ' '; - newMsg += txt; - setInputMessage(newMsg); - inputRef.current.focus(); - } - function handleSubmit(e) { e.preventDefault(); const msg = inputMessage.trim(); @@ -123,7 +116,7 @@ const Chat = ({ name="info" msgArray={splitChatMessage('Start chatting here', nameRegExp)} country="xx" - insertText={(txt) => padToInputMessage(txt)} + uid={0} /> ) } @@ -133,7 +126,7 @@ const Chat = ({ name={message[0]} msgArray={splitChatMessage(message[1], nameRegExp)} country={message[2]} - insertText={(txt) => padToInputMessage(txt)} + uid={message[3]} /> )) } @@ -148,7 +141,7 @@ const Chat = ({ style={{ flexGrow: 1, minWidth: 40 }} value={inputMessage} onChange={(e) => setInputMessage(e.target.value)} - ref={inputRef} + id="chatmsginput" maxLength="200" type="text" placeholder="Chat here" @@ -198,11 +191,17 @@ const Chat = ({ function mapStateToProps(state: State) { const { name } = state.user; const { chatChannel } = state.gui; - const { channels, messages, fetching } = state.chat; + const { + channels, + messages, + fetching, + inputMessage, + } = state.chat; return { channels, messages, fetching, + inputMessage, chatChannel, ownName: name, }; @@ -219,6 +218,9 @@ function mapDispatchToProps(dispatch) { fetchMessages(channelId) { dispatch(fetchChatMessages(channelId)); }, + setInputMessage(message) { + dispatch(setChatInputMessage(message)); + }, }; } diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx index ce153ea..d1e1cff 100644 --- a/src/components/ChatMessage.jsx +++ b/src/components/ChatMessage.jsx @@ -5,14 +5,16 @@ import React from 'react'; import { connect } from 'react-redux'; +import { showContextMenu } from '../actions'; import { colorFromText, setBrightness } from '../core/utils'; function ChatMessage({ name, - msgArray, + uid, country, - insertText, + msgArray, + openUserContextMenu, darkMode, }) { if (!name || !msgArray) { @@ -54,8 +56,17 @@ function ChatMessage({ }} role="button" tabIndex={-1} - onClick={() => { - insertText(`@${name} `); + onClick={(event) => { + const { + clientX, + clientY, + } = event; + openUserContextMenu( + clientX, + clientY, + uid, + name, + ); }} > {name} @@ -103,4 +114,15 @@ function mapStateToProps(state: State) { return { darkMode }; } -export default connect(mapStateToProps)(ChatMessage); +function mapDispatchToProps(dispatch) { + return { + openUserContextMenu(xPos, yPos, uid, name) { + dispatch(showContextMenu('USER', xPos, yPos, { + uid, + name, + })); + }, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ChatMessage); diff --git a/src/components/UI.jsx b/src/components/UI.jsx index 8ed58a9..43c0803 100644 --- a/src/components/UI.jsx +++ b/src/components/UI.jsx @@ -14,11 +14,28 @@ import PalselButton from './PalselButton'; import Palette from './Palette'; import HistorySelect from './HistorySelect'; import Mobile3DControls from './Mobile3DControls'; +import UserContextMenu from './UserContextMenu'; -const UI = ({ isHistoricalView, is3D, isOnMobile }) => { +const CONTEXT_MENUS = { + USER: , + /* other context menus */ +}; + +const UI = ({ + isHistoricalView, + is3D, + isOnMobile, + menuOpen, + menuType, +}) => { if (isHistoricalView) { - return ; + return ( +
+ + {(menuOpen && menuType) ? CONTEXT_MENUS[menuType] : null} +
+ ); } return (
@@ -28,6 +45,7 @@ const UI = ({ isHistoricalView, is3D, isOnMobile }) => { {(is3D && isOnMobile) ? : null} + {(menuOpen && menuType) ? CONTEXT_MENUS[menuType] : null}
); }; @@ -40,10 +58,16 @@ function mapStateToProps(state: State) { const { isOnMobile, } = state.user; + const { + menuOpen, + menuType, + } = state.contextMenu; return { isHistoricalView, is3D, isOnMobile, + menuOpen, + menuType, }; } diff --git a/src/components/UserContextMenu.jsx b/src/components/UserContextMenu.jsx new file mode 100644 index 0000000..367b314 --- /dev/null +++ b/src/components/UserContextMenu.jsx @@ -0,0 +1,120 @@ +/* + * + * @flow + */ + +import React, { + useRef, useEffect, +} from 'react'; +import { connect } from 'react-redux'; + +import { + hideContextMenu, + addToChatInputMessage, + setChatInputMessage, +} from '../actions'; +import type { State } from '../reducers'; + + +const UserContextMenu = ({ + xPos, + yPos, + uid, + name, + setInput, + addToInput, + close, +}) => { + const wrapperRef = useRef(null); + + useEffect(() => { + function 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); + }; + }, [wrapperRef]); + + return ( +
+
+ Block +
+
{ + setInput('loool'); + }} + style={{ borderBottom: 'thin solid' }} + > + DM +
+
{ + addToInput(`@${name} `); + close(); + }} + > + Ping +
+
+ ); +}; + +function mapStateToProps(state: State) { + const { + xPos, + yPos, + args, + } = state.contextMenu; + const { + name, + uid, + } = args; + return { + xPos, + yPos, + name, + uid, + }; +} + +function mapDispatchToProps(dispatch) { + return { + addToInput(text) { + dispatch(addToChatInputMessage(text)); + const input = document.getElementById('chatmsginput'); + if (input) { + input.focus(); + input.select(); + } + }, + setInput(text) { + dispatch(setChatInputMessage(text)); + }, + close() { + dispatch(hideContextMenu()); + }, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(UserContextMenu); diff --git a/src/core/ChatMessageBuffer.js b/src/core/ChatMessageBuffer.js index d75d35f..9e988ff 100644 --- a/src/core/ChatMessageBuffer.js +++ b/src/core/ChatMessageBuffer.js @@ -68,6 +68,7 @@ class ChatMessageBuffer { name, message, flag, + uid, ]); } } @@ -80,6 +81,7 @@ class ChatMessageBuffer { as: 'user', foreignKey: 'uid', attributes: [ + 'id', 'name', 'flag', ], @@ -101,11 +103,13 @@ class ChatMessageBuffer { message, 'user.name': name, 'user.flag': flag, + 'user.id': uid, } = messagesModel[i]; messages.push([ name, message, flag, + uid, ]); } return messages; diff --git a/src/reducers/chat.js b/src/reducers/chat.js index 73bb456..a89864f 100644 --- a/src/reducers/chat.js +++ b/src/reducers/chat.js @@ -5,6 +5,7 @@ import { MAX_CHAT_MESSAGES } from '../core/constants'; import type { Action } from '../actions/types'; export type ChatState = { + inputMessage: string, // [[cid, name], [cid2, name2],...] channels: Array, // { cid: [message1,message2,message2,...]} @@ -14,6 +15,7 @@ export type ChatState = { } const initialState: ChatState = { + inputMessage: '', channels: [], messages: {}, fetching: false, @@ -39,9 +41,33 @@ export default function chat( }; } + case 'SET_CHAT_INPUT_MSG': { + const { message } = action; + return { + ...state, + inputMessage: message, + }; + } + + case 'ADD_CHAT_INPUT_MSG': { + const { message } = action; + let { inputMessage } = state; + const lastChar = inputMessage.substr(-1); + const pad = (lastChar && lastChar !== ' '); + if (pad) { + inputMessage += ' '; + } + inputMessage += message; + + return { + ...state, + inputMessage, + }; + } + case 'RECEIVE_CHAT_MESSAGE': { const { - name, text, country, channel, + name, text, country, channel, user, } = action; if (!state.messages[channel]) { return state; @@ -50,7 +76,7 @@ export default function chat( ...state.messages, [channel]: [ ...state.messages[channel], - [name, text, country], + [name, text, country, user], ], }; if (messages[channel].length > MAX_CHAT_MESSAGES) { diff --git a/src/reducers/contextMenu.js b/src/reducers/contextMenu.js new file mode 100644 index 0000000..e62e48d --- /dev/null +++ b/src/reducers/contextMenu.js @@ -0,0 +1,57 @@ +/** + * https://stackoverflow.com/questions/35623656/how-can-i-display-a-modal-dialog-in-redux-that-performs-asynchronous-actions/35641680#35641680 + * + * @flow + */ + +import type { Action } from '../actions/types'; + +export type ContextMenuState = { + menuOpen: boolean, + menuType: ?string, + xPos: number, + yPos: number, + args: Object, +}; + +const initialState: ContextMenuState = { + menuOpen: false, + menuType: null, + xPos: 0, + yPos: 0, + args: {}, +}; + + +export default function contextMenu( + state: ModalState = initialState, + action: Action, +): ContextMenuState { + switch (action.type) { + case 'SHOW_CONTEXT_MENU': { + const { + menuType, xPos, yPos, args, + } = action; + return { + ...state, + menuType, + xPos, + yPos, + menuOpen: true, + args: { + ...args, + }, + }; + } + + case 'HIDE_CONTEXT_MENU': { + return { + ...state, + menuOpen: false, + }; + } + + default: + return state; + } +} diff --git a/src/reducers/index.js b/src/reducers/index.js index 4a9b4db..a7812be 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -8,6 +8,7 @@ import gui from './gui'; import modal from './modal'; import user from './user'; import chat from './chat'; +import contextMenu from './contextMenu'; import type { AudioState } from './audio'; import type { CanvasState } from './canvas'; @@ -15,6 +16,7 @@ import type { GUIState } from './gui'; import type { ModalState } from './modal'; import type { UserState } from './user'; import type { ChatState } from './chat'; +import type { ContextMenuState } from './contextMenu'; export type State = { audio: AudioState, @@ -23,12 +25,19 @@ export type State = { modal: ModalState, user: UserState, chat: ChatState, + contextMenu: ContextMenuState, }; const config = { key: 'primary', storage: localForage, - blacklist: ['user', 'canvas', 'modal', 'chat'], + blacklist: [ + 'user', + 'canvas', + 'modal', + 'chat', + 'contextMenu', + ], }; export default persistCombineReducers(config, { @@ -38,4 +47,5 @@ export default persistCombineReducers(config, { modal, user, chat, + contextMenu, }); diff --git a/src/reducers/modal.js b/src/reducers/modal.js index e0a32aa..331d8dc 100644 --- a/src/reducers/modal.js +++ b/src/reducers/modal.js @@ -24,8 +24,6 @@ export default function modal( action: Action, ): ModalState { switch (action.type) { - // clear hover when placing a pixel - // fixes a bug with iPad case 'SHOW_MODAL': { const { modalType } = action; const chatOpen = (modalType === 'CHAT') ? false : state.chatOpen; diff --git a/src/socket/ProtocolClient.js b/src/socket/ProtocolClient.js index 9c6cd96..3924e2f 100644 --- a/src/socket/ProtocolClient.js +++ b/src/socket/ProtocolClient.js @@ -169,10 +169,10 @@ class ProtocolClient extends EventEmitter { const data = JSON.parse(message); if (Array.isArray(data)) { - if (data.length === 4) { + if (data.length === 5) { // Ordinary array: Chat message - const [name, text, country, channelId] = data; - this.emit('chatMessage', name, text, country, channelId); + const [name, text, country, channelId, userId] = data; + this.emit('chatMessage', name, text, country, channelId, userId); } } else { // string = name diff --git a/src/socket/SocketServer.js b/src/socket/SocketServer.js index dc54d1b..57f52da 100644 --- a/src/socket/SocketServer.js +++ b/src/socket/SocketServer.js @@ -169,7 +169,7 @@ class SocketServer extends WebSocketEvents { id: number, country: string, ) { - const text = JSON.stringify([name, message, country, channelId]); + const text = JSON.stringify([name, message, country, channelId, id]); this.wss.clients.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { if (chatProvider.userHasChannelAccess(ws.user, channelId)) { diff --git a/src/styles/default.css b/src/styles/default.css index 1e9f4cb..41ec52b 100644 --- a/src/styles/default.css +++ b/src/styles/default.css @@ -124,6 +124,26 @@ tr:nth-child(even) { transition: 0.3s; } +.contextmenu { + position: absolute; + width: 90px; + font-size: 12px; + z-index: 10; + background-color: rgba(226, 226, 226); + border: solid black; + border-width: thin; +} + +.contextmenu > div { + border-width: thin; + margin: 2px; +} + +.contextmenu > div:hover { + background-color: white; + cursor: pointer; +} + .chatbox.show { height: 200px; width: 350px;