forked from wurzel/fruitbasket
stream doesn't want to work
This commit is contained in:
parent
d7ed57bbdd
commit
9656926a07
287 changed files with 6934 additions and 0 deletions
337
webroot/js/app-standalone-chat.js
Normal file
337
webroot/js/app-standalone-chat.js
Normal file
|
@ -0,0 +1,337 @@
|
|||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
import UsernameForm from './components/chat/username.js';
|
||||
import Chat from './components/chat/chat.js';
|
||||
import Websocket, {
|
||||
CALLBACKS,
|
||||
SOCKET_MESSAGE_TYPES,
|
||||
} from './utils/websocket.js';
|
||||
import { registerChat } from './chat/register.js';
|
||||
import { getLocalStorage, setLocalStorage } from './utils/helpers.js';
|
||||
import {
|
||||
CHAT_MAX_MESSAGE_LENGTH,
|
||||
EST_SOCKET_PAYLOAD_BUFFER,
|
||||
KEY_EMBED_CHAT_ACCESS_TOKEN,
|
||||
KEY_ACCESS_TOKEN,
|
||||
KEY_USERNAME,
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE,
|
||||
URL_STATUS,
|
||||
URL_CONFIG,
|
||||
TIMER_STATUS_UPDATE,
|
||||
} from './utils/constants.js';
|
||||
|
||||
export default class StandaloneChat extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
websocket: null,
|
||||
canChat: false,
|
||||
chatEnabled: true, // always true for standalone chat
|
||||
chatInputEnabled: false, // chat input box state
|
||||
accessToken: null,
|
||||
username: null,
|
||||
isRegistering: false,
|
||||
streamOnline: null, // stream is active/online
|
||||
lastDisconnectTime: null,
|
||||
configData: {
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
this.disableChatInputTimer = null;
|
||||
this.hasConfiguredChat = false;
|
||||
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
this.handleFormFocus = this.handleFormFocus.bind(this);
|
||||
this.handleFormBlur = this.handleFormBlur.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.disableChatInput = this.disableChatInput.bind(this);
|
||||
this.setupChatAuth = this.setupChatAuth.bind(this);
|
||||
this.disableChat = this.disableChat.bind(this);
|
||||
|
||||
// user events
|
||||
this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this);
|
||||
|
||||
this.getConfig();
|
||||
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
getConfig() {
|
||||
fetch(URL_CONFIG)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.setConfigData(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch stream status
|
||||
getStreamStatus() {
|
||||
fetch(URL_STATUS)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.updateStreamStatus(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { chatDisabled } = data;
|
||||
|
||||
// If this is the first time setting the config
|
||||
// then setup chat if it's enabled.
|
||||
if (!this.hasConfiguredChat && !chatDisabled) {
|
||||
this.setupChatAuth();
|
||||
}
|
||||
|
||||
this.hasConfiguredChat = true;
|
||||
|
||||
this.setState({
|
||||
canChat: !chatDisabled,
|
||||
configData: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const { online, lastDisconnectTime } = status;
|
||||
|
||||
this.setState({
|
||||
lastDisconnectTime,
|
||||
streamOnline: online,
|
||||
});
|
||||
|
||||
if (status.online !== curStreamOnline) {
|
||||
if (status.online) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else {
|
||||
// stream has just flipped offline or app just got loaded and stream is offline.
|
||||
this.handleOfflineMode(lastDisconnectTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode(lastDisconnectTime) {
|
||||
if (lastDisconnectTime) {
|
||||
const remainingChatTime =
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE -
|
||||
(Date.now() - new Date(lastDisconnectTime));
|
||||
const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
|
||||
if (countdown > 0) {
|
||||
this.setState({
|
||||
chatInputEnabled: true,
|
||||
});
|
||||
}
|
||||
this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
|
||||
}
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleOnlineMode() {
|
||||
clearTimeout(this.disableChatInputTimer);
|
||||
this.disableChatInputTimer = null;
|
||||
|
||||
this.setState({
|
||||
streamOnline: true,
|
||||
chatInputEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
handleUsernameChange(newName) {
|
||||
this.setState({
|
||||
username: newName,
|
||||
});
|
||||
this.sendUsernameChange(newName);
|
||||
}
|
||||
|
||||
disableChatInput() {
|
||||
this.setState({
|
||||
chatInputEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
handleWebsocketMessage(e) {
|
||||
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
|
||||
// User has been actively disabled on the backend. Turn off chat for them.
|
||||
this.handleBlockedChat();
|
||||
} else if (
|
||||
e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION &&
|
||||
!this.isRegistering
|
||||
) {
|
||||
// User needs an access token, so start the user auth flow.
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null });
|
||||
this.setupChatAuth(true);
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) {
|
||||
// Chat server cannot support any more chat clients. Turn off chat for them.
|
||||
this.disableChat();
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
|
||||
// When connected the user will return an event letting us know what our
|
||||
// user details are so we can display them properly.
|
||||
const { user } = e;
|
||||
const { displayName } = user;
|
||||
|
||||
this.setState({ username: displayName });
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockedChat() {
|
||||
setLocalStorage('owncast_chat_blocked', true);
|
||||
this.disableChat();
|
||||
}
|
||||
|
||||
handleFormFocus() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleFormBlur() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disableChat() {
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null, canChat: false });
|
||||
}
|
||||
|
||||
async setupChatAuth(force) {
|
||||
const { readonly } = this.props;
|
||||
var accessToken = readonly
|
||||
? getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN)
|
||||
: getLocalStorage(KEY_ACCESS_TOKEN);
|
||||
var randomIntArray = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(randomIntArray);
|
||||
var username = readonly
|
||||
? 'chat-embed-' + randomIntArray[0]
|
||||
: getLocalStorage(KEY_USERNAME);
|
||||
|
||||
if (!accessToken || force) {
|
||||
try {
|
||||
this.isRegistering = true;
|
||||
const registration = await registerChat(username);
|
||||
accessToken = registration.accessToken;
|
||||
username = registration.displayName;
|
||||
|
||||
if (readonly) {
|
||||
setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken);
|
||||
} else {
|
||||
setLocalStorage(KEY_ACCESS_TOKEN, accessToken);
|
||||
setLocalStorage(KEY_USERNAME, username);
|
||||
}
|
||||
|
||||
this.isRegistering = false;
|
||||
} catch (e) {
|
||||
console.error('registration error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.websocket) {
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({
|
||||
websocket: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Without a valid access token he websocket connection will be rejected.
|
||||
const websocket = new Websocket(accessToken);
|
||||
websocket.addListener(
|
||||
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
|
||||
this.handleWebsocketMessage
|
||||
);
|
||||
|
||||
this.setState({
|
||||
username,
|
||||
websocket,
|
||||
accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
sendUsernameChange(newName) {
|
||||
const nameChange = {
|
||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
newName,
|
||||
};
|
||||
this.state.websocket.send(nameChange);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, websocket, accessToken, chatInputEnabled, configData } =
|
||||
state;
|
||||
|
||||
const { chatDisabled, maxSocketPayloadSize, customStyles, name } =
|
||||
configData;
|
||||
|
||||
const { readonly } = props;
|
||||
return this.state.websocket
|
||||
? html`${!readonly
|
||||
? html`<style>
|
||||
${customStyles}
|
||||
</style>
|
||||
<header
|
||||
class="flex flex-row-reverse fixed z-10 w-full bg-gray-900"
|
||||
>
|
||||
<${UsernameForm}
|
||||
username=${username}
|
||||
onUsernameChange=${this.handleUsernameChange}
|
||||
onFocus=${this.handleFormFocus}
|
||||
onBlur=${this.handleFormBlur}
|
||||
/>
|
||||
</header>`
|
||||
: ''}
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
accessToken=${accessToken}
|
||||
readonly=${readonly}
|
||||
instanceTitle=${name}
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
|
||||
CHAT_MAX_MESSAGE_LENGTH}
|
||||
/>`
|
||||
: null;
|
||||
}
|
||||
}
|
287
webroot/js/app-video-only.js
Normal file
287
webroot/js/app-video-only.js
Normal file
|
@ -0,0 +1,287 @@
|
|||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import VideoPoster from './components/video-poster.js';
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
|
||||
import {
|
||||
addNewlines,
|
||||
makeLastOnlineString,
|
||||
pluralize,
|
||||
parseSecondsToDurationString,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
URL_CONFIG,
|
||||
URL_STATUS,
|
||||
URL_VIEWER_PING,
|
||||
TIMER_STATUS_UPDATE,
|
||||
TIMER_STREAM_DURATION_COUNTER,
|
||||
TEMP_IMAGE,
|
||||
MESSAGE_OFFLINE,
|
||||
MESSAGE_ONLINE,
|
||||
} from './utils/constants.js';
|
||||
|
||||
export default class VideoOnly extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
configData: {},
|
||||
|
||||
playerActive: false, // player object is active
|
||||
streamOnline: false, // stream is active/online
|
||||
|
||||
isPlaying: false,
|
||||
|
||||
//status
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
viewerCount: '',
|
||||
lastDisconnectTime: null,
|
||||
};
|
||||
|
||||
// timers
|
||||
this.playerRestartTimer = null;
|
||||
this.offlineTimer = null;
|
||||
this.statusTimer = null;
|
||||
this.streamDurationTimer = null;
|
||||
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||
|
||||
// fetch events
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getConfig();
|
||||
|
||||
this.player = new OwncastPlayer();
|
||||
this.player.setupPlayerCallbacks({
|
||||
onReady: this.handlePlayerReady,
|
||||
onPlaying: this.handlePlayerPlaying,
|
||||
onEnded: this.handlePlayerEnded,
|
||||
onError: this.handlePlayerError,
|
||||
});
|
||||
this.player.init();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// clear all the timers
|
||||
clearInterval(this.playerRestartTimer);
|
||||
clearInterval(this.offlineTimer);
|
||||
clearInterval(this.statusTimer);
|
||||
clearInterval(this.streamDurationTimer);
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
getConfig() {
|
||||
fetch(URL_CONFIG)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.setConfigData(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch stream status
|
||||
getStreamStatus() {
|
||||
fetch(URL_STATUS)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.updateStreamStatus(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
|
||||
// Ping the API to let them know we're an active viewer
|
||||
fetch(URL_VIEWER_PING).catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Viewer PING error: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { title, summary } = data;
|
||||
window.document.title = title;
|
||||
this.setState({
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const { viewerCount, online, lastConnectTime, lastDisconnectTime } = status;
|
||||
|
||||
if (status.online && !curStreamOnline) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else if (!status.online && curStreamOnline) {
|
||||
// stream has just flipped offline.
|
||||
this.handleOfflineMode();
|
||||
}
|
||||
this.setState({
|
||||
viewerCount,
|
||||
streamOnline: online,
|
||||
lastDisconnectTime,
|
||||
lastConnectTime,
|
||||
});
|
||||
}
|
||||
|
||||
// when videojs player is ready, start polling for stream
|
||||
handlePlayerReady() {
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
handlePlayerPlaying() {
|
||||
this.setState({
|
||||
isPlaying: true,
|
||||
});
|
||||
}
|
||||
|
||||
// likely called some time after stream status has gone offline.
|
||||
// basically hide video and show underlying "poster"
|
||||
handlePlayerEnded() {
|
||||
this.setState({
|
||||
playerActive: false,
|
||||
isPlaying: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayerError() {
|
||||
// do something?
|
||||
this.handleOfflineMode();
|
||||
this.handlePlayerEnded();
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode() {
|
||||
clearInterval(this.streamDurationTimer);
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentStreamDuration() {
|
||||
let streamDurationString = '';
|
||||
if (this.state.lastConnectTime) {
|
||||
const diff = (Date.now() - Date.parse(this.state.lastConnectTime)) / 1000;
|
||||
streamDurationString = parseSecondsToDurationString(diff);
|
||||
}
|
||||
this.setState({
|
||||
streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`,
|
||||
});
|
||||
}
|
||||
|
||||
// play video!
|
||||
handleOnlineMode() {
|
||||
this.player.startPlayer();
|
||||
|
||||
this.streamDurationTimer = setInterval(
|
||||
this.setCurrentStreamDuration,
|
||||
TIMER_STREAM_DURATION_COUNTER
|
||||
);
|
||||
|
||||
this.setState({
|
||||
playerActive: true,
|
||||
streamOnline: true,
|
||||
streamStatusMessage: MESSAGE_ONLINE,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
configData,
|
||||
|
||||
viewerCount,
|
||||
playerActive,
|
||||
streamOnline,
|
||||
streamStatusMessage,
|
||||
lastDisconnectTime,
|
||||
isPlaying,
|
||||
} = state;
|
||||
|
||||
const { logo = TEMP_IMAGE, customStyles } = configData;
|
||||
|
||||
let viewerCountMessage = '';
|
||||
if (streamOnline && viewerCount > 0) {
|
||||
viewerCountMessage = html`${viewerCount}
|
||||
${pluralize(' viewer', viewerCount)}`;
|
||||
} else if (lastDisconnectTime) {
|
||||
viewerCountMessage = makeLastOnlineString(lastDisconnectTime);
|
||||
}
|
||||
|
||||
const mainClass = playerActive ? 'online' : '';
|
||||
|
||||
const poster = isPlaying
|
||||
? null
|
||||
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
|
||||
return html`
|
||||
<main class=${mainClass}>
|
||||
<style>
|
||||
${customStyles}
|
||||
</style>
|
||||
<div
|
||||
id="video-container"
|
||||
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||
>
|
||||
<video
|
||||
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||
id="video"
|
||||
preload="auto"
|
||||
controls
|
||||
playsinline
|
||||
></video>
|
||||
${poster}
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="stream-info"
|
||||
aria-label="Stream status"
|
||||
class="flex flex-row justify-between font-mono py-2 px-4 bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid"
|
||||
>
|
||||
<span class="text-xs">${streamStatusMessage}</span>
|
||||
<span id="stream-viewer-count" class="text-xs text-right"
|
||||
>${viewerCountMessage}</span
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
}
|
975
webroot/js/app.js
Normal file
975
webroot/js/app.js
Normal file
|
@ -0,0 +1,975 @@
|
|||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
import SocialIconsList from './components/platform-logos-list.js';
|
||||
import UsernameForm from './components/chat/username.js';
|
||||
import VideoPoster from './components/video-poster.js';
|
||||
import Followers from './components/federation/followers.js';
|
||||
|
||||
import Chat from './components/chat/chat.js';
|
||||
import Websocket, {
|
||||
CALLBACKS,
|
||||
SOCKET_MESSAGE_TYPES,
|
||||
} from './utils/websocket.js';
|
||||
import { registerChat } from './chat/register.js';
|
||||
|
||||
import ExternalActionModal, {
|
||||
ExternalActionButton,
|
||||
} from './components/external-action-modal.js';
|
||||
|
||||
import FediverseFollowModal, {
|
||||
FediverseFollowButton,
|
||||
} from './components/fediverse-follow-modal.js';
|
||||
|
||||
import {
|
||||
addNewlines,
|
||||
checkUrlPathForDisplay,
|
||||
classNames,
|
||||
debounce,
|
||||
getLocalStorage,
|
||||
getOrientation,
|
||||
hasTouchScreen,
|
||||
makeLastOnlineString,
|
||||
parseSecondsToDurationString,
|
||||
pluralize,
|
||||
ROUTE_RECORDINGS,
|
||||
setLocalStorage,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
CHAT_MAX_MESSAGE_LENGTH,
|
||||
EST_SOCKET_PAYLOAD_BUFFER,
|
||||
HEIGHT_SHORT_WIDE,
|
||||
KEY_ACCESS_TOKEN,
|
||||
KEY_CHAT_DISPLAYED,
|
||||
KEY_USERNAME,
|
||||
MESSAGE_OFFLINE,
|
||||
MESSAGE_ONLINE,
|
||||
ORIENTATION_PORTRAIT,
|
||||
OWNCAST_LOGO_LOCAL,
|
||||
TEMP_IMAGE,
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE,
|
||||
TIMER_STATUS_UPDATE,
|
||||
TIMER_STREAM_DURATION_COUNTER,
|
||||
URL_CONFIG,
|
||||
URL_OWNCAST,
|
||||
URL_STATUS,
|
||||
URL_VIEWER_PING,
|
||||
WIDTH_SINGLE_COL,
|
||||
} from './utils/constants.js';
|
||||
import { checkIsModerator } from './utils/chat.js';
|
||||
import TabBar from './components/tab-bar.js';
|
||||
|
||||
export default class App extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const chatStorage = getLocalStorage(KEY_CHAT_DISPLAYED);
|
||||
this.hasTouchScreen = hasTouchScreen();
|
||||
this.windowBlurred = false;
|
||||
|
||||
this.state = {
|
||||
websocket: null,
|
||||
canChat: false, // all of chat functionality (panel + username)
|
||||
displayChatPanel: chatStorage === null ? true : chatStorage === 'true', // just the chat panel
|
||||
chatInputEnabled: false, // chat input box state
|
||||
accessToken: null,
|
||||
username: getLocalStorage(KEY_USERNAME),
|
||||
isModerator: false,
|
||||
|
||||
isRegistering: false,
|
||||
touchKeyboardActive: false,
|
||||
|
||||
configData: {
|
||||
loading: true,
|
||||
},
|
||||
extraPageContent: '',
|
||||
|
||||
playerActive: false, // player object is active
|
||||
streamOnline: null, // stream is active/online
|
||||
isPlaying: false, // player is actively playing video
|
||||
|
||||
// status
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
viewerCount: '',
|
||||
lastDisconnectTime: null,
|
||||
|
||||
// dom
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
orientation: getOrientation(this.hasTouchScreen),
|
||||
|
||||
// modals
|
||||
externalActionModalData: null,
|
||||
fediverseModalData: null,
|
||||
|
||||
// routing & tabbing
|
||||
section: '',
|
||||
sectionId: '',
|
||||
};
|
||||
|
||||
// timers
|
||||
this.playerRestartTimer = null;
|
||||
this.offlineTimer = null;
|
||||
this.statusTimer = null;
|
||||
this.disableChatInputTimer = null;
|
||||
this.streamDurationTimer = null;
|
||||
|
||||
// misc dom events
|
||||
this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this);
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
this.handleFormFocus = this.handleFormFocus.bind(this);
|
||||
this.handleFormBlur = this.handleFormBlur.bind(this);
|
||||
this.handleWindowBlur = this.handleWindowBlur.bind(this);
|
||||
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 250);
|
||||
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
this.disableChatInput = this.disableChatInput.bind(this);
|
||||
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
|
||||
|
||||
this.handleKeyPressed = this.handleKeyPressed.bind(this);
|
||||
this.displayExternalAction = this.displayExternalAction.bind(this);
|
||||
this.closeExternalActionModal = this.closeExternalActionModal.bind(this);
|
||||
this.displayFediverseFollowModal =
|
||||
this.displayFediverseFollowModal.bind(this);
|
||||
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||
|
||||
// fetch events
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
|
||||
// user events
|
||||
this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this);
|
||||
|
||||
// chat
|
||||
this.hasConfiguredChat = false;
|
||||
this.setupChatAuth = this.setupChatAuth.bind(this);
|
||||
this.disableChat = this.disableChat.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getConfig();
|
||||
if (!this.hasTouchScreen) {
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
window.addEventListener('blur', this.handleWindowBlur);
|
||||
window.addEventListener('focus', this.handleWindowFocus);
|
||||
if (this.hasTouchScreen) {
|
||||
window.addEventListener('orientationchange', this.handleWindowResize);
|
||||
}
|
||||
window.addEventListener('keypress', this.handleKeyPressed);
|
||||
this.player = new OwncastPlayer();
|
||||
this.player.setupPlayerCallbacks({
|
||||
onReady: this.handlePlayerReady,
|
||||
onPlaying: this.handlePlayerPlaying,
|
||||
onEnded: this.handlePlayerEnded,
|
||||
onError: this.handlePlayerError,
|
||||
});
|
||||
this.player.init();
|
||||
|
||||
// check routing
|
||||
this.getRoute();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// clear all the timers
|
||||
clearInterval(this.playerRestartTimer);
|
||||
clearInterval(this.offlineTimer);
|
||||
clearInterval(this.statusTimer);
|
||||
clearTimeout(this.disableChatInputTimer);
|
||||
clearInterval(this.streamDurationTimer);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
window.removeEventListener('focus', this.handleWindowFocus);
|
||||
window.removeEventListener('keypress', this.handleKeyPressed);
|
||||
if (this.hasTouchScreen) {
|
||||
window.removeEventListener('orientationchange', this.handleWindowResize);
|
||||
}
|
||||
}
|
||||
|
||||
getRoute() {
|
||||
const routeInfo = checkUrlPathForDisplay();
|
||||
this.setState({
|
||||
...routeInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
getConfig() {
|
||||
fetch(URL_CONFIG)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.setConfigData(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch stream status
|
||||
getStreamStatus() {
|
||||
fetch(URL_STATUS)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.updateStreamStatus(json);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
|
||||
// Ping the API to let them know we're an active viewer
|
||||
fetch(URL_VIEWER_PING).catch((error) => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Viewer PING error: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { name, summary, chatDisabled } = data;
|
||||
window.document.title = name;
|
||||
|
||||
// If this is the first time setting the config
|
||||
// then setup chat if it's enabled.
|
||||
if (!this.hasConfiguredChat && !chatDisabled) {
|
||||
this.setupChatAuth();
|
||||
}
|
||||
|
||||
this.hasConfiguredChat = true;
|
||||
|
||||
this.setState({
|
||||
canChat: !chatDisabled,
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
viewerCount,
|
||||
online,
|
||||
lastConnectTime,
|
||||
streamTitle,
|
||||
lastDisconnectTime,
|
||||
} = status;
|
||||
|
||||
this.setState({
|
||||
viewerCount,
|
||||
lastConnectTime,
|
||||
streamOnline: online,
|
||||
streamTitle,
|
||||
lastDisconnectTime,
|
||||
});
|
||||
|
||||
if (status.online !== curStreamOnline) {
|
||||
if (status.online) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else {
|
||||
// stream has just flipped offline or app just got loaded and stream is offline.
|
||||
this.handleOfflineMode(lastDisconnectTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// when videojs player is ready, start polling for stream
|
||||
handlePlayerReady() {
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
handlePlayerPlaying() {
|
||||
this.setState({
|
||||
isPlaying: true,
|
||||
});
|
||||
}
|
||||
|
||||
// likely called some time after stream status has gone offline.
|
||||
// basically hide video and show underlying "poster"
|
||||
handlePlayerEnded() {
|
||||
this.setState({
|
||||
playerActive: false,
|
||||
isPlaying: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayerError() {
|
||||
// do something?
|
||||
this.handleOfflineMode();
|
||||
this.handlePlayerEnded();
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode(lastDisconnectTime) {
|
||||
clearInterval(this.streamDurationTimer);
|
||||
|
||||
if (lastDisconnectTime) {
|
||||
const remainingChatTime =
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE -
|
||||
(Date.now() - new Date(lastDisconnectTime));
|
||||
const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
|
||||
if (countdown > 0) {
|
||||
this.setState({
|
||||
chatInputEnabled: true,
|
||||
});
|
||||
}
|
||||
this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
});
|
||||
|
||||
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
||||
this.handlePlayerEnded();
|
||||
}
|
||||
|
||||
if (this.windowBlurred) {
|
||||
document.title = ` 🔴 ${
|
||||
this.state.configData && this.state.configData.name
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
// play video!
|
||||
handleOnlineMode() {
|
||||
this.player.startPlayer();
|
||||
clearTimeout(this.disableChatInputTimer);
|
||||
this.disableChatInputTimer = null;
|
||||
|
||||
this.streamDurationTimer = setInterval(
|
||||
this.setCurrentStreamDuration,
|
||||
TIMER_STREAM_DURATION_COUNTER
|
||||
);
|
||||
|
||||
this.setState({
|
||||
playerActive: true,
|
||||
streamOnline: true,
|
||||
chatInputEnabled: true,
|
||||
streamTitle: '',
|
||||
streamStatusMessage: MESSAGE_ONLINE,
|
||||
});
|
||||
|
||||
if (this.windowBlurred) {
|
||||
document.title = ` 🟢 ${
|
||||
this.state.configData && this.state.configData.name
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStreamDuration() {
|
||||
let streamDurationString = '';
|
||||
if (this.state.lastConnectTime) {
|
||||
const diff = (Date.now() - Date.parse(this.state.lastConnectTime)) / 1000;
|
||||
streamDurationString = parseSecondsToDurationString(diff);
|
||||
}
|
||||
this.setState({
|
||||
streamStatusMessage: `${MESSAGE_ONLINE} ${streamDurationString}`,
|
||||
});
|
||||
}
|
||||
|
||||
handleUsernameChange(newName) {
|
||||
this.setState({
|
||||
username: newName,
|
||||
});
|
||||
|
||||
this.sendUsernameChange(newName);
|
||||
}
|
||||
|
||||
handleFormFocus() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleFormBlur() {
|
||||
if (this.hasTouchScreen) {
|
||||
this.setState({
|
||||
touchKeyboardActive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChatPanelToggle() {
|
||||
const { displayChatPanel: curDisplayed } = this.state;
|
||||
|
||||
const displayChat = !curDisplayed;
|
||||
setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
|
||||
this.setState({
|
||||
displayChatPanel: displayChat,
|
||||
});
|
||||
}
|
||||
|
||||
disableChatInput() {
|
||||
this.setState({
|
||||
chatInputEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
this.setState({
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
orientation: getOrientation(this.hasTouchScreen),
|
||||
});
|
||||
}
|
||||
|
||||
handleWindowBlur() {
|
||||
this.windowBlurred = true;
|
||||
}
|
||||
|
||||
handleWindowFocus() {
|
||||
this.windowBlurred = false;
|
||||
window.document.title = this.state.configData && this.state.configData.name;
|
||||
}
|
||||
|
||||
handleSpaceBarPressed(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.isPlaying) {
|
||||
this.setState({
|
||||
isPlaying: false,
|
||||
});
|
||||
try {
|
||||
this.player.vjsPlayer.pause();
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
isPlaying: true,
|
||||
});
|
||||
this.player.vjsPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleMuteKeyPressed() {
|
||||
const muted = this.player.vjsPlayer.muted();
|
||||
const volume = this.player.vjsPlayer.volume();
|
||||
|
||||
try {
|
||||
if (volume === 0) {
|
||||
this.player.vjsPlayer.volume(0.5);
|
||||
this.player.vjsPlayer.muted(false);
|
||||
} else {
|
||||
this.player.vjsPlayer.muted(!muted);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
handleFullScreenKeyPressed() {
|
||||
if (this.player.vjsPlayer.isFullscreen()) {
|
||||
this.player.vjsPlayer.exitFullscreen();
|
||||
} else {
|
||||
this.player.vjsPlayer.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
handleVolumeSet(factor) {
|
||||
this.player.vjsPlayer.volume(this.player.vjsPlayer.volume() + factor);
|
||||
}
|
||||
|
||||
handleKeyPressed(e) {
|
||||
// Only handle shortcuts if the focus is on the general page body,
|
||||
// not a specific input field.
|
||||
if (e.target !== document.getElementById('app-body')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.streamOnline) {
|
||||
switch (e.code) {
|
||||
case 'MediaPlayPause':
|
||||
case 'KeyP':
|
||||
case 'Space':
|
||||
this.handleSpaceBarPressed(e);
|
||||
break;
|
||||
case 'KeyM':
|
||||
this.handleMuteKeyPressed(e);
|
||||
break;
|
||||
case 'KeyF':
|
||||
this.handleFullScreenKeyPressed(e);
|
||||
break;
|
||||
case 'KeyC':
|
||||
this.handleChatPanelToggle();
|
||||
break;
|
||||
case 'Digit9':
|
||||
this.handleVolumeSet(-0.1);
|
||||
break;
|
||||
case 'Digit0':
|
||||
this.handleVolumeSet(0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayExternalAction(action) {
|
||||
const { username } = this.state;
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const { url: actionUrl, openExternally } = action || {};
|
||||
let url = new URL(actionUrl);
|
||||
// Append url and username to params so the link knows where we came from and who we are.
|
||||
url.searchParams.append('username', username);
|
||||
url.searchParams.append('instance', window.location);
|
||||
|
||||
const fullUrl = url.toString();
|
||||
|
||||
if (openExternally) {
|
||||
var win = window.open(fullUrl, '_blank');
|
||||
win.focus();
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
externalActionModalData: {
|
||||
...action,
|
||||
url: fullUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
closeExternalActionModal() {
|
||||
this.setState({
|
||||
externalActionModalData: null,
|
||||
});
|
||||
}
|
||||
|
||||
displayFediverseFollowModal(data) {
|
||||
this.setState({ fediverseModalData: data });
|
||||
}
|
||||
closeFediverseFollowModal() {
|
||||
this.setState({ fediverseModalData: null });
|
||||
}
|
||||
|
||||
handleWebsocketMessage(e) {
|
||||
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
|
||||
// User has been actively disabled on the backend. Turn off chat for them.
|
||||
this.handleBlockedChat();
|
||||
} else if (
|
||||
e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION &&
|
||||
!this.isRegistering
|
||||
) {
|
||||
// User needs an access token, so start the user auth flow.
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null });
|
||||
this.setupChatAuth(true);
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) {
|
||||
// Chat server cannot support any more chat clients. Turn off chat for them.
|
||||
this.disableChat();
|
||||
} else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
|
||||
// When connected the user will return an event letting us know what our
|
||||
// user details are so we can display them properly.
|
||||
const { user } = e;
|
||||
const { displayName } = user;
|
||||
|
||||
this.setState({
|
||||
username: displayName,
|
||||
isModerator: checkIsModerator(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockedChat() {
|
||||
this.disableChat();
|
||||
}
|
||||
|
||||
disableChat() {
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({ websocket: null, canChat: false });
|
||||
}
|
||||
|
||||
async setupChatAuth(force) {
|
||||
var accessToken = getLocalStorage(KEY_ACCESS_TOKEN);
|
||||
var username = getLocalStorage(KEY_USERNAME);
|
||||
|
||||
if (!accessToken || force) {
|
||||
try {
|
||||
this.isRegistering = true;
|
||||
const registration = await registerChat(this.state.username);
|
||||
accessToken = registration.accessToken;
|
||||
username = registration.displayName;
|
||||
|
||||
setLocalStorage(KEY_ACCESS_TOKEN, accessToken);
|
||||
setLocalStorage(KEY_USERNAME, username);
|
||||
|
||||
this.isRegistering = false;
|
||||
} catch (e) {
|
||||
console.error('registration error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.websocket) {
|
||||
this.state.websocket.shutdown();
|
||||
this.setState({
|
||||
websocket: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Without a valid access token he websocket connection will be rejected.
|
||||
const websocket = new Websocket(accessToken);
|
||||
websocket.addListener(
|
||||
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
|
||||
this.handleWebsocketMessage
|
||||
);
|
||||
|
||||
this.setState({
|
||||
username,
|
||||
websocket,
|
||||
accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
sendUsernameChange(newName) {
|
||||
const nameChange = {
|
||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
newName,
|
||||
};
|
||||
this.state.websocket.send(nameChange);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
chatInputEnabled,
|
||||
configData,
|
||||
displayChatPanel,
|
||||
canChat,
|
||||
isModerator,
|
||||
|
||||
isPlaying,
|
||||
orientation,
|
||||
playerActive,
|
||||
streamOnline,
|
||||
streamStatusMessage,
|
||||
streamTitle,
|
||||
touchKeyboardActive,
|
||||
username,
|
||||
viewerCount,
|
||||
websocket,
|
||||
windowHeight,
|
||||
windowWidth,
|
||||
fediverseModalData,
|
||||
externalActionModalData,
|
||||
lastDisconnectTime,
|
||||
section,
|
||||
sectionId,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
version: appVersion,
|
||||
logo = TEMP_IMAGE,
|
||||
socialHandles = [],
|
||||
summary,
|
||||
tags = [],
|
||||
name,
|
||||
extraPageContent,
|
||||
chatDisabled,
|
||||
externalActions,
|
||||
customStyles,
|
||||
maxSocketPayloadSize,
|
||||
federation = {},
|
||||
} = configData;
|
||||
|
||||
const bgUserLogo = { backgroundImage: `url(${logo})` };
|
||||
|
||||
const tagList = tags !== null && tags.length > 0 && tags.join(' #');
|
||||
|
||||
let viewerCountMessage = '';
|
||||
if (streamOnline && viewerCount > 0) {
|
||||
viewerCountMessage = html`${viewerCount}
|
||||
${pluralize(' viewer', viewerCount)}`;
|
||||
} else if (lastDisconnectTime) {
|
||||
viewerCountMessage = makeLastOnlineString(lastDisconnectTime);
|
||||
}
|
||||
|
||||
const mainClass = playerActive ? 'online' : '';
|
||||
const isPortrait =
|
||||
this.hasTouchScreen && orientation === ORIENTATION_PORTRAIT;
|
||||
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait;
|
||||
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
|
||||
|
||||
const noVideoContent =
|
||||
!playerActive || (section === ROUTE_RECORDINGS && sectionId !== '');
|
||||
const shouldDisplayChat =
|
||||
displayChatPanel && !chatDisabled && chatInputEnabled;
|
||||
|
||||
const extraAppClasses = classNames({
|
||||
'config-loading': configData.loading,
|
||||
|
||||
chat: shouldDisplayChat,
|
||||
'no-chat': !shouldDisplayChat,
|
||||
'no-video': noVideoContent,
|
||||
'chat-hidden': !displayChatPanel && canChat && !chatDisabled, // hide panel
|
||||
'chat-disabled': !canChat || chatDisabled,
|
||||
'single-col': singleColMode,
|
||||
'bg-gray-800': singleColMode && shouldDisplayChat,
|
||||
'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL,
|
||||
'touch-screen': this.hasTouchScreen,
|
||||
'touch-keyboard-active': touchKeyboardActive,
|
||||
});
|
||||
|
||||
const poster = isPlaying
|
||||
? null
|
||||
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
|
||||
|
||||
// modal buttons
|
||||
const externalActionButtons = html`<div
|
||||
id="external-actions-container"
|
||||
class="flex flex-row flex-wrap justify-end"
|
||||
>
|
||||
${externalActions &&
|
||||
externalActions.map(
|
||||
function (action) {
|
||||
return html`<${ExternalActionButton}
|
||||
onClick=${this.displayExternalAction}
|
||||
action=${action}
|
||||
/>`;
|
||||
}.bind(this)
|
||||
)}
|
||||
|
||||
<!-- fediverse follow button -->
|
||||
${federation.enabled &&
|
||||
html`<${FediverseFollowButton}
|
||||
onClick=${this.displayFediverseFollowModal}
|
||||
federationInfo=${federation}
|
||||
serverName=${name}
|
||||
/>`}
|
||||
</div>`;
|
||||
|
||||
// modal component
|
||||
const externalActionModal =
|
||||
externalActionModalData &&
|
||||
html`<${ExternalActionModal}
|
||||
action=${externalActionModalData}
|
||||
onClose=${this.closeExternalActionModal}
|
||||
/>`;
|
||||
|
||||
const fediverseFollowModal =
|
||||
fediverseModalData &&
|
||||
html`
|
||||
<${ExternalActionModal}
|
||||
onClose=${this.closeFediverseFollowModal}
|
||||
action=${fediverseModalData}
|
||||
useIframe=${false}
|
||||
customContent=${html`<${FediverseFollowModal}
|
||||
name=${name}
|
||||
logo=${logo}
|
||||
federationInfo=${federation}
|
||||
onClose=${this.closeFediverseFollowModal}
|
||||
/>`}
|
||||
/>
|
||||
`;
|
||||
|
||||
const chat = this.state.websocket
|
||||
? html`
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
instanceTitle=${name}
|
||||
accessToken=${this.state.accessToken}
|
||||
inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
|
||||
CHAT_MAX_MESSAGE_LENGTH}
|
||||
/>
|
||||
`
|
||||
: null;
|
||||
|
||||
const TAB_CONTENT = [
|
||||
{
|
||||
label: 'About',
|
||||
content: html`
|
||||
<div>
|
||||
<div
|
||||
id="stream-summary"
|
||||
class="stream-summary my-4"
|
||||
dangerouslySetInnerHTML=${{ __html: summary }}
|
||||
></div>
|
||||
<div id="tag-list" class="tag-list text-gray-600 mb-3">
|
||||
${tagList && `#${tagList}`}
|
||||
</div>
|
||||
<div
|
||||
id="extra-user-content"
|
||||
class="extra-user-content"
|
||||
dangerouslySetInnerHTML=${{ __html: extraPageContent }}
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
if (federation.enabled) {
|
||||
TAB_CONTENT.push({
|
||||
label: html`Followers
|
||||
${federation.followerCount > 10
|
||||
? `${' '}(${federation.followerCount})`
|
||||
: null}`,
|
||||
content: html`<${Followers} />`,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="app-container"
|
||||
class="flex w-full flex-col justify-start relative ${extraAppClasses}"
|
||||
>
|
||||
<style>
|
||||
${customStyles}
|
||||
</style>
|
||||
|
||||
<div id="top-content" class="z-50">
|
||||
<header
|
||||
class="flex border-b border-gray-900 border-solid shadow-md fixed z-10 w-full top-0 left-0 flex flex-row justify-between flex-no-wrap"
|
||||
>
|
||||
<h1
|
||||
class="flex flex-row items-center justify-start p-2 uppercase text-gray-400 text-xl font-thin tracking-wider overflow-hidden whitespace-no-wrap"
|
||||
>
|
||||
<span
|
||||
id="logo-container"
|
||||
class="inline-block rounded-full bg-white w-8 min-w-8 min-h-8 h-8 mr-2 bg-no-repeat bg-center"
|
||||
>
|
||||
<img
|
||||
class="logo visually-hidden"
|
||||
src=${OWNCAST_LOGO_LOCAL}
|
||||
alt="owncast logo"
|
||||
/>
|
||||
</span>
|
||||
<span class="instance-title overflow-hidden truncate"
|
||||
>${streamOnline && streamTitle ? streamTitle : name}</span
|
||||
>
|
||||
</h1>
|
||||
<div
|
||||
id="user-options-container"
|
||||
class="flex flex-row justify-end items-center flex-no-wrap"
|
||||
>
|
||||
<${UsernameForm}
|
||||
username=${username}
|
||||
isModerator=${isModerator}
|
||||
onUsernameChange=${this.handleUsernameChange}
|
||||
onFocus=${this.handleFormFocus}
|
||||
onBlur=${this.handleFormBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="chat-toggle"
|
||||
onClick=${this.handleChatPanelToggle}
|
||||
class="flex cursor-pointer text-center justify-center items-center min-w-12 h-full bg-gray-800 hover:bg-gray-700"
|
||||
style=${{
|
||||
display: chatDisabled || noVideoContent ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main class=${mainClass}>
|
||||
<div
|
||||
id="video-container"
|
||||
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||
>
|
||||
<video
|
||||
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||
id="video"
|
||||
preload="auto"
|
||||
controls
|
||||
playsinline
|
||||
></video>
|
||||
${poster}
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="stream-info"
|
||||
aria-label="Stream status"
|
||||
class="flex text-center flex-row justify-between font-mono py-2 px-4 bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid"
|
||||
>
|
||||
<span class="text-xs">${streamStatusMessage}</span>
|
||||
<span id="stream-viewer-count" class="text-xs text-right"
|
||||
>${viewerCountMessage}</span
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section
|
||||
id="user-content"
|
||||
aria-label="Owncast server information"
|
||||
class="p-2"
|
||||
>
|
||||
${externalActionButtons && html`${externalActionButtons}`}
|
||||
|
||||
<div class="user-content flex flex-row p-8">
|
||||
<div
|
||||
class="user-logo-icons flex flex-col items-center justify-start mr-8"
|
||||
>
|
||||
<div
|
||||
class="user-image rounded-full bg-white p-4 bg-no-repeat bg-center"
|
||||
style=${bgUserLogo}
|
||||
>
|
||||
<img class="logo visually-hidden" alt="" src=${logo} />
|
||||
</div>
|
||||
<div class="social-actions">
|
||||
<${SocialIconsList} handles=${socialHandles} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-content-header">
|
||||
<h2 class="server-name font-semibold text-5xl">
|
||||
<span class="streamer-name text-indigo-600">${name}</span>
|
||||
</h2>
|
||||
<h3 class="font-semibold text-3xl">
|
||||
${streamOnline && streamTitle}
|
||||
</h3>
|
||||
|
||||
<!-- tab bar -->
|
||||
<div class="${TAB_CONTENT.length > 1 ? 'my-8' : 'my-3'}">
|
||||
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="User Content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="flex flex-row justify-start p-8 opacity-50 text-xs">
|
||||
<span class="mx-1 inline-block">
|
||||
<a href="${URL_OWNCAST}" rel="noopener noreferrer" target="_blank"
|
||||
>${appVersion}</a
|
||||
>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
${chat} ${externalActionModal} ${fediverseFollowModal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
19
webroot/js/chat/register.js
Normal file
19
webroot/js/chat/register.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { URL_CHAT_REGISTRATION } from '../utils/constants.js';
|
||||
|
||||
export async function registerChat(username) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName: username }),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(URL_CHAT_REGISTRATION, options);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
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})`,
|
||||
}}
|
||||
/>
|
||||
`;
|
||||
}
|
198
webroot/js/utils/chat.js
Normal file
198
webroot/js/utils/chat.js
Normal file
|
@ -0,0 +1,198 @@
|
|||
import {
|
||||
CHAT_INITIAL_PLACEHOLDER_TEXT,
|
||||
CHAT_PLACEHOLDER_TEXT,
|
||||
CHAT_PLACEHOLDER_OFFLINE,
|
||||
} from './constants.js';
|
||||
|
||||
// Taken from https://stackoverflow.com/a/46902361
|
||||
export function getCaretPosition(node) {
|
||||
var range = window.getSelection().getRangeAt(0),
|
||||
preCaretRange = range.cloneRange(),
|
||||
caretPosition,
|
||||
tmp = document.createElement('div');
|
||||
|
||||
preCaretRange.selectNodeContents(node);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
tmp.appendChild(preCaretRange.cloneContents());
|
||||
caretPosition = tmp.innerHTML.length;
|
||||
return caretPosition;
|
||||
}
|
||||
|
||||
// Might not need this anymore
|
||||
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
|
||||
export function setCaretPosition(editableDiv, position) {
|
||||
var range = document.createRange();
|
||||
var sel = window.getSelection();
|
||||
range.selectNode(editableDiv);
|
||||
range.setStart(editableDiv.childNodes[0], position);
|
||||
range.collapse(true);
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
export function generatePlaceholderText(isEnabled, hasSentFirstChatMessage) {
|
||||
if (isEnabled) {
|
||||
return hasSentFirstChatMessage
|
||||
? CHAT_PLACEHOLDER_TEXT
|
||||
: CHAT_INITIAL_PLACEHOLDER_TEXT;
|
||||
}
|
||||
return CHAT_PLACEHOLDER_OFFLINE;
|
||||
}
|
||||
|
||||
export function extraUserNamesFromMessageHistory(messages) {
|
||||
const list = [];
|
||||
if (messages) {
|
||||
messages
|
||||
.filter((m) => m.user && m.user.displayName)
|
||||
.forEach(function (message) {
|
||||
if (!list.includes(message.user.displayName)) {
|
||||
list.push(message.user.displayName);
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// utils from https://gist.github.com/nathansmith/86b5d4b23ed968a92fd4
|
||||
/*
|
||||
You would call this after getting an element's
|
||||
`.innerHTML` value, while the user is typing.
|
||||
*/
|
||||
export function convertToText(str = '') {
|
||||
// Ensure string.
|
||||
let value = String(str);
|
||||
|
||||
// Convert encoding.
|
||||
value = value.replace(/ /gi, ' ');
|
||||
value = value.replace(/&/gi, '&');
|
||||
|
||||
// Replace `<br>`.
|
||||
value = value.replace(/<br>/gi, '\n');
|
||||
|
||||
// Replace `<div>` (from Chrome).
|
||||
value = value.replace(/<div>/gi, '\n');
|
||||
|
||||
// Replace `<p>` (from IE).
|
||||
value = value.replace(/<p>/gi, '\n');
|
||||
|
||||
// Cleanup the emoji titles.
|
||||
value = value.replace(/\u200C{2}/gi, '');
|
||||
|
||||
// Trim each line.
|
||||
value = value
|
||||
.split('\n')
|
||||
.map((line = '') => {
|
||||
return line.trim();
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// No more than 2x newline, per "paragraph".
|
||||
value = value.replace(/\n\n+/g, '\n\n');
|
||||
|
||||
// Clean up spaces.
|
||||
value = value.replace(/[ ]+/g, ' ');
|
||||
value = value.trim();
|
||||
|
||||
// Expose string.
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
You would call this when a user pastes from
|
||||
the clipboard into a `contenteditable` area.
|
||||
*/
|
||||
export function convertOnPaste(event = { preventDefault() {} }, emojiList) {
|
||||
// Prevent paste.
|
||||
event.preventDefault();
|
||||
|
||||
// Set later.
|
||||
let value = '';
|
||||
|
||||
// Does method exist?
|
||||
const hasEventClipboard = !!(
|
||||
event.clipboardData &&
|
||||
typeof event.clipboardData === 'object' &&
|
||||
typeof event.clipboardData.getData === 'function'
|
||||
);
|
||||
|
||||
// Get clipboard data?
|
||||
if (hasEventClipboard) {
|
||||
value = event.clipboardData.getData('text/plain');
|
||||
}
|
||||
|
||||
// Insert into temp `<textarea>`, read back out.
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = value;
|
||||
value = textarea.innerText;
|
||||
|
||||
// Clean up text.
|
||||
value = convertToText(value);
|
||||
|
||||
const HTML = emojify(value, emojiList);
|
||||
|
||||
// Insert text.
|
||||
if (typeof document.execCommand === 'function') {
|
||||
document.execCommand('insertHTML', false, HTML);
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmojiMarkup(data, isCustom) {
|
||||
const emojiUrl = isCustom ? data.emoji : data.url;
|
||||
const emojiName = (
|
||||
isCustom
|
||||
? data.name
|
||||
: data.url.split('\\').pop().split('/').pop().split('.').shift()
|
||||
).toLowerCase();
|
||||
return (
|
||||
'<img class="emoji" alt=":' +
|
||||
emojiName +
|
||||
':" title=":' +
|
||||
emojiName +
|
||||
':" src="' +
|
||||
emojiUrl +
|
||||
'"/>'
|
||||
);
|
||||
}
|
||||
|
||||
// trim html white space characters from ends of messages for more accurate counting
|
||||
export function trimNbsp(html) {
|
||||
return html.replace(/^(?: |\s)+|(?: |\s)+$/gi, '');
|
||||
}
|
||||
|
||||
export function emojify(HTML, emojiList) {
|
||||
const textValue = convertToText(HTML);
|
||||
|
||||
for (var lastPos = textValue.length; lastPos >= 0; lastPos--) {
|
||||
const endPos = textValue.lastIndexOf(':', lastPos);
|
||||
if (endPos <= 0) {
|
||||
break;
|
||||
}
|
||||
const startPos = textValue.lastIndexOf(':', endPos - 1);
|
||||
if (startPos === -1) {
|
||||
break;
|
||||
}
|
||||
const typedEmoji = textValue.substring(startPos + 1, endPos).trim();
|
||||
const emojiIndex = emojiList.findIndex(function (emojiItem) {
|
||||
return emojiItem.name.toLowerCase() === typedEmoji.toLowerCase();
|
||||
});
|
||||
|
||||
if (emojiIndex != -1) {
|
||||
const emojiImgElement = createEmojiMarkup(emojiList[emojiIndex], true);
|
||||
HTML = HTML.replace(':' + typedEmoji + ':', emojiImgElement);
|
||||
}
|
||||
}
|
||||
return HTML;
|
||||
}
|
||||
|
||||
// MODERATOR UTILS
|
||||
export function checkIsModerator(message) {
|
||||
const { user } = message;
|
||||
const { scopes } = user;
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return scopes.includes('MODERATOR');
|
||||
}
|
66
webroot/js/utils/constants.js
Normal file
66
webroot/js/utils/constants.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
// misc constants used throughout the app
|
||||
|
||||
export const URL_STATUS = `/api/status`;
|
||||
export const URL_CHAT_HISTORY = `/api/chat`;
|
||||
export const URL_CUSTOM_EMOJIS = `/api/emoji`;
|
||||
export const URL_CONFIG = `/api/config`;
|
||||
export const URL_VIEWER_PING = `/api/ping`;
|
||||
|
||||
// inline moderation actions
|
||||
export const URL_HIDE_MESSAGE = `/api/chat/updatemessagevisibility`;
|
||||
export const URL_BAN_USER = `/api/chat/users/setenabled`;
|
||||
|
||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||
export const URL_STREAM = `/hls/stream.m3u8`;
|
||||
export const URL_WEBSOCKET = `${
|
||||
location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
}://${location.host}/ws`;
|
||||
export const URL_CHAT_REGISTRATION = `/api/chat/register`;
|
||||
export const URL_FOLLOWERS = `/api/followers`;
|
||||
|
||||
export const TIMER_STATUS_UPDATE = 5000; // ms
|
||||
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||
export const TIMER_STREAM_DURATION_COUNTER = 1000;
|
||||
export const TEMP_IMAGE =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
|
||||
export const OWNCAST_LOGO_LOCAL = '/img/logo.svg';
|
||||
|
||||
export const MESSAGE_OFFLINE = 'Stream is offline.';
|
||||
export const MESSAGE_ONLINE = 'Stream is online.';
|
||||
|
||||
export const URL_OWNCAST = 'https://owncast.online'; // used in footer
|
||||
export const PLAYER_VOLUME = 'owncast_volume';
|
||||
|
||||
export const KEY_ACCESS_TOKEN = 'owncast_access_token';
|
||||
export const KEY_EMBED_CHAT_ACCESS_TOKEN = 'owncast_embed_chat_access_token';
|
||||
export const KEY_USERNAME = 'owncast_username';
|
||||
export const KEY_CUSTOM_USERNAME_SET = 'owncast_custom_username_set';
|
||||
export const KEY_CHAT_DISPLAYED = 'owncast_chat';
|
||||
export const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent';
|
||||
export const CHAT_INITIAL_PLACEHOLDER_TEXT =
|
||||
'Type here to chat, no account necessary.';
|
||||
export const CHAT_PLACEHOLDER_TEXT = 'Message';
|
||||
export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
|
||||
export const CHAT_MAX_MESSAGE_LENGTH = 500;
|
||||
export const EST_SOCKET_PAYLOAD_BUFFER = 512;
|
||||
export const CHAT_CHAR_COUNT_BUFFER = 20;
|
||||
export const CHAT_OK_KEYCODES = [
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowRight',
|
||||
'ArrowDown',
|
||||
'Shift',
|
||||
'Meta',
|
||||
'Alt',
|
||||
'Delete',
|
||||
'Backspace',
|
||||
];
|
||||
export const CHAT_KEY_MODIFIERS = ['Control', 'Shift', 'Meta', 'Alt'];
|
||||
export const MESSAGE_JUMPTOBOTTOM_BUFFER = 500;
|
||||
|
||||
// app styling
|
||||
export const WIDTH_SINGLE_COL = 780;
|
||||
export const HEIGHT_SHORT_WIDE = 500;
|
||||
export const ORIENTATION_PORTRAIT = 'portrait';
|
||||
export const ORIENTATION_LANDSCAPE = 'landscape';
|
210
webroot/js/utils/helpers.js
Normal file
210
webroot/js/utils/helpers.js
Normal file
|
@ -0,0 +1,210 @@
|
|||
import { ORIENTATION_LANDSCAPE, ORIENTATION_PORTRAIT } from './constants.js';
|
||||
|
||||
export function getLocalStorage(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setLocalStorage(key, value) {
|
||||
try {
|
||||
if (value !== '' && value !== null) {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clearLocalStorage(key) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
// jump down to the max height of a div, with a slight delay
|
||||
export function jumpToBottom(element) {
|
||||
if (!element) return;
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
element.scrollTo({
|
||||
top: element.scrollHeight,
|
||||
left: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
},
|
||||
50,
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
// convert newlines to <br>s
|
||||
export function addNewlines(str) {
|
||||
return str.replace(/(?:\r\n|\r|\n)/g, '<br />');
|
||||
}
|
||||
|
||||
export function pluralize(string, count) {
|
||||
if (count === 1) {
|
||||
return string;
|
||||
} else {
|
||||
return string + 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Trying to determine if browser is mobile/tablet.
|
||||
// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
||||
export function hasTouchScreen() {
|
||||
let hasTouch = false;
|
||||
if ('maxTouchPoints' in navigator) {
|
||||
hasTouch = navigator.maxTouchPoints > 0;
|
||||
} else if ('msMaxTouchPoints' in navigator) {
|
||||
hasTouch = navigator.msMaxTouchPoints > 0;
|
||||
} else {
|
||||
var mQ = window.matchMedia && matchMedia('(pointer:coarse)');
|
||||
if (mQ && mQ.media === '(pointer:coarse)') {
|
||||
hasTouch = !!mQ.matches;
|
||||
} else if ('orientation' in window) {
|
||||
hasTouch = true; // deprecated, but good fallback
|
||||
} else {
|
||||
// Only as a last resort, fall back to user agent sniffing
|
||||
hasTouch = navigator.userAgentData.mobile;
|
||||
}
|
||||
}
|
||||
return hasTouch;
|
||||
}
|
||||
|
||||
export function getOrientation(forTouch = false) {
|
||||
// chrome mobile gives misleading matchMedia result when keyboard is up
|
||||
if (forTouch && window.screen && window.screen.orientation) {
|
||||
return window.screen.orientation.type.match('portrait')
|
||||
? ORIENTATION_PORTRAIT
|
||||
: ORIENTATION_LANDSCAPE;
|
||||
} else {
|
||||
// all other cases
|
||||
return window.matchMedia('(orientation: portrait)').matches
|
||||
? ORIENTATION_PORTRAIT
|
||||
: ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
}
|
||||
|
||||
export function padLeft(text, pad, size) {
|
||||
return String(pad.repeat(size) + text).slice(-size);
|
||||
}
|
||||
|
||||
export function parseSecondsToDurationString(seconds = 0) {
|
||||
const finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0;
|
||||
|
||||
const days = Math.floor(finiteSeconds / 86400);
|
||||
const daysString = days > 0 ? `${days} day${days > 1 ? 's' : ''} ` : '';
|
||||
|
||||
const hours = Math.floor((finiteSeconds / 3600) % 24);
|
||||
const hoursString = hours || days ? padLeft(`${hours}:`, '0', 3) : '';
|
||||
|
||||
const mins = Math.floor((finiteSeconds / 60) % 60);
|
||||
const minString = padLeft(`${mins}:`, '0', 3);
|
||||
|
||||
const secs = Math.floor(finiteSeconds % 60);
|
||||
const secsString = padLeft(`${secs}`, '0', 2);
|
||||
|
||||
return daysString + hoursString + minString + secsString;
|
||||
}
|
||||
|
||||
export function setVHvar() {
|
||||
var vh = window.innerHeight * 0.01;
|
||||
// Then we set the value in the --vh custom property to the root of the document
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
}
|
||||
|
||||
export function doesObjectSupportFunction(object, functionName) {
|
||||
return typeof object[functionName] === 'function';
|
||||
}
|
||||
|
||||
// return a string of css classes
|
||||
export function classNames(json) {
|
||||
const classes = [];
|
||||
|
||||
Object.entries(json).map(function (item) {
|
||||
const [key, value] = item;
|
||||
if (value) {
|
||||
classes.push(key);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
// taken from
|
||||
// https://medium.com/@TCAS3/debounce-deep-dive-javascript-es6-e6f8d983b7a1
|
||||
export function debounce(fn, time) {
|
||||
let timeout;
|
||||
|
||||
return function () {
|
||||
const functionCall = () => fn.apply(this, arguments);
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(functionCall, time);
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiffInDaysFromNow(timestamp) {
|
||||
const time = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
return (new Date() - time) / (24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
// "Last live today at [time]" or "last live [date]"
|
||||
export function makeLastOnlineString(timestamp) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
let string = '';
|
||||
const time = new Date(timestamp);
|
||||
const comparisonDate = new Date(time).setHours(0, 0, 0, 0);
|
||||
|
||||
if (comparisonDate == new Date().setHours(0, 0, 0, 0)) {
|
||||
const atTime = time.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
string = `Today ${atTime}`;
|
||||
} else {
|
||||
string = time.toLocaleDateString();
|
||||
}
|
||||
|
||||
return `Last live: ${string}`;
|
||||
}
|
||||
|
||||
// Routing & Tabs
|
||||
export const ROUTE_RECORDINGS = 'recordings';
|
||||
export const ROUTE_SCHEDULE = 'schedule';
|
||||
// looks for `/recording|schedule/id` pattern to determine what to display from the tab view
|
||||
export function checkUrlPathForDisplay() {
|
||||
const pathTest = [ROUTE_RECORDINGS, ROUTE_SCHEDULE];
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
|
||||
if (pathParts.length >= 2) {
|
||||
const part = pathParts[1].toLowerCase();
|
||||
if (pathTest.includes(part)) {
|
||||
return {
|
||||
section: part,
|
||||
sectionId: pathParts[2] || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function paginateArray(items, page, perPage) {
|
||||
const offset = perPage * (page - 1);
|
||||
const totalPages = Math.ceil(items.length / perPage);
|
||||
const paginatedItems = items.slice(offset, perPage * page);
|
||||
|
||||
return {
|
||||
previousPage: page - 1 ? page - 1 : null,
|
||||
nextPage: totalPages > page ? page + 1 : null,
|
||||
total: items.length,
|
||||
totalPages: totalPages,
|
||||
items: paginatedItems,
|
||||
};
|
||||
}
|
17
webroot/js/utils/user-colors.js
Normal file
17
webroot/js/utils/user-colors.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
export function messageBubbleColorForHue(hue) {
|
||||
// Tweak these to adjust the result of the color
|
||||
const saturation = 50;
|
||||
const lightness = 50;
|
||||
const alpha = 'var(--message-background-alpha)';
|
||||
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||
}
|
||||
|
||||
export function textColorForHue(hue) {
|
||||
// Tweak these to adjust the result of the color
|
||||
const saturation = 70;
|
||||
const lightness = 80;
|
||||
const alpha = 0.85;
|
||||
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||
}
|
198
webroot/js/utils/websocket.js
Normal file
198
webroot/js/utils/websocket.js
Normal file
|
@ -0,0 +1,198 @@
|
|||
import { URL_WEBSOCKET } from './constants.js';
|
||||
/**
|
||||
* These are the types of messages that we can handle with the websocket.
|
||||
* Mostly used by `websocket.js` but if other components need to handle
|
||||
* different types then it can import this file.
|
||||
*/
|
||||
export const SOCKET_MESSAGE_TYPES = {
|
||||
CHAT: 'CHAT',
|
||||
PING: 'PING',
|
||||
NAME_CHANGE: 'NAME_CHANGE',
|
||||
PONG: 'PONG',
|
||||
SYSTEM: 'SYSTEM',
|
||||
USER_JOINED: 'USER_JOINED',
|
||||
CHAT_ACTION: 'CHAT_ACTION',
|
||||
FEDIVERSE_ENGAGEMENT_FOLLOW: 'FEDIVERSE_ENGAGEMENT_FOLLOW',
|
||||
FEDIVERSE_ENGAGEMENT_LIKE: 'FEDIVERSE_ENGAGEMENT_LIKE',
|
||||
FEDIVERSE_ENGAGEMENT_REPOST: 'FEDIVERSE_ENGAGEMENT_REPOST',
|
||||
CONNECTED_USER_INFO: 'CONNECTED_USER_INFO',
|
||||
ERROR_USER_DISABLED: 'ERROR_USER_DISABLED',
|
||||
ERROR_NEEDS_REGISTRATION: 'ERROR_NEEDS_REGISTRATION',
|
||||
ERROR_MAX_CONNECTIONS_EXCEEDED: 'ERROR_MAX_CONNECTIONS_EXCEEDED',
|
||||
VISIBILITY_UPDATE: 'VISIBILITY-UPDATE',
|
||||
};
|
||||
|
||||
export const CALLBACKS = {
|
||||
RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived',
|
||||
WEBSOCKET_CONNECTED: 'websocketConnected',
|
||||
};
|
||||
|
||||
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
|
||||
|
||||
export default class Websocket {
|
||||
constructor(accessToken) {
|
||||
this.websocket = null;
|
||||
this.websocketReconnectTimer = null;
|
||||
this.accessToken = accessToken;
|
||||
|
||||
this.websocketConnectedListeners = [];
|
||||
this.websocketDisconnectListeners = [];
|
||||
this.rawMessageListeners = [];
|
||||
|
||||
this.send = this.send.bind(this);
|
||||
this.createAndConnect = this.createAndConnect.bind(this);
|
||||
this.scheduleReconnect = this.scheduleReconnect.bind(this);
|
||||
this.shutdown = this.shutdown.bind(this);
|
||||
|
||||
this.isShutdown = false;
|
||||
|
||||
this.createAndConnect();
|
||||
}
|
||||
|
||||
createAndConnect() {
|
||||
const url = new URL(URL_WEBSOCKET);
|
||||
url.searchParams.append('accessToken', this.accessToken);
|
||||
|
||||
const ws = new WebSocket(url.toString());
|
||||
ws.onopen = this.onOpen.bind(this);
|
||||
ws.onclose = this.onClose.bind(this);
|
||||
ws.onerror = this.onError.bind(this);
|
||||
ws.onmessage = this.onMessage.bind(this);
|
||||
|
||||
this.websocket = ws;
|
||||
}
|
||||
|
||||
// Other components should register for websocket callbacks.
|
||||
addListener(type, callback) {
|
||||
if (type == CALLBACKS.WEBSOCKET_CONNECTED) {
|
||||
this.websocketConnectedListeners.push(callback);
|
||||
} else if (type == CALLBACKS.WEBSOCKET_DISCONNECTED) {
|
||||
this.websocketDisconnectListeners.push(callback);
|
||||
} else if (type == CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED) {
|
||||
this.rawMessageListeners.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Interface with other components
|
||||
|
||||
// Outbound: Other components can pass an object to `send`.
|
||||
send(message) {
|
||||
// Sanity check that what we're sending is a valid type.
|
||||
if (!message.type || !SOCKET_MESSAGE_TYPES[message.type]) {
|
||||
console.warn(
|
||||
`Outbound message: Unknown socket message type: "${message.type}" sent.`
|
||||
);
|
||||
}
|
||||
|
||||
const messageJSON = JSON.stringify(message);
|
||||
this.websocket.send(messageJSON);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.isShutdown = true;
|
||||
this.websocket.close();
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
// Fire the callbacks of the listeners.
|
||||
|
||||
notifyWebsocketConnectedListeners(message) {
|
||||
this.websocketConnectedListeners.forEach(function (callback) {
|
||||
callback(message);
|
||||
});
|
||||
}
|
||||
|
||||
notifyWebsocketDisconnectedListeners(message) {
|
||||
this.websocketDisconnectListeners.forEach(function (callback) {
|
||||
callback(message);
|
||||
});
|
||||
}
|
||||
|
||||
notifyRawMessageListeners(message) {
|
||||
this.rawMessageListeners.forEach(function (callback) {
|
||||
callback(message);
|
||||
});
|
||||
}
|
||||
|
||||
// Internal websocket callbacks
|
||||
|
||||
onOpen(e) {
|
||||
if (this.websocketReconnectTimer) {
|
||||
clearTimeout(this.websocketReconnectTimer);
|
||||
}
|
||||
|
||||
this.notifyWebsocketConnectedListeners();
|
||||
}
|
||||
|
||||
onClose(e) {
|
||||
// connection closed, discard old websocket and create a new one in 5s
|
||||
this.websocket = null;
|
||||
this.notifyWebsocketDisconnectedListeners();
|
||||
this.handleNetworkingError('Websocket closed.');
|
||||
if (!this.isShutdown) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// On ws error just close the socket and let it re-connect again for now.
|
||||
onError(e) {
|
||||
this.handleNetworkingError(`Socket error: ${JSON.parse(e)}`);
|
||||
this.websocket.close();
|
||||
if (!this.isShutdown) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
this.websocketReconnectTimer = setTimeout(
|
||||
this.createAndConnect,
|
||||
TIMER_WEBSOCKET_RECONNECT
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
onMessage is fired when an inbound object comes across the websocket.
|
||||
If the message is of type `PING` we send a `PONG` back and do not
|
||||
pass it along to listeners.
|
||||
*/
|
||||
onMessage(e) {
|
||||
// Optimization where multiple events can be sent within a
|
||||
// single websocket message. So split them if needed.
|
||||
var messages = e.data.split('\n');
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
try {
|
||||
var model = JSON.parse(messages[i]);
|
||||
} catch (e) {
|
||||
console.error(e, e.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!model.type) {
|
||||
console.error('No type provided', model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send PONGs
|
||||
if (model.type === SOCKET_MESSAGE_TYPES.PING) {
|
||||
this.sendPong();
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify any of the listeners via the raw socket message callback.
|
||||
this.notifyRawMessageListeners(model);
|
||||
}
|
||||
}
|
||||
|
||||
// Reply to a PING as a keep alive.
|
||||
sendPong() {
|
||||
const pong = { type: SOCKET_MESSAGE_TYPES.PONG };
|
||||
this.send(pong);
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(
|
||||
`Chat has been disconnected and is likely not working for you. It's possible you were removed from chat. If this is a server configuration issue, visit troubleshooting steps to resolve. https://owncast.online/docs/troubleshooting/#chat-is-disabled: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
25
webroot/js/web_modules/@joeattardi/emoji-button.js
Normal file
25
webroot/js/web_modules/@joeattardi/emoji-button.js
Normal file
File diff suppressed because one or more lines are too long
116
webroot/js/web_modules/@videojs/themes/fantasy/index.css
Normal file
116
webroot/js/web_modules/@videojs/themes/fantasy/index.css
Normal file
|
@ -0,0 +1,116 @@
|
|||
.vjs-theme-fantasy {
|
||||
--vjs-theme-fantasy--primary: #9f44b4;
|
||||
--vjs-theme-fantasy--secondary: #fff;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-big-play-button {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: none;
|
||||
line-height: 70px;
|
||||
font-size: 80px;
|
||||
border: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -35px;
|
||||
margin-left: -35px;
|
||||
color: var(--vjs-theme-fantasy--primary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy:hover .vjs-big-play-button,
|
||||
.vjs-theme-fantasy.vjs-big-play-button:focus {
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-control-bar {
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-button > .vjs-icon-placeholder::before {
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-time-control {
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
/* Play Button */
|
||||
.vjs-theme-fantasy .vjs-play-control {
|
||||
font-size: 1.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-volume-panel {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-volume-bar {
|
||||
margin-top: 2.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-city .vjs-volume-panel:hover .vjs-volume-control.vjs-volume-horizontal {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control .vjs-progress-holder {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control:hover .vjs-progress-holder {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-control .vjs-icon-placeholder::before {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
margin-top: 0.2em;
|
||||
border-radius: 1em;
|
||||
border: 3px solid var(--vjs-theme-fantasy--secondary);
|
||||
top: 2px;
|
||||
left: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-control:hover .vjs-icon-placeholder::before {
|
||||
border: 3px solid var(--vjs-theme-fantasy--secondary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-progress {
|
||||
background-color: var(--vjs-theme-fantasy--primary);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-play-progress::before {
|
||||
height: 0.8em;
|
||||
width: 0.8em;
|
||||
content: '';
|
||||
background-color: var(--vjs-theme-fantasy--primary);
|
||||
border: 4px solid var(--vjs-theme-fantasy--secondary);
|
||||
border-radius: 0.8em;
|
||||
top: -0.25em;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-progress-control {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-fullscreen-control {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy .vjs-remaining-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nyan version */
|
||||
.vjs-theme-fantasy.nyan .vjs-play-progress {
|
||||
background: linear-gradient(to bottom, #fe0000 0%, #fe9a01 16.666666667%, #fe9a01 16.666666667%, #ffff00 33.332666667%, #ffff00 33.332666667%, #32ff00 49.999326667%, #32ff00 49.999326667%, #0099fe 66.6659926%, #0099fe 66.6659926%, #6633ff 83.33266%, #6633ff 83.33266%);
|
||||
}
|
||||
|
||||
.vjs-theme-fantasy.nyan .vjs-play-progress::before {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
background: svg-load('icons/nyan-cat.svg', fill=#fff) no-repeat;
|
||||
border: none;
|
||||
top: -0.35em;
|
||||
}
|
21
webroot/js/web_modules/common/_commonjsHelpers-8c19dec8.js
Normal file
21
webroot/js/web_modules/common/_commonjsHelpers-8c19dec8.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
|
||||
|
||||
function getDefaultExportFromCjs (x) {
|
||||
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
||||
}
|
||||
|
||||
function createCommonjsModule(fn, basedir, module) {
|
||||
return module = {
|
||||
path: basedir,
|
||||
exports: {},
|
||||
require: function (path, base) {
|
||||
return commonjsRequire(path, (base === undefined || base === null) ? module.path : base);
|
||||
}
|
||||
}, fn(module, module.exports), module.exports;
|
||||
}
|
||||
|
||||
function commonjsRequire () {
|
||||
throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs');
|
||||
}
|
||||
|
||||
export { commonjsGlobal as a, createCommonjsModule as c, getDefaultExportFromCjs as g };
|
3
webroot/js/web_modules/htm.js
Normal file
3
webroot/js/web_modules/htm.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a);}return e},t=new Map;function htm_module(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e="";},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0]);}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
|
||||
|
||||
export { htm_module as default };
|
13
webroot/js/web_modules/import-map.json
Normal file
13
webroot/js/web_modules/import-map.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"imports": {
|
||||
"@joeattardi/emoji-button": "./@joeattardi/emoji-button.js",
|
||||
"@videojs/themes/fantasy/index.css": "./@videojs/themes/fantasy/index.css",
|
||||
"htm": "./htm.js",
|
||||
"mark.js/dist/mark.es6.min.js": "./markjs/dist/mark.es6.min.js",
|
||||
"micromodal/dist/micromodal.min.js": "./micromodal/dist/micromodal.min.js",
|
||||
"preact": "./preact.js",
|
||||
"tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css",
|
||||
"video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css",
|
||||
"video.js/dist/video.min.js": "./videojs/dist/video.min.js"
|
||||
}
|
||||
}
|
13
webroot/js/web_modules/markjs/dist/mark.es6.min.js
vendored
Normal file
13
webroot/js/web_modules/markjs/dist/mark.es6.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
webroot/js/web_modules/micromodal/dist/micromodal.min.js
vendored
Normal file
10
webroot/js/web_modules/micromodal/dist/micromodal.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
webroot/js/web_modules/preact.js
Normal file
3
webroot/js/web_modules/preact.js
Normal file
File diff suppressed because one or more lines are too long
1
webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
vendored
Normal file
1
webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
webroot/js/web_modules/videojs/dist/video-js.min.css
vendored
Normal file
1
webroot/js/web_modules/videojs/dist/video-js.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
33
webroot/js/web_modules/videojs/dist/video.min.js
vendored
Normal file
33
webroot/js/web_modules/videojs/dist/video.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
webroot/js/web_modules/videojs/video-js.min.css
vendored
Normal file
1
webroot/js/web_modules/videojs/video-js.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue