forked from wurzel/fruitbasket
stream doesn't want to work
This commit is contained in:
parent
d7ed57bbdd
commit
9656926a07
287 changed files with 6934 additions and 0 deletions
399
webroot/js/components/chat/chat-input.js
Normal file
399
webroot/js/components/chat/chat-input.js
Normal file
|
@ -0,0 +1,399 @@
|
|||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js';
|
||||
|
||||
import ContentEditable, { replaceCaret } from './content-editable.js';
|
||||
import {
|
||||
generatePlaceholderText,
|
||||
getCaretPosition,
|
||||
convertToText,
|
||||
convertOnPaste,
|
||||
createEmojiMarkup,
|
||||
trimNbsp,
|
||||
emojify,
|
||||
} from '../../utils/chat.js';
|
||||
import {
|
||||
getLocalStorage,
|
||||
setLocalStorage,
|
||||
classNames,
|
||||
} from '../../utils/helpers.js';
|
||||
import {
|
||||
URL_CUSTOM_EMOJIS,
|
||||
KEY_CHAT_FIRST_MESSAGE_SENT,
|
||||
CHAT_CHAR_COUNT_BUFFER,
|
||||
CHAT_OK_KEYCODES,
|
||||
CHAT_KEY_MODIFIERS,
|
||||
} from '../../utils/constants.js';
|
||||
|
||||
export default class ChatInput extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.formMessageInput = createRef();
|
||||
this.emojiPickerButton = createRef();
|
||||
|
||||
this.messageCharCount = 0;
|
||||
|
||||
this.prepNewLine = false;
|
||||
this.modifierKeyPressed = false; // control/meta/shift/alt
|
||||
|
||||
this.state = {
|
||||
inputHTML: '',
|
||||
inputCharsLeft: props.inputMaxBytes,
|
||||
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
|
||||
emojiPicker: null,
|
||||
emojiList: null,
|
||||
emojiNames: null,
|
||||
};
|
||||
|
||||
this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this);
|
||||
this.handleEmojiSelected = this.handleEmojiSelected.bind(this);
|
||||
this.getCustomEmojis = this.getCustomEmojis.bind(this);
|
||||
|
||||
this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this);
|
||||
this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this);
|
||||
this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this);
|
||||
this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this);
|
||||
this.handlePaste = this.handlePaste.bind(this);
|
||||
|
||||
this.handleContentEditableChange =
|
||||
this.handleContentEditableChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getCustomEmojis();
|
||||
}
|
||||
|
||||
getCustomEmojis() {
|
||||
fetch(URL_CUSTOM_EMOJIS)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
const emojiList = json;
|
||||
const emojiNames = emojiList.map((emoji) => emoji.name);
|
||||
const emojiPicker = new EmojiButton({
|
||||
zIndex: 100,
|
||||
theme: 'owncast', // see chat.css
|
||||
custom: json,
|
||||
initialCategory: 'custom',
|
||||
showPreview: false,
|
||||
autoHide: false,
|
||||
autoFocusSearch: false,
|
||||
showAnimation: false,
|
||||
emojiSize: '24px',
|
||||
position: 'right-start',
|
||||
strategy: 'absolute',
|
||||
});
|
||||
emojiPicker.on('emoji', (emoji) => {
|
||||
this.handleEmojiSelected(emoji);
|
||||
});
|
||||
emojiPicker.on('hidden', () => {
|
||||
this.formMessageInput.current.focus();
|
||||
replaceCaret(this.formMessageInput.current);
|
||||
});
|
||||
this.setState({ emojiNames, emojiList, emojiPicker });
|
||||
})
|
||||
.catch((error) => {
|
||||
// this.handleNetworkingError(`Emoji Fetch: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
handleEmojiButtonClick() {
|
||||
const { emojiPicker } = this.state;
|
||||
if (emojiPicker) {
|
||||
emojiPicker.togglePicker(this.emojiPickerButton.current);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmojiSelected(emoji) {
|
||||
const { inputHTML, inputCharsLeft } = this.state;
|
||||
// if we're already at char limit, don't do anything
|
||||
if (inputCharsLeft < 0) {
|
||||
return;
|
||||
}
|
||||
let content = '';
|
||||
if (emoji.url) {
|
||||
content = createEmojiMarkup(emoji, false);
|
||||
} else {
|
||||
content = emoji.emoji;
|
||||
}
|
||||
|
||||
const position = getCaretPosition(this.formMessageInput.current);
|
||||
const newHTML =
|
||||
inputHTML.substring(0, position) +
|
||||
content +
|
||||
inputHTML.substring(position);
|
||||
|
||||
const charsLeft = this.calculateCurrentBytesLeft(newHTML);
|
||||
this.setState({
|
||||
inputHTML: newHTML,
|
||||
inputCharsLeft: charsLeft,
|
||||
});
|
||||
// a hacky way add focus back into input field
|
||||
setTimeout(() => {
|
||||
const input = this.formMessageInput.current;
|
||||
input.focus();
|
||||
replaceCaret(input);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// autocomplete text from the given "list". "token" marks the start of word lookup.
|
||||
autoComplete(token, list) {
|
||||
const { inputHTML } = this.state;
|
||||
const position = getCaretPosition(this.formMessageInput.current);
|
||||
const at = inputHTML.lastIndexOf(token, position - 1);
|
||||
if (at === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let partial = inputHTML.substring(at + 1, position).trim();
|
||||
|
||||
if (this.partial === undefined) {
|
||||
this.partial = [];
|
||||
}
|
||||
|
||||
if (partial === this.suggestion) {
|
||||
partial = this.partial[token];
|
||||
} else {
|
||||
this.partial[token] = partial;
|
||||
}
|
||||
|
||||
const possibilities = list.filter(function (item) {
|
||||
return item.toLowerCase().startsWith(partial.toLowerCase());
|
||||
});
|
||||
|
||||
if (this.completionIndex === undefined) {
|
||||
this.completionIndex = [];
|
||||
}
|
||||
|
||||
if (
|
||||
this.completionIndex[token] === undefined ||
|
||||
++this.completionIndex[token] >= possibilities.length
|
||||
) {
|
||||
this.completionIndex[token] = 0;
|
||||
}
|
||||
|
||||
if (possibilities.length > 0) {
|
||||
this.suggestion = possibilities[this.completionIndex[token]];
|
||||
|
||||
const newHTML =
|
||||
inputHTML.substring(0, at + 1) +
|
||||
this.suggestion +
|
||||
' ' +
|
||||
inputHTML.substring(position);
|
||||
|
||||
this.setState({
|
||||
inputHTML: newHTML,
|
||||
inputCharsLeft: this.calculateCurrentBytesLeft(newHTML),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// replace :emoji: with the emoji <img>
|
||||
injectEmoji() {
|
||||
const { inputHTML, emojiList } = this.state;
|
||||
const textValue = convertToText(inputHTML);
|
||||
const processedHTML = emojify(inputHTML, emojiList);
|
||||
|
||||
if (textValue != convertToText(processedHTML)) {
|
||||
this.setState({
|
||||
inputHTML: processedHTML,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleMessageInputKeydown(event) {
|
||||
const key = event && event.key;
|
||||
|
||||
if (key === 'Enter') {
|
||||
if (!this.prepNewLine) {
|
||||
this.sendMessage();
|
||||
event.preventDefault();
|
||||
this.prepNewLine = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// allow key presses such as command/shift/meta, etc even when message length is full later.
|
||||
if (CHAT_KEY_MODIFIERS.includes(key)) {
|
||||
this.modifierKeyPressed = true;
|
||||
}
|
||||
if (key === 'Control' || key === 'Shift') {
|
||||
this.prepNewLine = true;
|
||||
}
|
||||
if (key === 'Tab') {
|
||||
const { chatUserNames } = this.props;
|
||||
const { emojiNames } = this.state;
|
||||
if (this.autoComplete('@', chatUserNames)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.autoComplete(':', emojiNames)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// if new input pushes the potential chars over, don't do anything
|
||||
const formField = this.formMessageInput.current;
|
||||
const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML);
|
||||
if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) {
|
||||
if (!this.modifierKeyPressed) {
|
||||
event.preventDefault(); // prevent typing more
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputKeyup(event) {
|
||||
const { key } = event;
|
||||
if (key === 'Control' || key === 'Shift') {
|
||||
this.prepNewLine = false;
|
||||
}
|
||||
if (CHAT_KEY_MODIFIERS.includes(key)) {
|
||||
this.modifierKeyPressed = false;
|
||||
}
|
||||
|
||||
if (key === ':' || key === ';') {
|
||||
this.injectEmoji();
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputBlur() {
|
||||
this.prepNewLine = false;
|
||||
this.modifierKeyPressed = false;
|
||||
}
|
||||
|
||||
handlePaste(event) {
|
||||
// don't allow paste if too much text already
|
||||
if (this.state.inputCharsLeft < 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
convertOnPaste(event, this.state.emojiList);
|
||||
this.handleMessageInputKeydown(event);
|
||||
}
|
||||
|
||||
handleSubmitChatButton(event) {
|
||||
event.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const { handleSendMessage, inputMaxBytes } = this.props;
|
||||
const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state;
|
||||
if (inputCharsLeft < 0) {
|
||||
return;
|
||||
}
|
||||
const message = convertToText(inputHTML);
|
||||
const newStates = {
|
||||
inputHTML: '',
|
||||
inputCharsLeft: inputMaxBytes,
|
||||
};
|
||||
|
||||
handleSendMessage(message);
|
||||
|
||||
if (!hasSentFirstChatMessage) {
|
||||
newStates.hasSentFirstChatMessage = true;
|
||||
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
|
||||
}
|
||||
|
||||
// clear things out.
|
||||
this.setState(newStates);
|
||||
}
|
||||
|
||||
handleContentEditableChange(event) {
|
||||
const value = event.target.value;
|
||||
this.setState({
|
||||
inputHTML: value,
|
||||
inputCharsLeft: this.calculateCurrentBytesLeft(value),
|
||||
});
|
||||
}
|
||||
|
||||
calculateCurrentBytesLeft(inputContent) {
|
||||
const { inputMaxBytes } = this.props;
|
||||
const curBytes = new Blob([trimNbsp(inputContent)]).size;
|
||||
return inputMaxBytes - curBytes;
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } =
|
||||
state;
|
||||
const { inputEnabled, inputMaxBytes } = props;
|
||||
const emojiButtonStyle = {
|
||||
display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
|
||||
};
|
||||
const extraClasses = classNames({
|
||||
'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER,
|
||||
});
|
||||
const placeholderText = generatePlaceholderText(
|
||||
inputEnabled,
|
||||
hasSentFirstChatMessage
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
id="message-input-container"
|
||||
class="relative shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20 ${extraClasses}"
|
||||
>
|
||||
<div
|
||||
id="message-input-wrap"
|
||||
class="flex flex-row justify-end appearance-none w-full bg-gray-200 border border-black-500 rounded py-2 px-2 pr-20 my-2 overflow-auto"
|
||||
>
|
||||
<${ContentEditable}
|
||||
id="message-input"
|
||||
class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none"
|
||||
placeholderText=${placeholderText}
|
||||
innerRef=${this.formMessageInput}
|
||||
html=${inputHTML}
|
||||
disabled=${!inputEnabled}
|
||||
onChange=${this.handleContentEditableChange}
|
||||
onKeyDown=${this.handleMessageInputKeydown}
|
||||
onKeyUp=${this.handleMessageInputKeyup}
|
||||
onBlur=${this.handleMessageInputBlur}
|
||||
onPaste=${this.handlePaste}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="message-form-actions"
|
||||
class="absolute flex flex-col justify-end items-end mr-4"
|
||||
>
|
||||
<span class="flex flex-row justify-center">
|
||||
<button
|
||||
ref=${this.emojiPickerButton}
|
||||
id="emoji-button"
|
||||
class="text-3xl leading-3 cursor-pointer text-purple-600"
|
||||
type="button"
|
||||
style=${emojiButtonStyle}
|
||||
onclick=${this.handleEmojiButtonClick}
|
||||
aria-label="Select an emoji"
|
||||
disabled=${!inputEnabled}
|
||||
>
|
||||
<img src="../../../img/smiley.png" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="send-message-button"
|
||||
class="text-sm text-white rounded bg-gray-600 hidden p-1 ml-1 -mr-2"
|
||||
type="button"
|
||||
onclick=${this.handleSubmitChatButton}
|
||||
disabled=${inputHTML === '' || inputCharsLeft < 0}
|
||||
aria-label="Send message"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span id="message-form-warning" class="text-red-600 text-xs"
|
||||
>${inputCharsLeft} bytes</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
212
webroot/js/components/chat/chat-message-view.js
Normal file
212
webroot/js/components/chat/chat-message-view.js
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
import Mark from '/js/web_modules/markjs/dist/mark.es6.min.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import {
|
||||
messageBubbleColorForHue,
|
||||
textColorForHue,
|
||||
} from '../../utils/user-colors.js';
|
||||
import { convertToText, checkIsModerator } from '../../utils/chat.js';
|
||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
import { getDiffInDaysFromNow } from '../../utils/helpers.js';
|
||||
import ModeratorActions from './moderator-actions.js';
|
||||
|
||||
export default class ChatMessageView extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
formattedMessage: '',
|
||||
moderatorMenuOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const { formattedMessage } = this.state;
|
||||
const { formattedMessage: nextFormattedMessage } = nextState;
|
||||
|
||||
return (
|
||||
formattedMessage !== nextFormattedMessage ||
|
||||
(!this.props.isModerator && nextProps.isModerator)
|
||||
);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { message, username } = this.props;
|
||||
const { body } = message;
|
||||
|
||||
if (message && username) {
|
||||
const formattedMessage = await formatMessageText(body, username);
|
||||
this.setState({
|
||||
formattedMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const { message, isModerator, accessToken } = this.props;
|
||||
const { user, timestamp } = message;
|
||||
|
||||
// User is required for this component to render.
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { displayName, displayColor, createdAt } = user;
|
||||
const isAuthorModerator = checkIsModerator(message);
|
||||
|
||||
const isMessageModeratable =
|
||||
isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT;
|
||||
|
||||
const { formattedMessage } = this.state;
|
||||
if (!formattedMessage) {
|
||||
return null;
|
||||
}
|
||||
const formattedTimestamp = `Sent at ${formatTimestamp(timestamp)}`;
|
||||
const userMetadata = createdAt
|
||||
? `${displayName} first joined ${formatTimestamp(createdAt)}`
|
||||
: null;
|
||||
|
||||
const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM;
|
||||
|
||||
const authorTextColor = isSystemMessage
|
||||
? { color: '#fff' }
|
||||
: { color: textColorForHue(displayColor) };
|
||||
const backgroundStyle = isSystemMessage
|
||||
? { backgroundColor: '#667eea' }
|
||||
: { backgroundColor: messageBubbleColorForHue(displayColor) };
|
||||
const messageClassString = isSystemMessage
|
||||
? 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l'
|
||||
: `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${
|
||||
isMessageModeratable ? 'moderatable' : ''
|
||||
}`;
|
||||
|
||||
const messageAuthorFlair = isAuthorModerator
|
||||
? html`<img
|
||||
class="flair"
|
||||
title="Moderator"
|
||||
src="/img/moderator-nobackground.svg"
|
||||
/>`
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div
|
||||
style=${backgroundStyle}
|
||||
class=${messageClassString}
|
||||
title=${formattedTimestamp}
|
||||
>
|
||||
<div class="message-content break-words w-full">
|
||||
<div
|
||||
style=${authorTextColor}
|
||||
class="message-author font-bold"
|
||||
title=${userMetadata}
|
||||
>
|
||||
${messageAuthorFlair} ${displayName}
|
||||
</div>
|
||||
${isMessageModeratable &&
|
||||
html`<${ModeratorActions}
|
||||
message=${message}
|
||||
accessToken=${accessToken}
|
||||
/>`}
|
||||
<div
|
||||
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
|
||||
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function formatMessageText(message, username) {
|
||||
let formattedText = getMessageWithEmbeds(message);
|
||||
formattedText = convertToMarkup(formattedText);
|
||||
return await highlightUsername(formattedText, username);
|
||||
}
|
||||
|
||||
function highlightUsername(message, username) {
|
||||
// https://github.com/julmot/mark.js/issues/115
|
||||
const node = document.createElement('span');
|
||||
node.innerHTML = message;
|
||||
return new Promise((res) => {
|
||||
new Mark(node).mark(username, {
|
||||
element: 'span',
|
||||
className: 'highlighted px-1 rounded font-bold bg-orange-500',
|
||||
separateWordSearch: false,
|
||||
accuracy: {
|
||||
value: 'exactly',
|
||||
limiters: [',', '.', "'", '?', '@'],
|
||||
},
|
||||
done() {
|
||||
res(node.innerHTML);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getMessageWithEmbeds(message) {
|
||||
var embedText = '';
|
||||
// Make a temporary element so we can actually parse the html and pull anchor tags from it.
|
||||
// This is a better approach than regex.
|
||||
var container = document.createElement('p');
|
||||
container.innerHTML = message;
|
||||
|
||||
var anchors = container.getElementsByTagName('a');
|
||||
for (var i = 0; i < anchors.length; i++) {
|
||||
const url = anchors[i].href;
|
||||
if (url.indexOf('instagram.com/p/') > -1) {
|
||||
embedText += getInstagramEmbedFromURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// If this message only consists of a single embeddable link
|
||||
// then only return the embed and strip the link url from the text.
|
||||
if (
|
||||
embedText !== '' &&
|
||||
anchors.length == 1 &&
|
||||
isMessageJustAnchor(message, anchors[0])
|
||||
) {
|
||||
return embedText;
|
||||
}
|
||||
return message + embedText;
|
||||
}
|
||||
|
||||
function getInstagramEmbedFromURL(url) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ''));
|
||||
urlObject.pathname += '/embed';
|
||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function isMessageJustAnchor(message, anchor) {
|
||||
return stripTags(message) === stripTags(anchor.innerHTML);
|
||||
}
|
||||
|
||||
function formatTimestamp(sentAt) {
|
||||
sentAt = new Date(sentAt);
|
||||
if (isNaN(sentAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let diffInDays = getDiffInDaysFromNow(sentAt);
|
||||
if (diffInDays >= 1) {
|
||||
return (
|
||||
`at ${sentAt.toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
})} at ` + sentAt.toLocaleTimeString()
|
||||
);
|
||||
}
|
||||
|
||||
return `${sentAt.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/*
|
||||
You would call this when receiving a plain text
|
||||
value back from an API, and before inserting the
|
||||
text into the `contenteditable` area on a page.
|
||||
*/
|
||||
function convertToMarkup(str = '') {
|
||||
return convertToText(str).replace(/\n/g, '<p></p>');
|
||||
}
|
||||
|
||||
function stripTags(str) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
}
|
508
webroot/js/components/chat/chat.js
Normal file
508
webroot/js/components/chat/chat.js
Normal file
|
@ -0,0 +1,508 @@
|
|||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import Message from './message.js';
|
||||
import ChatInput from './chat-input.js';
|
||||
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
import { jumpToBottom, debounce } from '../../utils/helpers.js';
|
||||
import {
|
||||
extraUserNamesFromMessageHistory,
|
||||
checkIsModerator,
|
||||
} from '../../utils/chat.js';
|
||||
import {
|
||||
URL_CHAT_HISTORY,
|
||||
MESSAGE_JUMPTOBOTTOM_BUFFER,
|
||||
} from '../../utils/constants.js';
|
||||
|
||||
const MAX_RENDER_BACKLOG = 300;
|
||||
|
||||
// Add message types that should be displayed in chat to this array.
|
||||
const renderableChatStyleMessages = [
|
||||
SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO,
|
||||
SOCKET_MESSAGE_TYPES.USER_JOINED,
|
||||
SOCKET_MESSAGE_TYPES.CHAT_ACTION,
|
||||
SOCKET_MESSAGE_TYPES.SYSTEM,
|
||||
SOCKET_MESSAGE_TYPES.CHAT,
|
||||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW,
|
||||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE,
|
||||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST,
|
||||
];
|
||||
export default class Chat extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
chatUserNames: [],
|
||||
// Ordered array of messages sorted by timestamp.
|
||||
sortedMessages: [],
|
||||
|
||||
newMessagesReceived: false,
|
||||
webSocketConnected: true,
|
||||
isModerator: false,
|
||||
};
|
||||
|
||||
this.scrollableMessagesContainer = createRef();
|
||||
|
||||
this.websocket = null;
|
||||
this.receivedFirstMessages = false;
|
||||
this.receivedMessageUpdate = false;
|
||||
this.hasFetchedHistory = false;
|
||||
|
||||
// Unordered dictionary of messages keyed by ID.
|
||||
this.messages = {};
|
||||
|
||||
this.windowBlurred = false;
|
||||
this.numMessagesSinceBlur = 0;
|
||||
|
||||
this.getChatHistory = this.getChatHistory.bind(this);
|
||||
this.handleNetworkingError = this.handleNetworkingError.bind(this);
|
||||
this.handleWindowBlur = this.handleWindowBlur.bind(this);
|
||||
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 500);
|
||||
this.messageListCallback = this.messageListCallback.bind(this);
|
||||
this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this);
|
||||
this.scrollToBottom = this.scrollToBottom.bind(this);
|
||||
this.submitChat = this.submitChat.bind(this);
|
||||
this.websocketConnected = this.websocketConnected.bind(this);
|
||||
this.websocketDisconnected = this.websocketDisconnected.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setupWebSocketCallbacks();
|
||||
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
|
||||
if (!this.props.readonly) {
|
||||
window.addEventListener('blur', this.handleWindowBlur);
|
||||
window.addEventListener('focus', this.handleWindowFocus);
|
||||
}
|
||||
|
||||
this.messageListObserver = new MutationObserver(this.messageListCallback);
|
||||
this.messageListObserver.observe(this.scrollableMessagesContainer.current, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const { username, chatInputEnabled } = this.props;
|
||||
const { username: nextUserName, chatInputEnabled: nextChatEnabled } =
|
||||
nextProps;
|
||||
|
||||
const {
|
||||
webSocketConnected,
|
||||
chatUserNames,
|
||||
newMessagesReceived,
|
||||
sortedMessages,
|
||||
} = this.state;
|
||||
|
||||
const {
|
||||
webSocketConnected: nextSocket,
|
||||
chatUserNames: nextUserNames,
|
||||
newMessagesReceived: nextMessagesReceived,
|
||||
} = nextState;
|
||||
|
||||
// If there are an updated number of sorted message then a render pass
|
||||
// needs to take place to render these new messages.
|
||||
if (
|
||||
Object.keys(sortedMessages).length !==
|
||||
Object.keys(nextState.sortedMessages).length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newMessagesReceived) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
username !== nextUserName ||
|
||||
chatInputEnabled !== nextChatEnabled ||
|
||||
webSocketConnected !== nextSocket ||
|
||||
chatUserNames.length !== nextUserNames.length ||
|
||||
newMessagesReceived !== nextMessagesReceived
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { accessToken } = this.props;
|
||||
|
||||
// Fetch chat history
|
||||
if (!this.hasFetchedHistory && accessToken) {
|
||||
this.hasFetchedHistory = true;
|
||||
this.getChatHistory(accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
if (!this.props.readonly) {
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
window.removeEventListener('focus', this.handleWindowFocus);
|
||||
}
|
||||
this.messageListObserver.disconnect();
|
||||
}
|
||||
|
||||
setupWebSocketCallbacks() {
|
||||
this.websocket = this.props.websocket;
|
||||
if (this.websocket) {
|
||||
this.websocket.addListener(
|
||||
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
|
||||
this.receivedWebsocketMessage
|
||||
);
|
||||
this.websocket.addListener(
|
||||
CALLBACKS.WEBSOCKET_CONNECTED,
|
||||
this.websocketConnected
|
||||
);
|
||||
this.websocket.addListener(
|
||||
CALLBACKS.WEBSOCKET_DISCONNECTED,
|
||||
this.websocketDisconnected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch chat history
|
||||
async getChatHistory(accessToken) {
|
||||
const { username } = this.props;
|
||||
try {
|
||||
const response = await fetch(
|
||||
URL_CHAT_HISTORY + `?accessToken=${accessToken}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
// Backlog of usernames from history
|
||||
const allChatUserNames = extraUserNamesFromMessageHistory(data);
|
||||
const chatUserNames = allChatUserNames.filter((name) => name != username);
|
||||
|
||||
this.addNewRenderableMessages(data);
|
||||
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
chatUserNames,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
receivedWebsocketMessage(message) {
|
||||
this.handleMessage(message);
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
// todo: something more useful
|
||||
console.error('chat error', error);
|
||||
}
|
||||
|
||||
// Give a list of message IDs and the visibility state they should change to.
|
||||
updateMessagesVisibility(idsToUpdate, visible) {
|
||||
let messageList = { ...this.messages };
|
||||
|
||||
// Iterate through each ID and mark the associated ID in our messages
|
||||
// dictionary with the new visibility.
|
||||
for (const id of idsToUpdate) {
|
||||
const message = messageList[id];
|
||||
if (message) {
|
||||
message.visible = visible;
|
||||
messageList[id] = message;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessagesList = {
|
||||
...this.messages,
|
||||
...messageList,
|
||||
};
|
||||
|
||||
this.messages = updatedMessagesList;
|
||||
|
||||
this.resortAndRenderMessages();
|
||||
}
|
||||
|
||||
handleChangeModeratorStatus(isModerator) {
|
||||
if (isModerator !== this.state.isModerator) {
|
||||
this.setState((previousState) => {
|
||||
return { ...previousState, isModerator: isModerator };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleWindowFocusNotificationCount(readonly, messageType) {
|
||||
// if window is blurred and we get a new message, add 1 to title
|
||||
if (
|
||||
!readonly &&
|
||||
messageType === SOCKET_MESSAGE_TYPES.CHAT &&
|
||||
this.windowBlurred
|
||||
) {
|
||||
this.numMessagesSinceBlur += 1;
|
||||
}
|
||||
}
|
||||
|
||||
addNewRenderableMessages(messagesArray) {
|
||||
// Convert the array of chat history messages into an object
|
||||
// to be merged with the existing chat messages.
|
||||
const newMessages = messagesArray.reduce(
|
||||
(o, message) => ({ ...o, [message.id]: message }),
|
||||
{}
|
||||
);
|
||||
|
||||
// Keep our unsorted collection of messages keyed by ID.
|
||||
const updatedMessagesList = {
|
||||
...newMessages,
|
||||
...this.messages,
|
||||
};
|
||||
this.messages = updatedMessagesList;
|
||||
|
||||
this.resortAndRenderMessages();
|
||||
}
|
||||
|
||||
resortAndRenderMessages() {
|
||||
// Convert the unordered dictionary of messages to an ordered array.
|
||||
// NOTE: This sorts the entire collection of messages on every new message
|
||||
// because the order a message comes in cannot be trusted that it's the order
|
||||
// it was sent, you need to sort by timestamp. I don't know if there
|
||||
// is a performance problem waiting to occur here for larger chat feeds.
|
||||
var sortedMessages = Object.values(this.messages)
|
||||
// Filter out messages set to not be visible
|
||||
.filter((message) => message.visible !== false)
|
||||
.sort((a, b) => {
|
||||
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
|
||||
});
|
||||
|
||||
// Cap this list to 300 items to improve browser performance.
|
||||
if (sortedMessages.length >= MAX_RENDER_BACKLOG) {
|
||||
sortedMessages = sortedMessages.slice(
|
||||
sortedMessages.length - MAX_RENDER_BACKLOG
|
||||
);
|
||||
}
|
||||
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
newMessagesReceived: true,
|
||||
sortedMessages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// handle any incoming message
|
||||
handleMessage(message) {
|
||||
const { type: messageType } = message;
|
||||
const { readonly, username } = this.props;
|
||||
|
||||
// Allow non-user chat messages to be visible by default.
|
||||
const messageVisible =
|
||||
message.visible || messageType !== SOCKET_MESSAGE_TYPES.CHAT;
|
||||
|
||||
// Show moderator status
|
||||
if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
|
||||
const modStatusUpdate = checkIsModerator(message);
|
||||
this.handleChangeModeratorStatus(modStatusUpdate);
|
||||
}
|
||||
|
||||
// Change the visibility of messages by ID.
|
||||
if (messageType === SOCKET_MESSAGE_TYPES.VISIBILITY_UPDATE) {
|
||||
const idsToUpdate = message.ids;
|
||||
const visible = message.visible;
|
||||
this.updateMessagesVisibility(idsToUpdate, visible);
|
||||
} else if (
|
||||
renderableChatStyleMessages.includes(messageType) &&
|
||||
messageVisible
|
||||
) {
|
||||
// Add new message to the chat feed.
|
||||
this.addNewRenderableMessages([message]);
|
||||
|
||||
// Update the usernames list, filtering out our own name.
|
||||
const updatedAllChatUserNames = this.updateAuthorList(message);
|
||||
if (updatedAllChatUserNames.length) {
|
||||
const updatedChatUserNames = updatedAllChatUserNames.filter(
|
||||
(name) => name != username
|
||||
);
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
chatUserNames: [...updatedChatUserNames],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the window title if needed.
|
||||
this.handleWindowFocusNotificationCount(readonly, messageType);
|
||||
}
|
||||
|
||||
websocketConnected() {
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
webSocketConnected: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
websocketDisconnected() {
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
webSocketConnected: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
submitChat(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
body: content,
|
||||
type: SOCKET_MESSAGE_TYPES.CHAT,
|
||||
};
|
||||
this.websocket.send(message);
|
||||
}
|
||||
|
||||
updateAuthorList(message) {
|
||||
const { type } = message;
|
||||
let nameList = this.state.chatUserNames;
|
||||
|
||||
if (
|
||||
type === SOCKET_MESSAGE_TYPES.CHAT &&
|
||||
!nameList.includes(message.user.displayName)
|
||||
) {
|
||||
nameList.push(message.user.displayName);
|
||||
return nameList;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||
const { oldName, user } = message;
|
||||
const oldNameIndex = nameList.indexOf(oldName);
|
||||
nameList.splice(oldNameIndex, 1, user.displayName);
|
||||
return nameList;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
jumpToBottom(this.scrollableMessagesContainer.current);
|
||||
}
|
||||
|
||||
checkShouldScroll() {
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
this.scrollableMessagesContainer.current;
|
||||
const fullyScrolled = scrollHeight - clientHeight;
|
||||
const shouldScroll =
|
||||
scrollHeight >= clientHeight &&
|
||||
fullyScrolled - scrollTop < MESSAGE_JUMPTOBOTTOM_BUFFER;
|
||||
|
||||
return shouldScroll;
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
handleWindowBlur() {
|
||||
this.windowBlurred = true;
|
||||
}
|
||||
|
||||
handleWindowFocus() {
|
||||
this.windowBlurred = false;
|
||||
this.numMessagesSinceBlur = 0;
|
||||
window.document.title = this.props.instanceTitle;
|
||||
}
|
||||
|
||||
// if the messages list grows in number of child message nodes due to new messages received, scroll to bottom.
|
||||
messageListCallback(mutations) {
|
||||
const numMutations = mutations.length;
|
||||
|
||||
if (numMutations) {
|
||||
const item = mutations[numMutations - 1];
|
||||
if (item.type === 'childList' && item.addedNodes.length) {
|
||||
if (this.state.newMessagesReceived) {
|
||||
if (!this.receivedFirstMessages) {
|
||||
this.scrollToBottom();
|
||||
this.receivedFirstMessages = true;
|
||||
} else if (this.checkShouldScroll()) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
this.setState((previousState) => {
|
||||
return {
|
||||
...previousState,
|
||||
newMessagesReceived: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
// update document title if window blurred
|
||||
if (
|
||||
this.numMessagesSinceBlur &&
|
||||
!this.props.readonly &&
|
||||
this.windowBlurred
|
||||
) {
|
||||
this.updateDocumentTitle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateDocumentTitle() {
|
||||
const num =
|
||||
this.numMessagesSinceBlur > 10 ? '10+' : this.numMessagesSinceBlur;
|
||||
window.document.title = `${num} 💬 :: ${this.props.instanceTitle}`;
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } =
|
||||
props;
|
||||
const { sortedMessages, chatUserNames, webSocketConnected, isModerator } =
|
||||
state;
|
||||
|
||||
const messageList = sortedMessages.map(
|
||||
(message) =>
|
||||
html`<${Message}
|
||||
message=${message}
|
||||
username=${username}
|
||||
key=${message.id}
|
||||
isModerator=${isModerator}
|
||||
accessToken=${accessToken}
|
||||
/>`
|
||||
);
|
||||
|
||||
if (readonly) {
|
||||
return html`
|
||||
<div
|
||||
id="messages-container"
|
||||
ref=${this.scrollableMessagesContainer}
|
||||
class="scrollbar-hidden py-1 overflow-auto"
|
||||
>
|
||||
${messageList}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<section id="chat-container-wrap" class="flex flex-col">
|
||||
<div
|
||||
id="chat-container"
|
||||
class="bg-gray-800 flex flex-col justify-end overflow-auto"
|
||||
>
|
||||
<div
|
||||
id="messages-container"
|
||||
ref=${this.scrollableMessagesContainer}
|
||||
class="scrollbar-hidden py-1 overflow-auto z-10"
|
||||
>
|
||||
${messageList}
|
||||
</div>
|
||||
<${ChatInput}
|
||||
chatUserNames=${chatUserNames}
|
||||
inputEnabled=${webSocketConnected && chatInputEnabled}
|
||||
handleSendMessage=${this.submitChat}
|
||||
inputMaxBytes=${inputMaxBytes}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
129
webroot/js/components/chat/content-editable.js
Normal file
129
webroot/js/components/chat/content-editable.js
Normal file
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
Since we can't really import react-contenteditable here, I'm borrowing code for this component from here:
|
||||
github.com/lovasoa/react-contenteditable/
|
||||
|
||||
and here:
|
||||
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
|
||||
|
||||
*/
|
||||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
|
||||
export function replaceCaret(el) {
|
||||
// Place the caret at the end of the element
|
||||
const target = document.createTextNode('');
|
||||
el.appendChild(target);
|
||||
// do not move caret if element was not focused
|
||||
const isTargetFocused = document.activeElement === el;
|
||||
if (target !== null && target.nodeValue !== null && isTargetFocused) {
|
||||
var sel = window.getSelection();
|
||||
if (sel !== null) {
|
||||
var range = document.createRange();
|
||||
range.setStart(target, target.nodeValue.length);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
if (el) el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtml(str) {
|
||||
return str && str.replace(/ |\u202F|\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default class ContentEditable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.el = createRef();
|
||||
|
||||
this.lastHtml = '';
|
||||
|
||||
this.emitChange = this.emitChange.bind(this);
|
||||
this.getDOMElement = this.getDOMElement.bind(this);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const { props } = this;
|
||||
const el = this.getDOMElement();
|
||||
|
||||
// We need not rerender if the change of props simply reflects the user's edits.
|
||||
// Rerendering in this case would make the cursor/caret jump
|
||||
|
||||
// Rerender if there is no element yet... (somehow?)
|
||||
if (!el) return true;
|
||||
|
||||
// ...or if html really changed... (programmatically, not by user edit)
|
||||
if (
|
||||
normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle additional properties
|
||||
return props.disabled !== nextProps.disabled ||
|
||||
props.tagName !== nextProps.tagName ||
|
||||
props.className !== nextProps.className ||
|
||||
props.innerRef !== nextProps.innerRef;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const el = this.getDOMElement();
|
||||
if (!el) return;
|
||||
|
||||
// Perhaps React (whose VDOM gets outdated because we often prevent
|
||||
// rerendering) did not update the DOM. So we update it manually now.
|
||||
if (this.props.html !== el.innerHTML) {
|
||||
el.innerHTML = this.props.html;
|
||||
}
|
||||
this.lastHtml = this.props.html;
|
||||
replaceCaret(el);
|
||||
}
|
||||
|
||||
getDOMElement() {
|
||||
return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current;
|
||||
}
|
||||
|
||||
|
||||
emitChange(originalEvt) {
|
||||
const el = this.getDOMElement();
|
||||
if (!el) return;
|
||||
|
||||
const html = el.innerHTML;
|
||||
if (this.props.onChange && html !== this.lastHtml) {
|
||||
// Clone event with Object.assign to avoid
|
||||
// "Cannot assign to read only property 'target' of object"
|
||||
const evt = Object.assign({}, originalEvt, {
|
||||
target: {
|
||||
value: html
|
||||
}
|
||||
});
|
||||
this.props.onChange(evt);
|
||||
}
|
||||
this.lastHtml = html;
|
||||
}
|
||||
|
||||
render(props) {
|
||||
const { html, innerRef } = props;
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
...props,
|
||||
ref: typeof innerRef === 'function' ? (current) => {
|
||||
innerRef(current)
|
||||
this.el.current = current
|
||||
} : innerRef || this.el,
|
||||
onInput: this.emitChange,
|
||||
onFocus: this.props.onFocus || this.emitChange,
|
||||
onBlur: this.props.onBlur || this.emitChange,
|
||||
onKeyup: this.props.onKeyUp || this.emitChange,
|
||||
onKeydown: this.props.onKeyDown || this.emitChange,
|
||||
contentEditable: !this.props.disabled,
|
||||
dangerouslySetInnerHTML: { __html: html },
|
||||
},
|
||||
this.props.children,
|
||||
);
|
||||
}
|
||||
}
|
140
webroot/js/components/chat/message.js
Normal file
140
webroot/js/components/chat/message.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { h } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import ChatMessageView from './chat-message-view.js';
|
||||
|
||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
import { checkIsModerator } from '../../utils/chat.js';
|
||||
|
||||
function SystemMessage(props) {
|
||||
const { contents } = props;
|
||||
return html`
|
||||
<div
|
||||
class="message message-name-change flex items-center justify-start p-3"
|
||||
>
|
||||
<div
|
||||
class="message-content flex flex-row items-center justify-center text-sm w-full"
|
||||
>
|
||||
<div
|
||||
class="text-gray-400 w-full text-center opacity-90 overflow-hidden break-words"
|
||||
>
|
||||
${contents}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function SingleFederatedUser(props) {
|
||||
const { message } = props;
|
||||
const { type, body, title, image, link } = message;
|
||||
|
||||
let icon = null;
|
||||
switch (type) {
|
||||
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW:
|
||||
icon = '/img/follow.svg';
|
||||
break;
|
||||
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE:
|
||||
icon = '/img/like.svg';
|
||||
break;
|
||||
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST:
|
||||
icon = '/img/repost.svg';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return html`
|
||||
<a
|
||||
href=${link}
|
||||
target="_blank"
|
||||
class="hover:no-underline"
|
||||
title="Visit profile"
|
||||
>
|
||||
<div
|
||||
class="federated-action m-2 mt-3 bg-white flex items-center px-2 rounded-xl shadow border"
|
||||
>
|
||||
<div class="relative" style="top: -6px">
|
||||
<img
|
||||
src="${image || '/img/logo.svg'}"
|
||||
style="max-width: unset"
|
||||
class="rounded-full border border-slate-500 w-16"
|
||||
/>
|
||||
<span
|
||||
style=${{ backgroundImage: `url(${icon})` }}
|
||||
class="absolute h-6 w-6 rounded-full border-2 border-white action-icon"
|
||||
></span>
|
||||
</div>
|
||||
<div class="px-4 py-2 min-w-0">
|
||||
<div class="text-gray-500 text-sm hover:no-underline truncate">
|
||||
${title}
|
||||
</div>
|
||||
<p
|
||||
class=" text-gray-700 w-full text-base leading-6"
|
||||
dangerouslySetInnerHTML=${{ __html: body }}
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
export default function Message(props) {
|
||||
const { message } = props;
|
||||
const { type, oldName, user, body } = message;
|
||||
if (
|
||||
type === SOCKET_MESSAGE_TYPES.CHAT ||
|
||||
type === SOCKET_MESSAGE_TYPES.SYSTEM
|
||||
) {
|
||||
return html`<${ChatMessageView} ...${props} />`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||
// User changed their name
|
||||
const { displayName } = user;
|
||||
const contents = html`
|
||||
<div>
|
||||
<span class="font-bold">${oldName}</span> is now known as ${' '}
|
||||
<span class="font-bold">${displayName}</span>.
|
||||
</div>
|
||||
`;
|
||||
return html`<${SystemMessage} contents=${contents} />`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
|
||||
const { displayName } = user;
|
||||
const isAuthorModerator = checkIsModerator(message);
|
||||
const messageAuthorFlair = isAuthorModerator
|
||||
? html`<img
|
||||
title="Moderator"
|
||||
class="inline-block mr-1 w-3 h-3"
|
||||
src="/img/moderator-nobackground.svg"
|
||||
/>`
|
||||
: null;
|
||||
const contents = html`<div>
|
||||
<span class="font-bold">${messageAuthorFlair}${displayName}</span>
|
||||
${' '}joined the chat.
|
||||
</div>`;
|
||||
return html`<${SystemMessage} contents=${contents} />`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) {
|
||||
const contents = html`<span
|
||||
dangerouslySetInnerHTML=${{ __html: body }}
|
||||
></span>`;
|
||||
return html`<${SystemMessage} contents=${contents} />`;
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
|
||||
// moderator message
|
||||
const isModerator = checkIsModerator(message);
|
||||
if (isModerator) {
|
||||
const contents = html`<div class="rounded-lg bg-gray-700 p-3">
|
||||
<img src="/img/moderator.svg" class="moderator-flag" />You are now a
|
||||
moderator.
|
||||
</div>`;
|
||||
return html`<${SystemMessage} contents=${contents} />`;
|
||||
}
|
||||
} else if (
|
||||
type === SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW ||
|
||||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE ||
|
||||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST
|
||||
) {
|
||||
return html` <${SingleFederatedUser} message=${message} /> `;
|
||||
} else {
|
||||
console.log('Unknown message type:', type);
|
||||
}
|
||||
}
|
298
webroot/js/components/chat/moderator-actions.js
Normal file
298
webroot/js/components/chat/moderator-actions.js
Normal file
|
@ -0,0 +1,298 @@
|
|||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
import { textColorForHue } from '../../utils/user-colors.js';
|
||||
import { URL_BAN_USER, URL_HIDE_MESSAGE } from '../../utils/constants.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const HIDE_MESSAGE_ICON = `/img/hide-message-grey.svg`;
|
||||
const HIDE_MESSAGE_ICON_HOVER = '/img/hide-message.svg';
|
||||
const BAN_USER_ICON = '/img/ban-user-grey.svg';
|
||||
const BAN_USER_ICON_HOVER = '/img/ban-user.svg';
|
||||
|
||||
export default class ModeratorActions extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isMenuOpen: false,
|
||||
};
|
||||
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||
}
|
||||
|
||||
handleOpenMenu() {
|
||||
this.setState({
|
||||
isMenuOpen: true,
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseMenu() {
|
||||
this.setState({
|
||||
isMenuOpen: false,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isMenuOpen } = this.state;
|
||||
const { message, accessToken } = this.props;
|
||||
const { id } = message;
|
||||
const { user } = message;
|
||||
|
||||
return html`
|
||||
<div class="moderator-actions-group flex flex-row text-xs p-3">
|
||||
<button
|
||||
type="button"
|
||||
class="moderator-menu-button"
|
||||
onClick=${this.handleOpenMenu}
|
||||
title="Moderator actions"
|
||||
alt="Moderator actions"
|
||||
aria-haspopup="true"
|
||||
aria-controls="open-mod-actions-menu"
|
||||
aria-expanded=${isMenuOpen}
|
||||
id="open-mod-actions-button"
|
||||
>
|
||||
<img src="/img/menu-vert.svg" alt="" />
|
||||
</button>
|
||||
|
||||
${isMenuOpen &&
|
||||
html`<${ModeratorMenu}
|
||||
message=${message}
|
||||
onDismiss=${this.handleCloseMenu}
|
||||
accessToken=${accessToken}
|
||||
id=${id}
|
||||
userId=${user.id}
|
||||
/>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class ModeratorMenu extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.menuNode = createRef();
|
||||
|
||||
this.state = {
|
||||
displayMoreInfo: false,
|
||||
};
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
this.handleToggleMoreInfo = this.handleToggleMoreInfo.bind(this);
|
||||
this.handleBanUser = this.handleBanUser.bind(this);
|
||||
this.handleHideMessage = this.handleHideMessage.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.handleClickOutside, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.handleClickOutside, false);
|
||||
}
|
||||
|
||||
handleClickOutside = (e) => {
|
||||
if (
|
||||
this.menuNode &&
|
||||
!this.menuNode.current.contains(e.target) &&
|
||||
this.props.onDismiss
|
||||
) {
|
||||
this.props.onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleMoreInfo() {
|
||||
this.setState({
|
||||
displayMoreInfo: !this.state.displayMoreInfo,
|
||||
});
|
||||
}
|
||||
|
||||
async handleHideMessage() {
|
||||
if (!confirm('Are you sure you want to remove this message from chat?')) {
|
||||
this.props.onDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken, id } = this.props;
|
||||
const url = new URL(location.origin + URL_HIDE_MESSAGE);
|
||||
url.searchParams.append('accessToken', accessToken);
|
||||
const hideMessageUrl = url.toString();
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ idArray: [id] }),
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(hideMessageUrl, options);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
this.props.onDismiss();
|
||||
}
|
||||
|
||||
async handleBanUser() {
|
||||
if (!confirm('Are you sure you want to remove this user from chat?')) {
|
||||
this.props.onDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken, userId } = this.props;
|
||||
const url = new URL(location.origin + URL_BAN_USER);
|
||||
url.searchParams.append('accessToken', accessToken);
|
||||
const hideMessageUrl = url.toString();
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId: userId }),
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(hideMessageUrl, options);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
this.props.onDismiss();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { message } = this.props;
|
||||
const { displayMoreInfo } = this.state;
|
||||
return html`
|
||||
<ul
|
||||
role="menu"
|
||||
id="open-mod-actions-menu"
|
||||
aria-labelledby="open-mod-actions-button"
|
||||
class="moderator-actions-menu bg-gray-700 rounded-lg shadow-md"
|
||||
ref=${this.menuNode}
|
||||
>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon=${HIDE_MESSAGE_ICON}
|
||||
hoverIcon=${HIDE_MESSAGE_ICON_HOVER}
|
||||
label="Hide message"
|
||||
onClick="${this.handleHideMessage}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon=${BAN_USER_ICON}
|
||||
hoverIcon=${BAN_USER_ICON_HOVER}
|
||||
label="Ban user"
|
||||
onClick="${this.handleBanUser}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<${ModeratorMenuItem}
|
||||
icon="/img/menu.svg"
|
||||
label="More Info"
|
||||
onClick=${this.handleToggleMoreInfo}
|
||||
/>
|
||||
</li>
|
||||
${displayMoreInfo &&
|
||||
html`<${ModeratorMoreInfoContainer}
|
||||
message=${message}
|
||||
handleBanUser=${this.handleBanUser}
|
||||
handleHideMessage=${this.handleHideMessage}
|
||||
/>`}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3 dots button
|
||||
function ModeratorMenuItem({ icon, hoverIcon, label, onClick }) {
|
||||
return html`
|
||||
<button
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick=${onClick}
|
||||
className="moderator-menu-item w-full py-2 px-4 text-white text-left whitespace-no-wrap rounded-lg hover:bg-gray-600"
|
||||
>
|
||||
${icon &&
|
||||
html`<span
|
||||
className="moderator-menu-icon menu-icon-base inline-block align-bottom mr-4"
|
||||
><img src="${icon}"
|
||||
/></span>`}
|
||||
<span
|
||||
className="moderator-menu-icon menu-icon-hover inline-block align-bottom mr-4"
|
||||
><img src="${hoverIcon || icon}"
|
||||
/></span>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// more details panel that display message, prev usernames, actions
|
||||
function ModeratorMoreInfoContainer({
|
||||
message,
|
||||
handleHideMessage,
|
||||
handleBanUser,
|
||||
}) {
|
||||
const { user, timestamp, body } = message;
|
||||
const { displayName, createdAt, previousNames, displayColor } = user;
|
||||
const isAuthorModerator = user.scopes && user.scopes.includes('MODERATOR');
|
||||
|
||||
const authorTextColor = { color: textColorForHue(displayColor) };
|
||||
const createDate = new Date(createdAt);
|
||||
const sentDate = new Date(timestamp);
|
||||
return html`
|
||||
<div
|
||||
className="moderator-more-info-container text-gray-300 bg-gray-800 rounded-lg p-4 border border-white text-base absolute"
|
||||
>
|
||||
<div
|
||||
className="moderator-more-info-message scrollbar-hidden bg-gray-700 rounded-md pb-2"
|
||||
>
|
||||
<p className="text-xs text-gray-500">
|
||||
Sent at ${sentDate.toLocaleTimeString()}
|
||||
</p>
|
||||
<div className="text-sm" dangerouslySetInnerHTML=${{ __html: body }} />
|
||||
</div>
|
||||
<div className="moderator-more-info-user py-2 my-2">
|
||||
<p className="text-xs text-gray-500">Sent by:</p>
|
||||
<p
|
||||
className="font-bold ${isAuthorModerator && ' moderator-flag'}"
|
||||
style=${authorTextColor}
|
||||
>
|
||||
${displayName}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
First joined: ${createDate.toLocaleString()}
|
||||
</p>
|
||||
|
||||
${previousNames.length > 1 &&
|
||||
html`
|
||||
<p className="text-xs text-gray-500 my-1">
|
||||
Previously known as: ${' '}
|
||||
<span className="text-white text-gray-400"
|
||||
>${previousNames.join(', ')}</span
|
||||
>
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
<div
|
||||
className="moderator-more-info-actions pt-2 flex flex-row border-t border-gray-700 shadow-md"
|
||||
>
|
||||
<${handleHideMessage && ModeratorMenuItem}
|
||||
icon=${HIDE_MESSAGE_ICON}
|
||||
hoverIcon=${HIDE_MESSAGE_ICON_HOVER}
|
||||
label="Hide message"
|
||||
onClick="${handleHideMessage}"
|
||||
/>
|
||||
<${handleBanUser && ModeratorMenuItem}
|
||||
icon=${BAN_USER_ICON}
|
||||
hoverIcon=${BAN_USER_ICON_HOVER}
|
||||
label="Ban user"
|
||||
onClick="${handleBanUser}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
158
webroot/js/components/chat/username.js
Normal file
158
webroot/js/components/chat/username.js
Normal file
|
@ -0,0 +1,158 @@
|
|||
import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { setLocalStorage } from '../../utils/helpers.js';
|
||||
import {
|
||||
KEY_USERNAME,
|
||||
KEY_CUSTOM_USERNAME_SET,
|
||||
} from '../../utils/constants.js';
|
||||
|
||||
export default class UsernameForm extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
displayForm: false,
|
||||
isFocused: false,
|
||||
};
|
||||
|
||||
this.textInput = createRef();
|
||||
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
this.handleDisplayForm = this.handleDisplayForm.bind(this);
|
||||
this.handleHideForm = this.handleHideForm.bind(this);
|
||||
this.handleUpdateUsername = this.handleUpdateUsername.bind(this);
|
||||
this.handleFocus = this.handleFocus.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
}
|
||||
|
||||
handleDisplayForm() {
|
||||
const { displayForm: curDisplay } = this.state;
|
||||
this.setState({
|
||||
displayForm: !curDisplay,
|
||||
});
|
||||
}
|
||||
|
||||
handleHideForm() {
|
||||
this.setState({
|
||||
displayForm: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.keyCode === 13) {
|
||||
// enter
|
||||
this.handleUpdateUsername();
|
||||
} else if (event.keyCode === 27) {
|
||||
// esc
|
||||
this.handleHideForm();
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateUsername() {
|
||||
const { username: curName, onUsernameChange } = this.props;
|
||||
let newName = this.textInput.current.value;
|
||||
newName = newName.trim();
|
||||
if (newName !== '' && newName !== curName) {
|
||||
setLocalStorage(KEY_USERNAME, newName);
|
||||
// So we know that the user has set a custom name
|
||||
setLocalStorage(KEY_CUSTOM_USERNAME_SET, true);
|
||||
if (onUsernameChange) {
|
||||
onUsernameChange(newName);
|
||||
}
|
||||
this.handleHideForm();
|
||||
}
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
const { onFocus } = this.props;
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
const { onBlur } = this.props;
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, isModerator } = props;
|
||||
const { displayForm } = state;
|
||||
|
||||
const styles = {
|
||||
info: {
|
||||
display: displayForm ? 'none' : 'flex',
|
||||
},
|
||||
form: {
|
||||
display: displayForm ? 'flex' : 'none',
|
||||
},
|
||||
};
|
||||
|
||||
const moderatorFlag = html`
|
||||
<img src="/img/moderator-nobackground.svg" class="moderator-flag" />
|
||||
`;
|
||||
const userIcon = html`
|
||||
<img src="/img/user-icon.svg" class="user-icon-flag" />
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div id="user-info" class="whitespace-nowrap">
|
||||
<div
|
||||
id="user-info-display"
|
||||
style=${styles.info}
|
||||
title="Click to update user name"
|
||||
class="flex flex-row justify-end items-center align-middle cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75"
|
||||
onClick=${this.handleDisplayForm}
|
||||
>
|
||||
<span
|
||||
id="username-display"
|
||||
class="text-indigo-100 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap ${isModerator &&
|
||||
'moderator-flag'}"
|
||||
>${isModerator ? moderatorFlag : userIcon}${username}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="user-info-change"
|
||||
class="flex flex-row flex-no-wrap p-1 items-center justify-end"
|
||||
style=${styles.form}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="username-change-input"
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight text-xs focus:bg-white"
|
||||
maxlength="60"
|
||||
placeholder="Update username"
|
||||
defaultValue=${username}
|
||||
onKeydown=${this.handleKeydown}
|
||||
onFocus=${this.handleFocus}
|
||||
onBlur=${this.handleBlur}
|
||||
ref=${this.textInput}
|
||||
/>
|
||||
<button
|
||||
id="button-update-username"
|
||||
onClick=${this.handleUpdateUsername}
|
||||
type="button"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white text-xs uppercase p-1 mx-1 rounded cursor-pointer user-btn"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="button-cancel-change"
|
||||
onClick=${this.handleHideForm}
|
||||
type="button"
|
||||
class="bg-gray-900 hover:bg-gray-800 py-1 px-2 mx-1 rounded cursor-pointer user-btn text-white text-xs uppercase text-opacity-50"
|
||||
title="cancel"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue