diff --git a/src/actions/index.js b/src/actions/index.js index 0a1172d..020dcd7 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -197,6 +197,7 @@ export function receiveChatMessage( text: string, country: string, channel: number, + isPing: boolean, ): Action { return { type: 'RECEIVE_CHAT_MESSAGE', @@ -204,6 +205,7 @@ export function receiveChatMessage( text, country, channel, + isPing, }; } diff --git a/src/actions/types.js b/src/actions/types.js index b70700a..66ebbfc 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -59,6 +59,7 @@ export type Action = text: string, country: string, channel: number, + isPing: boolean, } | { type: 'RECEIVE_CHAT_HISTORY', data: Array } | { type: 'SET_CHAT_CHANNEL', channelId: number } diff --git a/src/client.js b/src/client.js index e83d9a2..6ddb9b4 100644 --- a/src/client.js +++ b/src/client.js @@ -29,6 +29,7 @@ import ProtocolClient from './socket/ProtocolClient'; function init() { initRenderer(store, false); + let nameRegExp = null; ProtocolClient.on('pixelUpdate', ({ i, j, offset, color, }) => { @@ -40,8 +41,12 @@ function init() { ProtocolClient.on('onlineCounter', ({ online }) => { store.dispatch(receiveOnline(online)); }); + ProtocolClient.on('setWsName', (name) => { + nameRegExp = new RegExp(`(^|\\s+)(@${name})(\\s+|$)`, 'g'); + }); ProtocolClient.on('chatMessage', (name, text, country, channelId) => { - store.dispatch(receiveChatMessage(name, text, country, channelId)); + const isPing = (nameRegExp && text.match(nameRegExp)); + store.dispatch(receiveChatMessage(name, text, country, channelId, isPing)); }); ProtocolClient.on('chatHistory', (data) => { store.dispatch(receiveChatHistory(data)); diff --git a/src/components/Chat.jsx b/src/components/Chat.jsx index 3c6d623..bf90942 100644 --- a/src/components/Chat.jsx +++ b/src/components/Chat.jsx @@ -9,82 +9,26 @@ import React, { import useStayScrolled from 'react-stay-scrolled'; import { connect } from 'react-redux'; -import { MAX_CHAT_MESSAGES } from '../core/constants'; import type { State } from '../reducers'; -import ChatInput from './ChatInput'; +import ChatMessage from './ChatMessage'; + +import { showUserAreaModal, setChatChannel } from '../actions'; +import { MAX_CHAT_MESSAGES, CHAT_CHANNELS } from '../core/constants'; +import ProtocolClient from '../socket/ProtocolClient'; import { saveSelection, restoreSelection } from '../utils/storeSelection'; -import { colorFromText, splitChatMessage } from '../core/utils'; +import splitChatMessage from '../core/chatMessageFilter'; -function ChatMessage({ name, msgArray, country }) { - if (!name || !msgArray) { - return null; - } - - const isInfo = (name === 'info'); - let className = 'msg'; - if (isInfo) { - className += ' info'; - } else if (msgArray[0][1].charAt(0) === '>') { - className += ' greentext'; - } - - return ( -

- { - (!isInfo) - && ( - - { - e.target.onerror = null; - e.target.src = './cf/xx.gif'; - }} - /> - -   - {name} - - :  - - ) - } - { - msgArray.map((msgPart) => { - const [type, txt] = msgPart; - if (type === 't') { - return ({txt}); - } if (type === 'c') { - return ({txt}); - } if (type === 'p') { - return ({txt}); - } if (type === 'm') { - return ( - {txt} - ); - } - return null; - }) - } -

- ); -} - -const Chat = ({ chatMessages, chatChannel, ownName }) => { +const Chat = ({ + chatMessages, + chatChannel, + ownName, + open, + setChannel, +}) => { const listRef = useRef(); + const inputRef = useRef(); + const [inputMessage, setInputMessage] = useState(''); const [selection, setSelection] = useState(null); const [nameRegExp, setNameRegExp] = useState(null); const { stayScrolled } = useStayScrolled(listRef, { @@ -95,7 +39,7 @@ const Chat = ({ chatMessages, chatChannel, ownName }) => { useLayoutEffect(() => { stayScrolled(); - }, [channelMessages.slice(-1)]); + }, [channelMessages]); useEffect(() => { if (channelMessages.length === MAX_CHAT_MESSAGES) { @@ -110,6 +54,25 @@ const Chat = ({ chatMessages, chatChannel, ownName }) => { 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(); + if (!inputMessage) return; + // send message via websocket + ProtocolClient.sendChatMessage(inputMessage, chatChannel); + setInputMessage(''); + } + + return (
- + {(ownName) ? ( +
+
handleSubmit(e)} + style={{ display: 'flex', flexDirection: 'row' }} + > + setInputMessage(e.target.value)} + ref={inputRef} + type="text" + placeholder="Chat here" + /> + + +
+
+ ) : ( +
+ You must be logged in to chat +
+ )}
); }; @@ -140,4 +146,15 @@ function mapStateToProps(state: State) { return { chatMessages, chatChannel, ownName: name }; } -export default connect(mapStateToProps)(Chat); +function mapDispatchToProps(dispatch) { + return { + open() { + dispatch(showUserAreaModal()); + }, + setChannel(channelId) { + dispatch(setChatChannel(channelId)); + }, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Chat); diff --git a/src/components/ChatInput.jsx b/src/components/ChatInput.jsx deleted file mode 100644 index 97558dd..0000000 --- a/src/components/ChatInput.jsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Chat input field - * - * @flow - */ - -import React from 'react'; -import { connect } from 'react-redux'; - -import type { State } from '../reducers'; -import ProtocolClient from '../socket/ProtocolClient'; - -import { showUserAreaModal, setChatChannel } from '../actions'; -import { CHAT_CHANNELS } from '../core/constants'; - -class ChatInput extends React.Component { - constructor() { - super(); - this.state = { - message: '', - }; - - this.handleSubmit = this.handleSubmit.bind(this); - } - - handleSubmit(e, channelId) { - e.preventDefault(); - - const { message } = this.state; - if (!message) return; - // send message via websocket - ProtocolClient.sendChatMessage(message, channelId); - this.setState({ - message: '', - }); - } - - render() { - const { - name, chatChannel, open, setChannel, - } = this.props; - const { - message, - } = this.state; - const selectedChannel = CHAT_CHANNELS[chatChannel]; - - if (name) { - return ( -
-
{ this.handleSubmit(e, chatChannel); }} - style={{ display: 'flex', flexDirection: 'row' }} - > - this.setState({ message: evt.target.value })} - type="text" - placeholder="Chat here" - /> - - -
-
- ); - } - return ( -
- You must be logged in to chat -
- ); - } -} - -function mapStateToProps(state: State) { - const { name } = state.user; - const { chatChannel } = state.gui; - return { name, chatChannel }; -} - -function mapDispatchToProps(dispatch) { - return { - open() { - dispatch(showUserAreaModal()); - }, - setChannel(channelId) { - dispatch(setChatChannel(channelId)); - }, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ChatInput); diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx new file mode 100644 index 0000000..6eab3e5 --- /dev/null +++ b/src/components/ChatMessage.jsx @@ -0,0 +1,102 @@ +/* + * + * @flow + */ +import React from 'react'; + +import { colorFromText } from '../core/utils'; + + +function ChatMessage({ + name, + msgArray, + country, + insertText, +}) { + if (!name || !msgArray) { + return null; + } + + const isInfo = (name === 'info'); + let className = 'msg'; + if (isInfo) { + className += ' info'; + } else if (msgArray[0][1].charAt(0) === '>') { + className += ' greentext'; + } + + let pinged = false; + return ( +

+ { + (!isInfo) + && ( + + { + e.target.onerror = null; + e.target.src = './cf/xx.gif'; + }} + /> +   + { + insertText(`@${name} `); + }} + > + {name} + + :  + + ) + } + { + msgArray.map((msgPart) => { + const [type, txt] = msgPart; + if (type === 't') { + return ({txt}); + } if (type === 'c') { + return ({txt}); + } if (type === 'p') { + if (!pinged) { + pinged = true; + // TODO notify of ping + // ahmm. does that do this on every rerender? :peepowerid: + // better put nameRegexp in the store or something + } + return ( + {txt} + ); + } if (type === 'm') { + return ( + {txt} + ); + } + return null; + }) + } +

+ ); +} + +export default ChatMessage; diff --git a/src/core/ChatProvider.js b/src/core/ChatProvider.js index f72aa68..6fa594f 100644 --- a/src/core/ChatProvider.js +++ b/src/core/ChatProvider.js @@ -13,7 +13,7 @@ import { } from './config'; -class ChatProvider { +export class ChatProvider { /* * TODO: * history really be saved in redis @@ -187,13 +187,13 @@ class ChatProvider { webSockets.broadcastChatMessage(name, message, country, channelId, sendapi); } - /* - * that is really just because i do not like to import the class AND the - * singleton - */ - // eslint-disable-next-line class-methods-use-this - automute(name, channelId = 0) { + static automute(name, channelId = 0) { ChatProvider.mute(name, channelId, 60); + webSockets.broadcastChatMessage( + 'info', + `${name} has been muted for spam for 60min`, + channelId, + ); } static async checkIfMuted(user) { @@ -202,7 +202,8 @@ class ChatProvider { return ttl; } - static async mute(name, channelId = 0, timeMin = null) { + static async mute(plainName, channelId = 0, timeMin = null) { + const name = (plainName.startsWith('@')) ? plainName.substr(1) : plainName; const id = await User.name2Id(name); if (!id) { return `Couldn't find user ${name}`; @@ -230,7 +231,8 @@ class ChatProvider { return null; } - static async unmute(name, channelId = 0) { + static async unmute(plainName, channelId = 0) { + const name = (plainName.startsWith('@')) ? plainName.substr(1) : plainName; const id = await User.name2Id(name); if (!id) { return `Couldn't find user ${name}`; diff --git a/src/core/chatMessageFilter.js b/src/core/chatMessageFilter.js new file mode 100644 index 0000000..4275bc2 --- /dev/null +++ b/src/core/chatMessageFilter.js @@ -0,0 +1,60 @@ +/* + * + * @flow + */ + + +/* + * splits chat message into array of what it represents + * [[type, text],[type, text], ...] + * type: + * 't': text + * 'p': ping + * 'c': coordinates + * 'm': mention of somebody else + * nameRegExp has to be in the form of: + new RegExp(`(^|\\s+)(@${ownName})(\\s+|$)`, 'g'); + */ +const linkRegExp = /(#[a-z]*,-?[0-9]*,-?[0-9]*(,-?[0-9]+)?)/gi; +const linkRegExpFilter = (val, ind) => ((ind % 3) !== 2); +const mentionRegExp = /(^|\s+)(@\S+)/g; +const spaceFilter = (val, ind) => (val !== ' ' && (ind !== 0 | val !== '')); + +function splitChatMessageRegexp( + msgArray, + regExp, + ident, + filter = () => true, +) { + return msgArray.map((msgPart) => { + const [type, part] = msgPart; + if (type !== 't') { + return [msgPart]; + } + return part + .split(regExp) + .filter(filter) + .map((stri, i) => { + if (i % 2 === 0) { + return ['t', stri]; + } + return [ident, stri]; + }) + .filter((el) => !!el[1]); + }).flat(1); +} + +function splitChatMessage(message, nameRegExp = null) { + if (!message) { + return null; + } + let arr = [['t', message.trim()]]; + arr = splitChatMessageRegexp(arr, linkRegExp, 'c', linkRegExpFilter); + if (nameRegExp) { + arr = splitChatMessageRegexp(arr, nameRegExp, 'p', spaceFilter); + } + arr = splitChatMessageRegexp(arr, mentionRegExp, 'm', spaceFilter); + return arr; +} + +export default splitChatMessage; diff --git a/src/core/utils.js b/src/core/utils.js index ff1edf1..52c960c 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -226,56 +226,3 @@ export function colorFromText(str: string) { return `#${'00000'.substring(0, 6 - c.length)}${c}`; } - -/* - * splits chat message into array of what it represents - * [[type, text],[type, text], ...] - * type: - * 't': text - * 'p': ping - * 'c': coordinates - * 'm': mention of somebody else - * nameRegExp has to be in the form of: - new RegExp(`(^|\\s+)(@${ownName})(\\s+|$)`, 'g'); - */ -const linkRegExp = /(#[a-z]*,-?[0-9]*,-?[0-9]*(,-?[0-9]+)?)/gi; -const linkRegExpFilter = (val, ind) => ((ind % 3) !== 2); -const mentionRegExp = /(^|\s+)(@\S+)(\s+|$)/g; -const spaceFilter = (val) => (val !== ' '); - -function splitChatMessageRegexp( - msgArray, - regExp, - ident, - filter = () => true, -) { - return msgArray.map((msgPart) => { - const [type, part] = msgPart; - if (type !== 't') { - return [msgPart]; - } - return part - .split(regExp) - .filter(filter) - .map((stri, i) => { - if (i % 2 === 0) { - return ['t', stri]; - } - return [ident, stri]; - }) - .filter((el) => !!el) - }).flat(1); -} - -export function splitChatMessage(message, nameRegExp = null) { - if (!message) { - return null; - } - let arr = [['t', message.trim()]]; - arr = splitChatMessageRegexp(arr, linkRegExp, 'c', linkRegExpFilter); - if (nameRegExp) { - arr = splitChatMessageRegexp(arr, nameRegExp, 'p', spaceFilter); - } - arr = splitChatMessageRegexp(arr, mentionRegExp, 'm', spaceFilter); - return arr; -} diff --git a/src/socket/ProtocolClient.js b/src/socket/ProtocolClient.js index 7bf5047..7e22ddb 100644 --- a/src/socket/ProtocolClient.js +++ b/src/socket/ProtocolClient.js @@ -173,6 +173,7 @@ class ProtocolClient extends EventEmitter { } else { // string = name this.name = data; + this.emit('setWsName', data); } } diff --git a/src/socket/SocketServer.js b/src/socket/SocketServer.js index 2313848..d0a0302 100644 --- a/src/socket/SocketServer.js +++ b/src/socket/SocketServer.js @@ -18,7 +18,7 @@ import CoolDownPacket from './packets/CoolDownPacket'; import ChangedMe from './packets/ChangedMe'; import OnlineCounter from './packets/OnlineCounter'; -import chatProvider from '../core/ChatProvider'; +import chatProvider, { ChatProvider } from '../core/ChatProvider'; import authenticateClient from './verifyClient'; import WebSocketEvents from './WebSocketEvents'; import webSockets from './websockets'; @@ -261,19 +261,21 @@ class SocketServer extends WebSocketEvents { message, channelId, ); - if (errorMsg) { - ws.send(JSON.stringify(['info', errorMsg, 'il', channelId])); - } - if (ws.last_message && ws.last_message === message) { - ws.message_repeat += 1; - if (ws.message_repeat >= 3) { - logger.info(`User ${ws.name} got automuted`); - chatProvider.automute(ws.name, channelId); + if (!errorMsg) { + // automute on repeated message spam + if (ws.last_message && ws.last_message === message) { + ws.message_repeat += 1; + if (ws.message_repeat >= 4) { + logger.info(`User ${ws.name} got automuted`); + ChatProvider.automute(ws.name, channelId); + ws.message_repeat = 0; + } + } else { ws.message_repeat = 0; + ws.last_message = message; } } else { - ws.message_repeat = 0; - ws.last_message = message; + ws.send(JSON.stringify(['info', errorMsg, 'il', channelId])); } logger.info( `Received chat message ${message} from ${ws.name} / ${ws.user.ip}`, diff --git a/src/store/audio.js b/src/store/audio.js index 5610b14..b87f0af 100644 --- a/src/store/audio.js +++ b/src/store/audio.js @@ -174,8 +174,9 @@ export default (store) => (next) => (action) => { case 'RECEIVE_CHAT_MESSAGE': { if (!chatNotify) break; + const { isPing } = action; const { chatChannel } = state.gui; - if (action.channel !== chatChannel) { + if (!isPing && action.channel !== chatChannel) { break; } @@ -184,8 +185,9 @@ export default (store) => (next) => (action) => { oscillatorNode.type = 'sine'; oscillatorNode.frequency.setValueAtTime(310, context.currentTime); + const freq = (isPing) ? 540 : 355; oscillatorNode.frequency.exponentialRampToValueAtTime( - 355, + freq, context.currentTime + 0.025, ); diff --git a/src/store/notifications.js b/src/store/notifications.js index d06d474..4a91856 100644 --- a/src/store/notifications.js +++ b/src/store/notifications.js @@ -7,40 +7,65 @@ export default (store) => (next) => (action) => { try { - switch (action.type) { - case 'PLACE_PIXEL': { - if (window.Notification - && Notification.permission !== 'granted' - && Notification.permission !== 'denied' - ) { - Notification.requestPermission(); - } - break; - } - - case 'COOLDOWN_END': { - const state = store.getState(); - - // do not notify if last cooldown end was <15s ago - const { lastCoolDownEnd } = state.user; - if (lastCoolDownEnd && lastCoolDownEnd.getTime() + 15000 > Date.now()) { + if (!document.hasFocus()) { + switch (action.type) { + case 'RECEIVE_ME': + case 'PLACE_PIXEL': { + if (window.Notification + && Notification.permission !== 'granted' + && Notification.permission !== 'denied' + ) { + Notification.requestPermission(); + } break; } - if (window.Notification && Notification.permission === 'granted') { - // eslint-disable-next-line no-unused-vars - const notification = new Notification('Your next pixels are ready', { - icon: '/tile.png', - silent: true, - vibrate: [200, 100], - body: 'You can now place more on pixelplanet.fun :)', - }); - } - break; - } + case 'COOLDOWN_END': { + const state = store.getState(); - default: - // nothing + // do not notify if last cooldown end was <15s ago + const { lastCoolDownEnd } = state.user; + if (lastCoolDownEnd + && lastCoolDownEnd.getTime() + 15000 > Date.now()) { + break; + } + + if (window.Notification && Notification.permission === 'granted') { + // eslint-disable-next-line no-new + new Notification('Your next pixels are ready', { + icon: '/tile.png', + silent: true, + vibrate: [200, 100], + body: 'You can now place more on pixelplanet.fun :)', + }); + } + break; + } + + case 'RECEIVE_CHAT_MESSAGE': { + const state = store.getState(); + const { chatNotify } = state.audio; + if (!chatNotify) break; + + const { isPing } = action; + if (!isPing) break; + const { name } = action; + + if (window.Notification && Notification.permission === 'granted') { + // eslint-disable-next-line no-new + new Notification(`${name} mentioned you`, { + icon: '/tile.png', + silent: true, + vibrate: [200, 100], + body: 'You have new messages in chat', + }); + } + break; + } + + default: + // nothing + } } } catch (e) { // eslint-disable-next-line no-console diff --git a/src/styles/default.css b/src/styles/default.css index 3ccdbc1..7befbcd 100644 --- a/src/styles/default.css +++ b/src/styles/default.css @@ -389,7 +389,6 @@ tr:nth-child(even) { .chatname { color: #4B0000; font-size: 13px; - user-select: all; } .chatmsg { color: #030303;