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

View file

@ -0,0 +1,128 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import MicroModal from '/js/web_modules/micromodal/dist/micromodal.min.js';
const html = htm.bind(h);
export default class ExternalActionModal extends Component {
constructor(props) {
super(props);
this.state = {
iframeLoaded: false,
};
this.setIframeLoaded = this.setIframeLoaded.bind(this);
}
componentDidMount() {
// initalize and display Micromodal on mount
try {
MicroModal.init({
awaitCloseAnimation: false,
awaitOpenAnimation: true, // if using css animations to open the modal. This allows it to wait for the animation to finish before focusing on an element inside the modal.
});
MicroModal.show('external-actions-modal', {
onClose: this.props.onClose,
});
} catch (e) {
console.error('modal error: ', e);
}
}
setIframeLoaded() {
this.setState({
iframeLoaded: true,
});
}
render() {
const { action, useIframe = true, customContent = null } = this.props;
const { url, title, description } = action;
const { iframeLoaded } = this.state;
const iframeStyle = iframeLoaded
? null
: { backgroundImage: 'url(/img/loading.gif)' };
return html`
<div
class="modal micromodal-slide"
id="external-actions-modal"
aria-hidden="true"
>
<div class="modal__overlay" tabindex="-1" data-micromodal-close>
<div
id="modal-container"
class="modal__container rounded-md"
role="dialog"
aria-modal="true"
aria-labelledby="modal-1-title"
>
<header
id="modal-header"
class="modal__header flex flex-row justify-between items-center bg-gray-300 p-3 rounded-t-md"
>
<h2
id="external-action-modal-header"
class="modal__title text-indigo-600 font-semibold"
>
${title || description}
</h2>
<button
class="modal__close"
aria-label="Close modal"
data-micromodal-close
></button>
</header>
<div
id="modal-content-content"
class="modal-content-content rounded-b-md"
>
${useIframe
? html`
<div
id="modal-content"
class="modal__content text-gray-600 overflow-y-auto overflow-x-hidden"
>
<iframe
id="external-modal-iframe"
style=${iframeStyle}
class="bg-gray-100 bg-center bg-no-repeat"
width="100%"
allowpaymentrequest="true"
allowfullscreen="false"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
src=${url}
onload=${this.setIframeLoaded}
/>
</div>
`
: customContent}
</div>
</div>
</div>
</div>
`;
}
}
export function ExternalActionButton({ action, onClick, label = '' }) {
const { title, icon, color = undefined, description } = action;
const logo =
icon &&
html`
<span class="external-action-icon"><img src=${icon} alt="" /></span>
`;
const bgcolor = color && { backgroundColor: `${color}` };
const handleClick = () => onClick(action);
return html`
<button
class="external-action-button rounded-sm flex flex-row justify-center items-center overflow-hidden m-1 px-3 py-1 text-base text-white bg-gray-800 rounded"
onClick=${handleClick}
style=${bgcolor}
aria-label=${description}
title=${description || title}
>
${logo}
<span class="external-action-label">${label || title}</span>
</button>
`;
}

View file

@ -0,0 +1,134 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { URL_FOLLOWERS } from '/js/utils/constants.js';
const html = htm.bind(h);
import { paginateArray } from '../../utils/helpers.js';
export default class FollowerList extends Component {
constructor(props) {
super(props);
this.state = {
followers: [],
followersPage: 0,
};
}
componentDidMount() {
try {
this.getFollowers();
} catch (e) {
console.error('followers error: ', e);
}
}
async getFollowers() {
const response = await fetch(URL_FOLLOWERS);
const followers = await response.json();
this.setState({
followers: followers,
});
}
changeFollowersPage(page) {
this.setState({ followersPage: page });
}
render() {
const FOLLOWER_PAGE_SIZE = 16;
const { followersPage } = this.state;
const { followers } = this.state;
if (!followers) {
return null;
}
const noFollowersInfo = html`<div>
<p class="mb-5 text-2xl">Be the first to follow this live stream.</p>
<p class="text-md">
By following this stream you'll get updates when it goes live, receive
posts from the streamer, and be featured here as a follower.
</p>
<p class="text-md mt-5">
Learn more about ${' '}
<a class="underline" href="https://en.wikipedia.org/wiki/Fediverse"
>The Fediverse</a
>, where you can follow this server as well as so much more.
</p>
</div>`;
const paginatedFollowers = paginateArray(
followers,
followersPage + 1,
FOLLOWER_PAGE_SIZE
);
const paginationControls =
paginatedFollowers.totalPages > 1 &&
Array(paginatedFollowers.totalPages)
.fill()
.map((x, n) => {
const activePageClass =
n === followersPage &&
'bg-indigo-600 rounded-full shadow-md focus:shadow-md text-white';
return html` <li class="page-item active">
<a
class="page-link relative block cursor-pointer hover:no-underline py-1.5 px-3 border-0 rounded-full hover:text-gray-800 hover:bg-gray-200 outline-none transition-all duration-300 ${activePageClass}"
onClick=${() => this.changeFollowersPage(n)}
>
${n + 1}
</a>
</li>`;
});
return html`
<div>
<div class="flex flex-wrap">
${followers.length === 0 && noFollowersInfo}
${paginatedFollowers.items.map((follower) => {
return html` <${SingleFollower} user=${follower} /> `;
})}
</div>
<div class="flex">
<nav aria-label="Page navigation example">
<ul class="flex list-style-none">
${paginationControls}
</ul>
</nav>
</div>
</div>
`;
}
}
function SingleFollower(props) {
const { user } = props;
const { name, username, link, image } = user;
var displayName = name;
var displayUsername = username;
if (!displayName) {
displayName = displayUsername.split('@', 1)[0];
}
return html`
<a
href=${link}
class="following-list-follower block bg-white flex p-2 rounded-xl shadow border hover:no-underline mb-3 mr-3"
target="_blank"
>
<img
src="${image || '/img/logo.svg'}"
class="w-16 h-16 rounded-full"
onError=${({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = '/img/logo.svg';
}}
/>
<div class="p-3 truncate flex-grow">
<p class="font-semibold text-gray-700 truncate">${displayName}</p>
<p class="text-sm text-gray-500 truncate">${displayUsername}</p>
</div>
</a>
`;
}

View file

@ -0,0 +1,192 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { ExternalActionButton } from './external-action-modal.js';
const html = htm.bind(h);
function validateAccount(account) {
account = account.replace(/^@+/, '');
var regex =
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return regex.test(String(account).toLowerCase());
}
export default class FediverseFollowModal extends Component {
constructor(props) {
super(props);
this.remoteFollowButtonPressed = this.remoteFollowButtonPressed.bind(this);
this.state = {
errorMessage: null,
value: '',
loading: false,
valid: false,
};
}
async remoteFollowButtonPressed() {
if (!this.state.valid) {
return;
}
this.setState({ loading: true, errorMessage: null });
const { value } = this.state;
const { onClose } = this.props;
const account = value.replace(/^@+/, '');
const request = { account: account };
const requestURL = '/api/remotefollow';
const rawResponse = await fetch(requestURL, {
method: 'POST',
body: JSON.stringify(request),
});
const result = await rawResponse.json();
if (!result.redirectUrl) {
this.setState({ errorMessage: result.message, loading: false });
return;
}
window.open(result.redirectUrl, '_blank');
onClose();
}
navigateToFediverseJoinPage() {
window.open('https://owncast.online/join-fediverse', '_blank');
}
onInput = (e) => {
const { value } = e.target;
const valid = validateAccount(value);
this.setState({ value, valid });
};
render() {
const { name, federationInfo = {}, logo } = this.props;
const { account } = federationInfo;
const { errorMessage, value, valid, loading } = this.state;
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
const error = errorMessage
? html`
<div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<div class="font-bold mb-2">
There was an error following this Owncast server.
</div>
<span class="block">
Please verify you entered the correct user account. It's also
possible your server may not support remote following, so you may
want to manually follow ${' '}
<span class="font-semibold">${account}</span> using your service's
own interface.
</span>
<div class="block mt-2">
Server error: <span class="">${errorMessage}</span>
</div>
</div>
`
: null;
const loaderStyle = loading ? 'flex' : 'none';
return html`
<div class="bg-gray-100 bg-center bg-no-repeat p-4">
<p class="text-gray-700 text-md">
By following this stream you'll get posts and notifications such as
when it goes live.
</p>
<div
class="p-4 my-2 rounded bg-gray-300 border border-indigo-400 border-solid flex items-center justify-start"
>
<img src=${logo} style=${{ height: '3em', width: '3em' }} />
<p class="ml-4">
<span class="font-bold">${name}</span>
<br />
<span class="">${account}</span>
</p>
</div>
${error}
<div class="mb34">
<label
class="block text-gray-700 text-sm font-semibold mt-6"
for="username"
>
Enter your username@server to follow:
</label>
<input
onInput=${this.onInput}
value="${value}"
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
placeholder="Fediverse account@instance.tld"
/>
</div>
<p class="text-gray-600 text-xs italic">
You'll be redirected to your Fediverse server and asked to confirm
this action. ${' '}
<a
class=" text-blue-500"
href="https://owncast.online/join-fediverse"
target="_blank"
rel="noopener noreferrer"
>Join the Fediverse if you haven't.</a
>
</p>
<button
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
type="button"
onClick=${this.remoteFollowButtonPressed}
>
Follow
</button>
<button
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 ml-4 mt-6 px-4 rounded focus:outline-none focus:shadow-outline"
type="button"
onClick=${this.navigateToFediverseJoinPage}
>
Join the Fediverse
</button>
<div
id="follow-loading-spinner-container"
style="display: ${loaderStyle}"
>
<img id="follow-loading-spinner" src="/img/loading.gif" />
<p class="text-gray-700 text-lg">Contacting your server.</p>
<p class="text-gray-600 text-lg">Please wait...</p>
</div>
</div>
`;
}
}
export function FediverseFollowButton({ serverName, federationInfo, onClick }) {
const fediverseFollowAction = {
color: 'rgba(28, 26, 59, 1)',
description: `Follow ${serverName} at ${federationInfo.account}`,
icon: '/img/fediverse-color.png',
openExternally: false,
title: `Follow ${serverName}`,
url: '',
};
const handleClick = () => onClick(fediverseFollowAction);
return html`
<span id="fediverse-follow-button-container">
<${ExternalActionButton}
onClick=${handleClick}
action=${fediverseFollowAction}
label="Follow"
/>
</span>
`;
}

