add on click mentions

add ping notifications
This commit is contained in:
HF 2020-05-12 23:43:59 +02:00
parent d278edb9b1
commit 7614d5366b
14 changed files with 346 additions and 294 deletions

View File

@ -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,
};
}

View File

@ -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 }

View File

@ -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));

View File

@ -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 (
<p className="chatmsg">
{
(!isInfo)
&& (
<span>
<img
alt=""
title={country}
src={`${window.assetserver}/cf/${country}.gif`}
onError={(e) => {
e.target.onerror = null;
e.target.src = './cf/xx.gif';
}}
/>
<span
className="chatname"
style={{
color: colorFromText(name),
}}
>
&nbsp;
{name}
</span>
:&nbsp;
</span>
)
}
{
msgArray.map((msgPart) => {
const [type, txt] = msgPart;
if (type === 't') {
return (<span className={className}>{txt}</span>);
} if (type === 'c') {
return (<a href={`./${txt}`}>{txt}</a>);
} if (type === 'p') {
return (<span className="ping">{txt}</span>);
} if (type === 'm') {
return (
<span
className="mention"
style={{
color: colorFromText(txt),
}}
>{txt}</span>
);
}
return null;
})
}
</p>
);
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ul
@ -125,11 +88,54 @@ const Chat = ({ chatMessages, chatChannel, ownName }) => {
name={message[0]}
msgArray={splitChatMessage(message[1], nameRegExp)}
country={message[2]}
insertText={(txt) => padToInputMessage(txt)}
/>
))
}
</ul>
<ChatInput />
{(ownName) ? (
<div classNam="chatinput">
<form
onSubmit={(e) => handleSubmit(e)}
style={{ display: 'flex', flexDirection: 'row' }}
>
<input
style={{ flexGrow: 1, minWidth: 40 }}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
ref={inputRef}
type="text"
placeholder="Chat here"
/>
<button
style={{ flexGrow: 0 }}
type="submit"
>
</button>
<select
style={{ flexGrow: 0 }}
onChange={(evt) => setChannel(evt.target.selectedIndex)}
>
{
CHAT_CHANNELS.map((ch) => (
<option selected={ch === chatChannel}>{ch}</option>
))
}
</select>
</form>
</div>
) : (
<div
className="modallink"
onClick={open}
style={{ textAlign: 'center', fontSize: 13 }}
role="button"
tabIndex={0}
>
You must be logged in to chat
</div>
)}
</div>
);
};
@ -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);

View File

@ -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 (
<div className="chatinput">
<form
onSubmit={(e) => { this.handleSubmit(e, chatChannel); }}
style={{ display: 'flex', flexDirection: 'row' }}
>
<input
style={{ flexGrow: 1, minWidth: 40 }}
value={message}
onChange={(evt) => this.setState({ message: evt.target.value })}
type="text"
placeholder="Chat here"
/>
<button
style={{ flexGrow: 0 }}
id="chatmsginput"
type="submit"
>
</button>
<select
style={{ flexGrow: 0 }}
onChange={(evt) => setChannel(evt.target.selectedIndex)}
>
{
CHAT_CHANNELS.map((ch) => (
<option selected={ch === selectedChannel}>{ch}</option>
))
}
</select>
</form>
</div>
);
}
return (
<div
className="modallink"
onClick={open}
style={{ textAlign: 'center', fontSize: 13 }}
role="button"
tabIndex={0}
>
You must be logged in to chat
</div>
);
}
}
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);

View File

@ -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 (
<p className="chatmsg">
{
(!isInfo)
&& (
<span>
<img
alt=""
title={country}
src={`${window.assetserver}/cf/${country}.gif`}
onError={(e) => {
e.target.onerror = null;
e.target.src = './cf/xx.gif';
}}
/>
&nbsp;
<span
className="chatname"
style={{
color: colorFromText(name),
cursor: 'pointer',
}}
role="button"
tabIndex={-1}
onClick={() => {
insertText(`@${name} `);
}}
>
{name}
</span>
:&nbsp;
</span>
)
}
{
msgArray.map((msgPart) => {
const [type, txt] = msgPart;
if (type === 't') {
return (<span className={className}>{txt}</span>);
} if (type === 'c') {
return (<a href={`./${txt}`}>{txt}</a>);
} 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 (
<span
className="ping"
style={{
color: colorFromText(txt.substr(1)),
}}
>{txt}</span>
);
} if (type === 'm') {
return (
<span
className="mention"
style={{
color: colorFromText(txt.substr(1)),
}}
>{txt}</span>
);
}
return null;
})
}
</p>
);
}
export default ChatMessage;

View File

@ -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}`;

View File

@ -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;

View File

@ -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;
}

View File

@ -173,6 +173,7 @@ class ProtocolClient extends EventEmitter {
} else {
// string = name
this.name = data;
this.emit('setWsName', data);
}
}

View File

@ -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}`,

View File

@ -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,
);

View File

@ -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

View File

@ -389,7 +389,6 @@ tr:nth-child(even) {
.chatname {
color: #4B0000;
font-size: 13px;
user-select: all;
}
.chatmsg {
color: #030303;