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