View file

@ -0,0 +1,70 @@
import { h } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { classNames } from '../utils/helpers.js';
const html = htm.bind(h);
function SocialIcon(props) {
const { platform, icon, url } = props;
const iconSupplied = !!icon;
const name = platform;
const finalIcon = iconSupplied ? icon : '/img/platformlogos/default.svg';
const style = `background-image: url(${finalIcon});`;
const itemClass = classNames({
'user-social-item': true,
flex: true,
'justify-start': true,
'items-center': true,
'm-1': true,
});
const labelClass = classNames({
'platform-label': true,
'visually-hidden': !!finalIcon,
'text-indigo-800': true,
'text-xs': true,
uppercase: true,
'max-w-xs': true,
'inline-block': true,
});
return html`
<a class=${itemClass} target="_blank" rel="me" href=${url}>
<span
class="platform-icon rounded-lg bg-no-repeat"
style=${style}
title="Find me on ${name}"
></span>
<span class=${labelClass}>Find me on ${name}</span>
</a>
`;
}
export default function (props) {
const { handles } = props;
if (handles == null) {
return null;
}
const list = handles.map(
(item, index) => html`
<li key="social${index}">
<${SocialIcon}
platform=${item.platform}
icon=${item.icon}
url=${item.url}
/>
</li>
`
);
return html` <ul id="social-list" class="social-list m-2 text-center">
<p
class="follow-icon-list flex flex-row items-center justify-center flex-wrap"
>
${list}
</p>
</ul>`;
}

View file

