stream doesn't want to work

This commit is contained in:
root 2022-10-01 14:30:18 +02:00
parent d7ed57bbdd
commit 9656926a07
287 changed files with 6934 additions and 0 deletions

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

View 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, '');
}

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

View 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(/&nbsp;|\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,
);
}
}

View 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);
}
}

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

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