@ -0,0 +1,261 @@
// https://docs.videojs.com/player
import videojs from '/js/web_modules/videojs/dist/video.min.js';
import { getLocalStorage, setLocalStorage } from '../utils/helpers.js';
import { PLAYER_VOLUME, URL_STREAM } from '../utils/constants.js';
const VIDEO_ID = 'video';
// Video setup
const VIDEO_SRC = {
src: URL_STREAM,
type: 'application/x-mpegURL',
};
const VIDEO_OPTIONS = {
autoplay: false,
liveui: true,
preload: 'auto',
controlBar: {
progressControl: {
seekBar: false,
},
},
html5: {
vhs: {
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
enableLowInitialPlaylist: true,
experimentalBufferBasedABR: true,
maxPlaylistRetries: 30,
},
},
liveTracker: {
trackingThreshold: 0,
},
sources: [VIDEO_SRC],
};
export const POSTER_DEFAULT = `/img/logo.png`;
export const POSTER_THUMB = `/thumbnail.jpg`;
class OwncastPlayer {
constructor() {
window.VIDEOJS_NO_DYNAMIC_STYLE = true; // style override
this.vjsPlayer = null;
this.appPlayerReadyCallback = null;
this.appPlayerPlayingCallback = null;
this.appPlayerEndedCallback = null;
// bind all the things because safari
this.startPlayer = this.startPlayer.bind(this);
this.handleReady = this.handleReady.bind(this);
this.handlePlaying = this.handlePlaying.bind(this);
this.handleVolume = this.handleVolume.bind(this);
this.handleEnded = this.handleEnded.bind(this);
this.handleError = this.handleError.bind(this);
this.addQualitySelector = this.addQualitySelector.bind(this);
this.qualitySelectionMenu = null;
}
init() {
this.addAirplay();
this.addQualitySelector();
videojs.Vhs.xhr.beforeRequest = (options) => {
if (options.uri.match('m3u8')) {
const cachebuster = Math.round(new Date().getTime() / 1000);
options.uri = `${options.uri}?cachebust=${cachebuster}`;
}
return options;
};
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS);
this.vjsPlayer.ready(this.handleReady);
}
setupPlayerCallbacks(callbacks) {
const { onReady, onPlaying, onEnded, onError } = callbacks;
this.appPlayerReadyCallback = onReady;
this.appPlayerPlayingCallback = onPlaying;
this.appPlayerEndedCallback = onEnded;
this.appPlayerErrorCallback = onError;
}
// play
startPlayer() {
this.log('Start playing');
const source = { ...VIDEO_SRC };
try {
this.vjsPlayer.volume(getLocalStorage(PLAYER_VOLUME) || 1);
} catch (err) {
console.warn(err);
}
this.vjsPlayer.src(source);
// this.vjsPlayer.play();
}
handleReady() {
this.log('on Ready');
this.vjsPlayer.on('error', this.handleError);
this.vjsPlayer.on('playing', this.handlePlaying);
this.vjsPlayer.on('volumechange', this.handleVolume);
this.vjsPlayer.on('ended', this.handleEnded);
if (this.appPlayerReadyCallback) {
// start polling
this.appPlayerReadyCallback();
}
}
handleVolume() {
setLocalStorage(
PLAYER_VOLUME,
this.vjsPlayer.muted() ? 0 : this.vjsPlayer.volume()
);
}
handlePlaying() {
this.log('on Playing');
if (this.appPlayerPlayingCallback) {
// start polling
this.appPlayerPlayingCallback();
}
}
handleEnded() {
this.log('on Ended');
if (this.appPlayerEndedCallback) {
this.appPlayerEndedCallback();
}
}
handleError(e) {
this.log(`on Error: ${JSON.stringify(e)}`);
if (this.appPlayerEndedCallback) {
this.appPlayerEndedCallback();
}
}
log(message) {
// console.log(`>>> Player: ${message}`);
}
async addQualitySelector() {
if (this.qualityMenuButton) {
player.controlBar.removeChild(this.qualityMenuButton);
}
videojs.hookOnce(
'setup',
async function (player) {
var qualities = [];
try {
const response = await fetch('/api/video/variants');
qualities = await response.json();
} catch (e) {
console.error(e);
}
var MenuItem = videojs.getComponent('MenuItem');
var MenuButtonClass = videojs.getComponent('MenuButton');
var MenuButton = videojs.extend(MenuButtonClass, {
// The `init()` method will also work for constructor logic here, but it is
// deprecated. If you provide an `init()` method, it will override the
// `constructor()` method!
constructor: function () {
MenuButtonClass.call(this, player);
},
createItems: function () {
const defaultAutoItem = new MenuItem(player, {
selectable: true,
label: 'Auto',
});
const items = qualities.map(function (item) {
var newMenuItem = new MenuItem(player, {
selectable: true,
label: item.name,
});
// Quality selected
newMenuItem.on('click', function () {
// Only enable this single, selected representation.
player
.tech({ IWillNotUseThisInPlugins: true })
.vhs.representations()
.forEach(function (rep, index) {
rep.enabled(index === item.index);
});
newMenuItem.selected(false);
});
return newMenuItem;
});
defaultAutoItem.on('click', function () {
// Re-enable all representations.
player
.tech({ IWillNotUseThisInPlugins: true })
.vhs.representations()
.forEach(function (rep, index) {
rep.enabled(true);
});
defaultAutoItem.selected(false);
});
return [defaultAutoItem, ...items];
},
});
// Only show the quality selector if there is more than one option.
if (qualities.length < 2) {
return;
}
var menuButton = new MenuButton();
menuButton.addClass('vjs-quality-selector');
player.controlBar.addChild(
menuButton,
{},
player.controlBar.children_.length - 2
);
this.qualityMenuButton = menuButton;
}.bind(this)
);
}
addAirplay() {
videojs.hookOnce('setup', function (player) {
if (window.WebKitPlaybackTargetAvailabilityEvent) {
var videoJsButtonClass = videojs.getComponent('Button');
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
// The `init()` method will also work for constructor logic here, but it is
// deprecated. If you provide an `init()` method, it will override the
// `constructor()` method!
constructor: function () {
videoJsButtonClass.call(this, player);
},
handleClick: function () {
const videoElement = document.getElementsByTagName('video')[0];
videoElement.webkitShowPlaybackTargetPicker();
},
});
var concreteButtonInstance = player.controlBar.addChild(
new concreteButtonClass()
);
concreteButtonInstance.addClass('vjs-airplay');
}
});
}
}
export { OwncastPlayer };

View file

@ -0,0 +1,65 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
export default class TabBar extends Component {
constructor(props) {
super(props);
this.state = {
activeIndex: 0,
};
this.handleTabClick = this.handleTabClick.bind(this);
}
handleTabClick(index) {
this.setState({ activeIndex: index });
}
render() {
const { tabs, ariaLabel } = this.props;
if (!tabs.length) {
return null;
}
if (tabs.length === 1) {
return html` ${tabs[0].content} `;
} else {
return html`
<div class="tab-bar">
<div role="tablist" aria-label=${ariaLabel}>
${tabs.map((tabItem, index) => {
const handleClick = () => this.handleTabClick(index);
return html`
<button
role="tab"
aria-selected=${index === this.state.activeIndex}
aria-controls=${`tabContent${index}`}
id=${`tab-${tabItem.label}`}
onclick=${handleClick}
>
${tabItem.label}
</button>
`;
})}
</div>
${tabs.map((tabItem, index) => {
return html`
<div
tabindex="0"
role="tabpanel"
id=${`tabContent${index}`}
aria-labelledby=${`tab-${tabItem.label}`}
hidden=${index !== this.state.activeIndex}
>
${tabItem.content}
</div>
`;
})}
</div>
`;
}
}
}

View file

@ -0,0 +1,114 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import { TEMP_IMAGE } from '../utils/constants.js';
const REFRESH_INTERVAL = 15000;
const POSTER_BASE_URL = '/thumbnail.jpg';
export default class VideoPoster extends Component {
constructor(props) {
super(props);
this.state = {
// flipped is the state of showing primary/secondary image views
flipped: false,
oldUrl: TEMP_IMAGE,
url: TEMP_IMAGE,
};
this.refreshTimer = null;
this.startRefreshTimer = this.startRefreshTimer.bind(this);
this.fire = this.fire.bind(this);
this.setLoaded = this.setLoaded.bind(this);
}
componentDidMount() {
if (this.props.active) {
this.fire();
this.startRefreshTimer();
}
}
shouldComponentUpdate(prevProps, prevState) {
return this.props.active !== prevProps.active ||
this.props.offlineImage !== prevProps.offlineImage ||
this.state.url !== prevState.url ||
this.state.oldUrl !== prevState.oldUrl;
}
componentDidUpdate(prevProps) {
const { active } = this.props;
const { active: prevActive } = prevProps;
if (active && !prevActive) {
this.startRefreshTimer();
} else if (!active && prevActive) {
this.stopRefreshTimer();
}
}
componentWillUnmount() {
this.stopRefreshTimer();
}
startRefreshTimer() {
this.stopRefreshTimer();
this.fire();
// Load a new copy of the image every n seconds
this.refreshTimer = setInterval(this.fire, REFRESH_INTERVAL);
}
// load new img
fire() {
const cachebuster = Math.round(new Date().getTime() / 1000);
this.loadingImage = POSTER_BASE_URL + '?cb=' + cachebuster;
const img = new Image();
img.onload = this.setLoaded;
img.src = this.loadingImage;
}
setLoaded() {
const { url: currentUrl, flipped } = this.state;
this.setState({
flipped: !flipped,
url: this.loadingImage,
oldUrl: currentUrl,
});
}
stopRefreshTimer() {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
render() {
const { active, offlineImage } = this.props;
const { url, oldUrl, flipped } = this.state;
if (!active) {
return html`
<div id="oc-custom-poster">
<${ThumbImage} url=${offlineImage} visible=${true} />
</div>
`;
}
return html`
<div id="oc-custom-poster">
<${ThumbImage} url=${!flipped ? oldUrl : url } visible=${true} />
<${ThumbImage} url=${flipped ? oldUrl : url } visible=${!flipped} />
</div>
`;
}
}
function ThumbImage({ url, visible }) {
if (!url) {
return null;
}
return html`
<div
class="custom-thumbnail-image"
style=${{
opacity: visible ? 1 : 0,
backgroundImage: `url(${url})`,
}}
/>
`;
}