stream doesn't want to work

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

View file

@ -0,0 +1,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;
}
}

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

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

View file

@ -0,0 +1,399 @@
import { h, Component, createRef } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js';
import ContentEditable, { replaceCaret } from './content-editable.js';
import {
generatePlaceholderText,
getCaretPosition,
convertToText,
convertOnPaste,
createEmojiMarkup,
trimNbsp,
emojify,
} from '../../utils/chat.js';
import {
getLocalStorage,
setLocalStorage,
classNames,
} from '../../utils/helpers.js';
import {
URL_CUSTOM_EMOJIS,
KEY_CHAT_FIRST_MESSAGE_SENT,
CHAT_CHAR_COUNT_BUFFER,
CHAT_OK_KEYCODES,
CHAT_KEY_MODIFIERS,
} from '../../utils/constants.js';
export default class ChatInput extends Component {
constructor(props, context) {
super(props, context);
this.formMessageInput = createRef();
this.emojiPickerButton = createRef();
this.messageCharCount = 0;
this.prepNewLine = false;
this.modifierKeyPressed = false; // control/meta/shift/alt
this.state = {
inputHTML: '',
inputCharsLeft: props.inputMaxBytes,
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
emojiPicker: null,
emojiList: null,
emojiNames: null,
};
this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this);
this.handleEmojiSelected = this.handleEmojiSelected.bind(this);
this.getCustomEmojis = this.getCustomEmojis.bind(this);
this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this);
this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this);
this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this);
this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this);
this.handlePaste = this.handlePaste.bind(this);
this.handleContentEditableChange =
this.handleContentEditableChange.bind(this);
}
componentDidMount() {
this.getCustomEmojis();
}
getCustomEmojis() {
fetch(URL_CUSTOM_EMOJIS)
.then((response) => {
if (!response.ok) {
throw new Error(`Network response was not ok ${response.ok}`);
}
return response.json();
})
.then((json) => {
const emojiList = json;
const emojiNames = emojiList.map((emoji) => emoji.name);
const emojiPicker = new EmojiButton({
zIndex: 100,
theme: 'owncast', // see chat.css
custom: json,
initialCategory: 'custom',
showPreview: false,
autoHide: false,
autoFocusSearch: false,
showAnimation: false,
emojiSize: '24px',
position: 'right-start',
strategy: 'absolute',
});
emojiPicker.on('emoji', (emoji) => {
this.handleEmojiSelected(emoji);
});
emojiPicker.on('hidden', () => {
this.formMessageInput.current.focus();
replaceCaret(this.formMessageInput.current);
});
this.setState({ emojiNames, emojiList, emojiPicker });
})
.catch((error) => {
// this.handleNetworkingError(`Emoji Fetch: ${error}`);
});
}
handleEmojiButtonClick() {
const { emojiPicker } = this.state;
if (emojiPicker) {
emojiPicker.togglePicker(this.emojiPickerButton.current);
}
}
handleEmojiSelected(emoji) {
const { inputHTML, inputCharsLeft } = this.state;
// if we're already at char limit, don't do anything
if (inputCharsLeft < 0) {
return;
}
let content = '';
if (emoji.url) {
content = createEmojiMarkup(emoji, false);
} else {
content = emoji.emoji;
}
const position = getCaretPosition(this.formMessageInput.current);
const newHTML =
inputHTML.substring(0, position) +
content +
inputHTML.substring(position);
const charsLeft = this.calculateCurrentBytesLeft(newHTML);
this.setState({
inputHTML: newHTML,
inputCharsLeft: charsLeft,
});
// a hacky way add focus back into input field
setTimeout(() => {
const input = this.formMessageInput.current;
input.focus();
replaceCaret(input);
}, 100);
}
// autocomplete text from the given "list". "token" marks the start of word lookup.
autoComplete(token, list) {
const { inputHTML } = this.state;
const position = getCaretPosition(this.formMessageInput.current);
const at = inputHTML.lastIndexOf(token, position - 1);
if (at === -1) {
return false;
}
let partial = inputHTML.substring(at + 1, position).trim();
if (this.partial === undefined) {
this.partial = [];
}
if (partial === this.suggestion) {
partial = this.partial[token];
} else {
this.partial[token] = partial;
}
const possibilities = list.filter(function (item) {
return item.toLowerCase().startsWith(partial.toLowerCase());
});
if (this.completionIndex === undefined) {
this.completionIndex = [];
}
if (
this.completionIndex[token] === undefined ||
++this.completionIndex[token] >= possibilities.length
) {
this.completionIndex[token] = 0;
}
if (possibilities.length > 0) {
this.suggestion = possibilities[this.completionIndex[token]];
const newHTML =
inputHTML.substring(0, at + 1) +
this.suggestion +
' ' +
inputHTML.substring(position);
this.setState({
inputHTML: newHTML,
inputCharsLeft: this.calculateCurrentBytesLeft(newHTML),
});
}
return true;
}
// replace :emoji: with the emoji <img>
injectEmoji() {
const { inputHTML, emojiList } = this.state;
const textValue = convertToText(inputHTML);
const processedHTML = emojify(inputHTML, emojiList);
if (textValue != convertToText(processedHTML)) {
this.setState({
inputHTML: processedHTML,
});
return true;
}
return false;
}
handleMessageInputKeydown(event) {
const key = event && event.key;
if (key === 'Enter') {
if (!this.prepNewLine) {
this.sendMessage();
event.preventDefault();
this.prepNewLine = false;
return;
}
}
// allow key presses such as command/shift/meta, etc even when message length is full later.
if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = true;
}
if (key === 'Control' || key === 'Shift') {
this.prepNewLine = true;
}
if (key === 'Tab') {
const { chatUserNames } = this.props;
const { emojiNames } = this.state;
if (this.autoComplete('@', chatUserNames)) {
event.preventDefault();
}
if (this.autoComplete(':', emojiNames)) {
event.preventDefault();
}
}
// if new input pushes the potential chars over, don't do anything
const formField = this.formMessageInput.current;
const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML);
if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) {
if (!this.modifierKeyPressed) {
event.preventDefault(); // prevent typing more
}
return;
}
}
handleMessageInputKeyup(event) {
const { key } = event;
if (key === 'Control' || key === 'Shift') {
this.prepNewLine = false;
}
if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = false;
}
if (key === ':' || key === ';') {
this.injectEmoji();
}
}
handleMessageInputBlur() {
this.prepNewLine = false;
this.modifierKeyPressed = false;
}
handlePaste(event) {
// don't allow paste if too much text already
if (this.state.inputCharsLeft < 0) {
event.preventDefault();
return;
}
convertOnPaste(event, this.state.emojiList);
this.handleMessageInputKeydown(event);
}
handleSubmitChatButton(event) {
event.preventDefault();
this.sendMessage();
}
sendMessage() {
const { handleSendMessage, inputMaxBytes } = this.props;
const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state;
if (inputCharsLeft < 0) {
return;
}
const message = convertToText(inputHTML);
const newStates = {
inputHTML: '',
inputCharsLeft: inputMaxBytes,
};
handleSendMessage(message);
if (!hasSentFirstChatMessage) {
newStates.hasSentFirstChatMessage = true;
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
}
// clear things out.
this.setState(newStates);
}
handleContentEditableChange(event) {
const value = event.target.value;
this.setState({
inputHTML: value,
inputCharsLeft: this.calculateCurrentBytesLeft(value),
});
}
calculateCurrentBytesLeft(inputContent) {
const { inputMaxBytes } = this.props;
const curBytes = new Blob([trimNbsp(inputContent)]).size;
return inputMaxBytes - curBytes;
}
render(props, state) {
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } =
state;
const { inputEnabled, inputMaxBytes } = props;
const emojiButtonStyle = {
display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
};
const extraClasses = classNames({
'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER,
});
const placeholderText = generatePlaceholderText(
inputEnabled,
hasSentFirstChatMessage
);
return html`
<div
id="message-input-container"
class="relative shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20 ${extraClasses}"
>
<div
id="message-input-wrap"
class="flex flex-row justify-end appearance-none w-full bg-gray-200 border border-black-500 rounded py-2 px-2 pr-20 my-2 overflow-auto"
>
<${ContentEditable}
id="message-input"
class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none"
placeholderText=${placeholderText}
innerRef=${this.formMessageInput}
html=${inputHTML}
disabled=${!inputEnabled}
onChange=${this.handleContentEditableChange}
onKeyDown=${this.handleMessageInputKeydown}
onKeyUp=${this.handleMessageInputKeyup}
onBlur=${this.handleMessageInputBlur}
onPaste=${this.handlePaste}
/>
</div>
<div
id="message-form-actions"
class="absolute flex flex-col justify-end items-end mr-4"
>
<span class="flex flex-row justify-center">
<button
ref=${this.emojiPickerButton}
id="emoji-button"
class="text-3xl leading-3 cursor-pointer text-purple-600"
type="button"
style=${emojiButtonStyle}
onclick=${this.handleEmojiButtonClick}
aria-label="Select an emoji"
disabled=${!inputEnabled}
>
<img src="../../../img/smiley.png" />
</button>
<button
id="send-message-button"
class="text-sm text-white rounded bg-gray-600 hidden p-1 ml-1 -mr-2"
type="button"
onclick=${this.handleSubmitChatButton}
disabled=${inputHTML === '' || inputCharsLeft < 0}
aria-label="Send message"
>
Send
</button>
</span>
<span id="message-form-warning" class="text-red-600 text-xs"
>${inputCharsLeft} bytes</span
>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,212 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import Mark from '/js/web_modules/markjs/dist/mark.es6.min.js';
const html = htm.bind(h);
import {
messageBubbleColorForHue,
textColorForHue,
} from '../../utils/user-colors.js';
import { convertToText, checkIsModerator } from '../../utils/chat.js';
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
import { getDiffInDaysFromNow } from '../../utils/helpers.js';
import ModeratorActions from './moderator-actions.js';
export default class ChatMessageView extends Component {
constructor(props) {
super(props);
this.state = {
formattedMessage: '',
moderatorMenuOpen: false,
};
}
shouldComponentUpdate(nextProps, nextState) {
const { formattedMessage } = this.state;
const { formattedMessage: nextFormattedMessage } = nextState;
return (
formattedMessage !== nextFormattedMessage ||
(!this.props.isModerator && nextProps.isModerator)
);
}
async componentDidMount() {
const { message, username } = this.props;
const { body } = message;
if (message && username) {
const formattedMessage = await formatMessageText(body, username);
this.setState({
formattedMessage,
});
}
}
render() {
const { message, isModerator, accessToken } = this.props;
const { user, timestamp } = message;
// User is required for this component to render.
if (!user) {
return null;
}
const { displayName, displayColor, createdAt } = user;
const isAuthorModerator = checkIsModerator(message);
const isMessageModeratable =
isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT;
const { formattedMessage } = this.state;
if (!formattedMessage) {
return null;
}
const formattedTimestamp = `Sent at ${formatTimestamp(timestamp)}`;
const userMetadata = createdAt
? `${displayName} first joined ${formatTimestamp(createdAt)}`
: null;
const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM;
const authorTextColor = isSystemMessage
? { color: '#fff' }
: { color: textColorForHue(displayColor) };
const backgroundStyle = isSystemMessage
? { backgroundColor: '#667eea' }
: { backgroundColor: messageBubbleColorForHue(displayColor) };
const messageClassString = isSystemMessage
? 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l'
: `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${
isMessageModeratable ? 'moderatable' : ''
}`;
const messageAuthorFlair = isAuthorModerator
? html`<img
class="flair"
title="Moderator"
src="/img/moderator-nobackground.svg"
/>`
: null;
return html`
<div
style=${backgroundStyle}
class=${messageClassString}
title=${formattedTimestamp}
>
<div class="message-content break-words w-full">
<div
style=${authorTextColor}
class="message-author font-bold"
title=${userMetadata}
>
${messageAuthorFlair} ${displayName}
</div>
${isMessageModeratable &&
html`<${ModeratorActions}
message=${message}
accessToken=${accessToken}
/>`}
<div
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
dangerouslySetInnerHTML=${{ __html: formattedMessage }}
></div>
</div>
</div>
`;
}
}
export async function formatMessageText(message, username) {
let formattedText = getMessageWithEmbeds(message);
formattedText = convertToMarkup(formattedText);
return await highlightUsername(formattedText, username);
}
function highlightUsername(message, username) {
// https://github.com/julmot/mark.js/issues/115
const node = document.createElement('span');
node.innerHTML = message;
return new Promise((res) => {
new Mark(node).mark(username, {
element: 'span',
className: 'highlighted px-1 rounded font-bold bg-orange-500',
separateWordSearch: false,
accuracy: {
value: 'exactly',
limiters: [',', '.', "'", '?', '@'],
},
done() {
res(node.innerHTML);
},
});
});
}
function getMessageWithEmbeds(message) {
var embedText = '';
// Make a temporary element so we can actually parse the html and pull anchor tags from it.
// This is a better approach than regex.
var container = document.createElement('p');
container.innerHTML = message;
var anchors = container.getElementsByTagName('a');
for (var i = 0; i < anchors.length; i++) {
const url = anchors[i].href;
if (url.indexOf('instagram.com/p/') > -1) {
embedText += getInstagramEmbedFromURL(url);
}
}
// If this message only consists of a single embeddable link
// then only return the embed and strip the link url from the text.
if (
embedText !== '' &&
anchors.length == 1 &&
isMessageJustAnchor(message, anchors[0])
) {
return embedText;
}
return message + embedText;
}
function getInstagramEmbedFromURL(url) {
const urlObject = new URL(url.replace(/\/$/, ''));
urlObject.pathname += '/embed';
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
}
function isMessageJustAnchor(message, anchor) {
return stripTags(message) === stripTags(anchor.innerHTML);
}
function formatTimestamp(sentAt) {
sentAt = new Date(sentAt);
if (isNaN(sentAt)) {
return '';
}
let diffInDays = getDiffInDaysFromNow(sentAt);
if (diffInDays >= 1) {
return (
`at ${sentAt.toLocaleDateString('en-US', {
dateStyle: 'medium',
})} at ` + sentAt.toLocaleTimeString()
);
}
return `${sentAt.toLocaleTimeString()}`;
}
/*
You would call this when receiving a plain text
value back from an API, and before inserting the
text into the `contenteditable` area on a page.
*/
function convertToMarkup(str = '') {
return convertToText(str).replace(/\n/g, '<p></p>');
}
function stripTags(str) {
return str.replace(/<\/?[^>]+(>|$)/g, '');
}

View file

@ -0,0 +1,508 @@
import { h, Component, createRef } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import Message from './message.js';
import ChatInput from './chat-input.js';
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
import { jumpToBottom, debounce } from '../../utils/helpers.js';
import {
extraUserNamesFromMessageHistory,
checkIsModerator,
} from '../../utils/chat.js';
import {
URL_CHAT_HISTORY,
MESSAGE_JUMPTOBOTTOM_BUFFER,
} from '../../utils/constants.js';
const MAX_RENDER_BACKLOG = 300;
// Add message types that should be displayed in chat to this array.
const renderableChatStyleMessages = [
SOCKET_MESSAGE_TYPES.NAME_CHANGE,
SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO,
SOCKET_MESSAGE_TYPES.USER_JOINED,
SOCKET_MESSAGE_TYPES.CHAT_ACTION,
SOCKET_MESSAGE_TYPES.SYSTEM,
SOCKET_MESSAGE_TYPES.CHAT,
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW,
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE,
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST,
];
export default class Chat extends Component {
constructor(props, context) {
super(props, context);
this.state = {
chatUserNames: [],
// Ordered array of messages sorted by timestamp.
sortedMessages: [],
newMessagesReceived: false,
webSocketConnected: true,
isModerator: false,
};
this.scrollableMessagesContainer = createRef();
this.websocket = null;
this.receivedFirstMessages = false;
this.receivedMessageUpdate = false;
this.hasFetchedHistory = false;
// Unordered dictionary of messages keyed by ID.
this.messages = {};
this.windowBlurred = false;
this.numMessagesSinceBlur = 0;
this.getChatHistory = this.getChatHistory.bind(this);
this.handleNetworkingError = this.handleNetworkingError.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 500);
this.messageListCallback = this.messageListCallback.bind(this);
this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this);
this.scrollToBottom = this.scrollToBottom.bind(this);
this.submitChat = this.submitChat.bind(this);
this.websocketConnected = this.websocketConnected.bind(this);
this.websocketDisconnected = this.websocketDisconnected.bind(this);
}
componentDidMount() {
this.setupWebSocketCallbacks();
window.addEventListener('resize', this.handleWindowResize);
if (!this.props.readonly) {
window.addEventListener('blur', this.handleWindowBlur);
window.addEventListener('focus', this.handleWindowFocus);
}
this.messageListObserver = new MutationObserver(this.messageListCallback);
this.messageListObserver.observe(this.scrollableMessagesContainer.current, {
childList: true,
});
}
shouldComponentUpdate(nextProps, nextState) {
const { username, chatInputEnabled } = this.props;
const { username: nextUserName, chatInputEnabled: nextChatEnabled } =
nextProps;
const {
webSocketConnected,
chatUserNames,
newMessagesReceived,
sortedMessages,
} = this.state;
const {
webSocketConnected: nextSocket,
chatUserNames: nextUserNames,
newMessagesReceived: nextMessagesReceived,
} = nextState;
// If there are an updated number of sorted message then a render pass
// needs to take place to render these new messages.
if (
Object.keys(sortedMessages).length !==
Object.keys(nextState.sortedMessages).length
) {
return true;
}
if (newMessagesReceived) {
return true;
}
return (
username !== nextUserName ||
chatInputEnabled !== nextChatEnabled ||
webSocketConnected !== nextSocket ||
chatUserNames.length !== nextUserNames.length ||
newMessagesReceived !== nextMessagesReceived
);
}
componentDidUpdate(prevProps, prevState) {
const { accessToken } = this.props;
// Fetch chat history
if (!this.hasFetchedHistory && accessToken) {
this.hasFetchedHistory = true;
this.getChatHistory(accessToken);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleWindowResize);
if (!this.props.readonly) {
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus);
}
this.messageListObserver.disconnect();
}
setupWebSocketCallbacks() {
this.websocket = this.props.websocket;
if (this.websocket) {
this.websocket.addListener(
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
this.receivedWebsocketMessage
);
this.websocket.addListener(
CALLBACKS.WEBSOCKET_CONNECTED,
this.websocketConnected
);
this.websocket.addListener(
CALLBACKS.WEBSOCKET_DISCONNECTED,
this.websocketDisconnected
);
}
}
// fetch chat history
async getChatHistory(accessToken) {
const { username } = this.props;
try {
const response = await fetch(
URL_CHAT_HISTORY + `?accessToken=${accessToken}`
);
const data = await response.json();
// Backlog of usernames from history
const allChatUserNames = extraUserNamesFromMessageHistory(data);
const chatUserNames = allChatUserNames.filter((name) => name != username);
this.addNewRenderableMessages(data);
this.setState((previousState) => {
return {
...previousState,
chatUserNames,
};
});
} catch (error) {
this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
}
this.scrollToBottom();
}
receivedWebsocketMessage(message) {
this.handleMessage(message);
}
handleNetworkingError(error) {
// todo: something more useful
console.error('chat error', error);
}
// Give a list of message IDs and the visibility state they should change to.
updateMessagesVisibility(idsToUpdate, visible) {
let messageList = { ...this.messages };
// Iterate through each ID and mark the associated ID in our messages
// dictionary with the new visibility.
for (const id of idsToUpdate) {
const message = messageList[id];
if (message) {
message.visible = visible;
messageList[id] = message;
}
}
const updatedMessagesList = {
...this.messages,
...messageList,
};
this.messages = updatedMessagesList;
this.resortAndRenderMessages();
}
handleChangeModeratorStatus(isModerator) {
if (isModerator !== this.state.isModerator) {
this.setState((previousState) => {
return { ...previousState, isModerator: isModerator };
});
}
}
handleWindowFocusNotificationCount(readonly, messageType) {
// if window is blurred and we get a new message, add 1 to title
if (
!readonly &&
messageType === SOCKET_MESSAGE_TYPES.CHAT &&
this.windowBlurred
) {
this.numMessagesSinceBlur += 1;
}
}
addNewRenderableMessages(messagesArray) {
// Convert the array of chat history messages into an object
// to be merged with the existing chat messages.
const newMessages = messagesArray.reduce(
(o, message) => ({ ...o, [message.id]: message }),
{}
);
// Keep our unsorted collection of messages keyed by ID.
const updatedMessagesList = {
...newMessages,
...this.messages,
};
this.messages = updatedMessagesList;
this.resortAndRenderMessages();
}
resortAndRenderMessages() {
// Convert the unordered dictionary of messages to an ordered array.
// NOTE: This sorts the entire collection of messages on every new message
// because the order a message comes in cannot be trusted that it's the order
// it was sent, you need to sort by timestamp. I don't know if there
// is a performance problem waiting to occur here for larger chat feeds.
var sortedMessages = Object.values(this.messages)
// Filter out messages set to not be visible
.filter((message) => message.visible !== false)
.sort((a, b) => {
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
});
// Cap this list to 300 items to improve browser performance.
if (sortedMessages.length >= MAX_RENDER_BACKLOG) {
sortedMessages = sortedMessages.slice(
sortedMessages.length - MAX_RENDER_BACKLOG
);
}
this.setState((previousState) => {
return {
...previousState,
newMessagesReceived: true,
sortedMessages,
};
});
}
// handle any incoming message
handleMessage(message) {
const { type: messageType } = message;
const { readonly, username } = this.props;
// Allow non-user chat messages to be visible by default.
const messageVisible =
message.visible || messageType !== SOCKET_MESSAGE_TYPES.CHAT;
// Show moderator status
if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
const modStatusUpdate = checkIsModerator(message);
this.handleChangeModeratorStatus(modStatusUpdate);
}
// Change the visibility of messages by ID.
if (messageType === SOCKET_MESSAGE_TYPES.VISIBILITY_UPDATE) {
const idsToUpdate = message.ids;
const visible = message.visible;
this.updateMessagesVisibility(idsToUpdate, visible);
} else if (
renderableChatStyleMessages.includes(messageType) &&
messageVisible
) {
// Add new message to the chat feed.
this.addNewRenderableMessages([message]);
// Update the usernames list, filtering out our own name.
const updatedAllChatUserNames = this.updateAuthorList(message);
if (updatedAllChatUserNames.length) {
const updatedChatUserNames = updatedAllChatUserNames.filter(
(name) => name != username
);
this.setState((previousState) => {
return {
...previousState,
chatUserNames: [...updatedChatUserNames],
};
});
}
}
// Update the window title if needed.
this.handleWindowFocusNotificationCount(readonly, messageType);
}
websocketConnected() {
this.setState((previousState) => {
return {
...previousState,
webSocketConnected: true,
};
});
}
websocketDisconnected() {
this.setState((previousState) => {
return {
...previousState,
webSocketConnected: false,
};
});
}
submitChat(content) {
if (!content) {
return;
}
const message = {
body: content,
type: SOCKET_MESSAGE_TYPES.CHAT,
};
this.websocket.send(message);
}
updateAuthorList(message) {
const { type } = message;
let nameList = this.state.chatUserNames;
if (
type === SOCKET_MESSAGE_TYPES.CHAT &&
!nameList.includes(message.user.displayName)
) {
nameList.push(message.user.displayName);
return nameList;
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
const { oldName, user } = message;
const oldNameIndex = nameList.indexOf(oldName);
nameList.splice(oldNameIndex, 1, user.displayName);
return nameList;
}
return [];
}
scrollToBottom() {
jumpToBottom(this.scrollableMessagesContainer.current);
}
checkShouldScroll() {
const { scrollTop, scrollHeight, clientHeight } =
this.scrollableMessagesContainer.current;
const fullyScrolled = scrollHeight - clientHeight;
const shouldScroll =
scrollHeight >= clientHeight &&
fullyScrolled - scrollTop < MESSAGE_JUMPTOBOTTOM_BUFFER;
return shouldScroll;
}
handleWindowResize() {
this.scrollToBottom();
}
handleWindowBlur() {
this.windowBlurred = true;
}
handleWindowFocus() {
this.windowBlurred = false;
this.numMessagesSinceBlur = 0;
window.document.title = this.props.instanceTitle;
}
// if the messages list grows in number of child message nodes due to new messages received, scroll to bottom.
messageListCallback(mutations) {
const numMutations = mutations.length;
if (numMutations) {
const item = mutations[numMutations - 1];
if (item.type === 'childList' && item.addedNodes.length) {
if (this.state.newMessagesReceived) {
if (!this.receivedFirstMessages) {
this.scrollToBottom();
this.receivedFirstMessages = true;
} else if (this.checkShouldScroll()) {
this.scrollToBottom();
}
this.setState((previousState) => {
return {
...previousState,
newMessagesReceived: false,
};
});
}
}
// update document title if window blurred
if (
this.numMessagesSinceBlur &&
!this.props.readonly &&
this.windowBlurred
) {
this.updateDocumentTitle();
}
}
}
updateDocumentTitle() {
const num =
this.numMessagesSinceBlur > 10 ? '10+' : this.numMessagesSinceBlur;
window.document.title = `${num} 💬 :: ${this.props.instanceTitle}`;
}
render(props, state) {
const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } =
props;
const { sortedMessages, chatUserNames, webSocketConnected, isModerator } =
state;
const messageList = sortedMessages.map(
(message) =>
html`<${Message}
message=${message}
username=${username}
key=${message.id}
isModerator=${isModerator}
accessToken=${accessToken}
/>`
);
if (readonly) {
return html`
<div
id="messages-container"
ref=${this.scrollableMessagesContainer}
class="scrollbar-hidden py-1 overflow-auto"
>
${messageList}
</div>
`;
}
return html`
<section id="chat-container-wrap" class="flex flex-col">
<div
id="chat-container"
class="bg-gray-800 flex flex-col justify-end overflow-auto"
>
<div
id="messages-container"
ref=${this.scrollableMessagesContainer}
class="scrollbar-hidden py-1 overflow-auto z-10"
>
${messageList}
</div>
<${ChatInput}
chatUserNames=${chatUserNames}
inputEnabled=${webSocketConnected && chatInputEnabled}
handleSendMessage=${this.submitChat}
inputMaxBytes=${inputMaxBytes}
/>
</div>
</section>
`;
}
}

View file

@ -0,0 +1,129 @@
/*
Since we can't really import react-contenteditable here, I'm borrowing code for this component from here:
github.com/lovasoa/react-contenteditable/
and here:
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
*/
import { h, Component, createRef } from '/js/web_modules/preact.js';
export function replaceCaret(el) {
// Place the caret at the end of the element
const target = document.createTextNode('');
el.appendChild(target);
// do not move caret if element was not focused
const isTargetFocused = document.activeElement === el;
if (target !== null && target.nodeValue !== null && isTargetFocused) {
var sel = window.getSelection();
if (sel !== null) {
var range = document.createRange();
range.setStart(target, target.nodeValue.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
if (el) el.focus();
}
}
function normalizeHtml(str) {
return str && str.replace(/&nbsp;|\u202F|\u00A0/g, ' ');
}
export default class ContentEditable extends Component {
constructor(props) {
super(props);
this.el = createRef();
this.lastHtml = '';
this.emitChange = this.emitChange.bind(this);
this.getDOMElement = this.getDOMElement.bind(this);
}
shouldComponentUpdate(nextProps) {
const { props } = this;
const el = this.getDOMElement();
// We need not rerender if the change of props simply reflects the user's edits.
// Rerendering in this case would make the cursor/caret jump
// Rerender if there is no element yet... (somehow?)
if (!el) return true;
// ...or if html really changed... (programmatically, not by user edit)
if (
normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML)
) {
return true;
}
// Handle additional properties
return props.disabled !== nextProps.disabled ||
props.tagName !== nextProps.tagName ||
props.className !== nextProps.className ||
props.innerRef !== nextProps.innerRef;
}
componentDidUpdate() {
const el = this.getDOMElement();
if (!el) return;
// Perhaps React (whose VDOM gets outdated because we often prevent
// rerendering) did not update the DOM. So we update it manually now.
if (this.props.html !== el.innerHTML) {
el.innerHTML = this.props.html;
}
this.lastHtml = this.props.html;
replaceCaret(el);
}
getDOMElement() {
return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current;
}
emitChange(originalEvt) {
const el = this.getDOMElement();
if (!el) return;
const html = el.innerHTML;
if (this.props.onChange && html !== this.lastHtml) {
// Clone event with Object.assign to avoid
// "Cannot assign to read only property 'target' of object"
const evt = Object.assign({}, originalEvt, {
target: {
value: html
}
});
this.props.onChange(evt);
}
this.lastHtml = html;
}
render(props) {
const { html, innerRef } = props;
return h(
'div',
{
...props,
ref: typeof innerRef === 'function' ? (current) => {
innerRef(current)
this.el.current = current
} : innerRef || this.el,
onInput: this.emitChange,
onFocus: this.props.onFocus || this.emitChange,
onBlur: this.props.onBlur || this.emitChange,
onKeyup: this.props.onKeyUp || this.emitChange,
onKeydown: this.props.onKeyDown || this.emitChange,
contentEditable: !this.props.disabled,
dangerouslySetInnerHTML: { __html: html },
},
this.props.children,
);
}
}

View file

@ -0,0 +1,140 @@
import { h } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import ChatMessageView from './chat-message-view.js';
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
import { checkIsModerator } from '../../utils/chat.js';
function SystemMessage(props) {
const { contents } = props;
return html`
<div
class="message message-name-change flex items-center justify-start p-3"
>
<div
class="message-content flex flex-row items-center justify-center text-sm w-full"
>
<div
class="text-gray-400 w-full text-center opacity-90 overflow-hidden break-words"
>
${contents}
</div>
</div>
</div>
`;
}
function SingleFederatedUser(props) {
const { message } = props;
const { type, body, title, image, link } = message;
let icon = null;
switch (type) {
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW:
icon = '/img/follow.svg';
break;
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE:
icon = '/img/like.svg';
break;
case SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST:
icon = '/img/repost.svg';
break;
default:
break;
}
return html`
<a
href=${link}
target="_blank"
class="hover:no-underline"
title="Visit profile"
>
<div
class="federated-action m-2 mt-3 bg-white flex items-center px-2 rounded-xl shadow border"
>
<div class="relative" style="top: -6px">
<img
src="${image || '/img/logo.svg'}"
style="max-width: unset"
class="rounded-full border border-slate-500 w-16"
/>
<span
style=${{ backgroundImage: `url(${icon})` }}
class="absolute h-6 w-6 rounded-full border-2 border-white action-icon"
></span>
</div>
<div class="px-4 py-2 min-w-0">
<div class="text-gray-500 text-sm hover:no-underline truncate">
${title}
</div>
<p
class=" text-gray-700 w-full text-base leading-6"
dangerouslySetInnerHTML=${{ __html: body }}
></p>
</div>
</div>
</a>
`;
}
export default function Message(props) {
const { message } = props;
const { type, oldName, user, body } = message;
if (
type === SOCKET_MESSAGE_TYPES.CHAT ||
type === SOCKET_MESSAGE_TYPES.SYSTEM
) {
return html`<${ChatMessageView} ...${props} />`;
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
// User changed their name
const { displayName } = user;
const contents = html`
<div>
<span class="font-bold">${oldName}</span> is now known as ${' '}
<span class="font-bold">${displayName}</span>.
</div>
`;
return html`<${SystemMessage} contents=${contents} />`;
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
const { displayName } = user;
const isAuthorModerator = checkIsModerator(message);
const messageAuthorFlair = isAuthorModerator
? html`<img
title="Moderator"
class="inline-block mr-1 w-3 h-3"
src="/img/moderator-nobackground.svg"
/>`
: null;
const contents = html`<div>
<span class="font-bold">${messageAuthorFlair}${displayName}</span>
${' '}joined the chat.
</div>`;
return html`<${SystemMessage} contents=${contents} />`;
} else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) {
const contents = html`<span
dangerouslySetInnerHTML=${{ __html: body }}
></span>`;
return html`<${SystemMessage} contents=${contents} />`;
} else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
// moderator message
const isModerator = checkIsModerator(message);
if (isModerator) {
const contents = html`<div class="rounded-lg bg-gray-700 p-3">
<img src="/img/moderator.svg" class="moderator-flag" />You are now a
moderator.
</div>`;
return html`<${SystemMessage} contents=${contents} />`;
}
} else if (
type === SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_FOLLOW ||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_LIKE ||
SOCKET_MESSAGE_TYPES.FEDIVERSE_ENGAGEMENT_REPOST
) {
return html` <${SingleFederatedUser} message=${message} /> `;
} else {
console.log('Unknown message type:', type);
}
}

View file

@ -0,0 +1,298 @@
import { h, Component, createRef } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { textColorForHue } from '../../utils/user-colors.js';
import { URL_BAN_USER, URL_HIDE_MESSAGE } from '../../utils/constants.js';
const html = htm.bind(h);
const HIDE_MESSAGE_ICON = `/img/hide-message-grey.svg`;
const HIDE_MESSAGE_ICON_HOVER = '/img/hide-message.svg';
const BAN_USER_ICON = '/img/ban-user-grey.svg';
const BAN_USER_ICON_HOVER = '/img/ban-user.svg';
export default class ModeratorActions extends Component {
constructor(props) {
super(props);
this.state = {
isMenuOpen: false,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
}
handleOpenMenu() {
this.setState({
isMenuOpen: true,
});
}
handleCloseMenu() {
this.setState({
isMenuOpen: false,
});
}
render() {
const { isMenuOpen } = this.state;
const { message, accessToken } = this.props;
const { id } = message;
const { user } = message;
return html`
<div class="moderator-actions-group flex flex-row text-xs p-3">
<button
type="button"
class="moderator-menu-button"
onClick=${this.handleOpenMenu}
title="Moderator actions"
alt="Moderator actions"
aria-haspopup="true"
aria-controls="open-mod-actions-menu"
aria-expanded=${isMenuOpen}
id="open-mod-actions-button"
>
<img src="/img/menu-vert.svg" alt="" />
</button>
${isMenuOpen &&
html`<${ModeratorMenu}
message=${message}
onDismiss=${this.handleCloseMenu}
accessToken=${accessToken}
id=${id}
userId=${user.id}
/>`}
</div>
`;
}
}
class ModeratorMenu extends Component {
constructor(props) {
super(props);
this.menuNode = createRef();
this.state = {
displayMoreInfo: false,
};
this.handleClickOutside = this.handleClickOutside.bind(this);
this.handleToggleMoreInfo = this.handleToggleMoreInfo.bind(this);
this.handleBanUser = this.handleBanUser.bind(this);
this.handleHideMessage = this.handleHideMessage.bind(this);
}
componentDidMount() {
document.addEventListener('mousedown', this.handleClickOutside, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handleClickOutside, false);
}
handleClickOutside = (e) => {
if (
this.menuNode &&
!this.menuNode.current.contains(e.target) &&
this.props.onDismiss
) {
this.props.onDismiss();
}
};
handleToggleMoreInfo() {
this.setState({
displayMoreInfo: !this.state.displayMoreInfo,
});
}
async handleHideMessage() {
if (!confirm('Are you sure you want to remove this message from chat?')) {
this.props.onDismiss();
return;
}
const { accessToken, id } = this.props;
const url = new URL(location.origin + URL_HIDE_MESSAGE);
url.searchParams.append('accessToken', accessToken);
const hideMessageUrl = url.toString();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ idArray: [id] }),
};
try {
await fetch(hideMessageUrl, options);
} catch (e) {
console.error(e);
}
this.props.onDismiss();
}
async handleBanUser() {
if (!confirm('Are you sure you want to remove this user from chat?')) {
this.props.onDismiss();
return;
}
const { accessToken, userId } = this.props;
const url = new URL(location.origin + URL_BAN_USER);
url.searchParams.append('accessToken', accessToken);
const hideMessageUrl = url.toString();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId: userId }),
};
try {
await fetch(hideMessageUrl, options);
} catch (e) {
console.error(e);
}
this.props.onDismiss();
}
render() {
const { message } = this.props;
const { displayMoreInfo } = this.state;
return html`
<ul
role="menu"
id="open-mod-actions-menu"
aria-labelledby="open-mod-actions-button"
class="moderator-actions-menu bg-gray-700 rounded-lg shadow-md"
ref=${this.menuNode}
>
<li>
<${ModeratorMenuItem}
icon=${HIDE_MESSAGE_ICON}
hoverIcon=${HIDE_MESSAGE_ICON_HOVER}
label="Hide message"
onClick="${this.handleHideMessage}"
/>
</li>
<li>
<${ModeratorMenuItem}
icon=${BAN_USER_ICON}
hoverIcon=${BAN_USER_ICON_HOVER}
label="Ban user"
onClick="${this.handleBanUser}"
/>
</li>
<li>
<${ModeratorMenuItem}
icon="/img/menu.svg"
label="More Info"
onClick=${this.handleToggleMoreInfo}
/>
</li>
${displayMoreInfo &&
html`<${ModeratorMoreInfoContainer}
message=${message}
handleBanUser=${this.handleBanUser}
handleHideMessage=${this.handleHideMessage}
/>`}
</ul>
`;
}
}
// 3 dots button
function ModeratorMenuItem({ icon, hoverIcon, label, onClick }) {
return html`
<button
role="menuitem"
type="button"
onClick=${onClick}
className="moderator-menu-item w-full py-2 px-4 text-white text-left whitespace-no-wrap rounded-lg hover:bg-gray-600"
>
${icon &&
html`<span
className="moderator-menu-icon menu-icon-base inline-block align-bottom mr-4"
><img src="${icon}"
/></span>`}
<span
className="moderator-menu-icon menu-icon-hover inline-block align-bottom mr-4"
><img src="${hoverIcon || icon}"
/></span>
${label}
</button>
`;
}
// more details panel that display message, prev usernames, actions
function ModeratorMoreInfoContainer({
message,
handleHideMessage,
handleBanUser,
}) {
const { user, timestamp, body } = message;
const { displayName, createdAt, previousNames, displayColor } = user;
const isAuthorModerator = user.scopes && user.scopes.includes('MODERATOR');
const authorTextColor = { color: textColorForHue(displayColor) };
const createDate = new Date(createdAt);
const sentDate = new Date(timestamp);
return html`
<div
className="moderator-more-info-container text-gray-300 bg-gray-800 rounded-lg p-4 border border-white text-base absolute"
>
<div
className="moderator-more-info-message scrollbar-hidden bg-gray-700 rounded-md pb-2"
>
<p className="text-xs text-gray-500">
Sent at ${sentDate.toLocaleTimeString()}
</p>
<div className="text-sm" dangerouslySetInnerHTML=${{ __html: body }} />
</div>
<div className="moderator-more-info-user py-2 my-2">
<p className="text-xs text-gray-500">Sent by:</p>
<p
className="font-bold ${isAuthorModerator && ' moderator-flag'}"
style=${authorTextColor}
>
${displayName}
</p>
<p className="text-xs text-gray-500 mt-2">
First joined: ${createDate.toLocaleString()}
</p>
${previousNames.length > 1 &&
html`
<p className="text-xs text-gray-500 my-1">
Previously known as: ${' '}
<span className="text-white text-gray-400"
>${previousNames.join(', ')}</span
>
</p>
`}
</div>
<div
className="moderator-more-info-actions pt-2 flex flex-row border-t border-gray-700 shadow-md"
>
<${handleHideMessage && ModeratorMenuItem}
icon=${HIDE_MESSAGE_ICON}
hoverIcon=${HIDE_MESSAGE_ICON_HOVER}
label="Hide message"
onClick="${handleHideMessage}"
/>
<${handleBanUser && ModeratorMenuItem}
icon=${BAN_USER_ICON}
hoverIcon=${BAN_USER_ICON_HOVER}
label="Ban user"
onClick="${handleBanUser}"
/>
</div>
</div>
`;
}

View file

@ -0,0 +1,158 @@
import { h, Component, createRef } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import { setLocalStorage } from '../../utils/helpers.js';
import {
KEY_USERNAME,
KEY_CUSTOM_USERNAME_SET,
} from '../../utils/constants.js';
export default class UsernameForm extends Component {
constructor(props, context) {
super(props, context);
this.state = {
displayForm: false,
isFocused: false,
};
this.textInput = createRef();
this.handleKeydown = this.handleKeydown.bind(this);
this.handleDisplayForm = this.handleDisplayForm.bind(this);
this.handleHideForm = this.handleHideForm.bind(this);
this.handleUpdateUsername = this.handleUpdateUsername.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleBlur = this.handleBlur.bind(this);
}
handleDisplayForm() {
const { displayForm: curDisplay } = this.state;
this.setState({
displayForm: !curDisplay,
});
}
handleHideForm() {
this.setState({
displayForm: false,
});
}
handleKeydown(event) {
if (event.keyCode === 13) {
// enter
this.handleUpdateUsername();
} else if (event.keyCode === 27) {
// esc
this.handleHideForm();
}
}
handleUpdateUsername() {
const { username: curName, onUsernameChange } = this.props;
let newName = this.textInput.current.value;
newName = newName.trim();
if (newName !== '' && newName !== curName) {
setLocalStorage(KEY_USERNAME, newName);
// So we know that the user has set a custom name
setLocalStorage(KEY_CUSTOM_USERNAME_SET, true);
if (onUsernameChange) {
onUsernameChange(newName);
}
this.handleHideForm();
}
}
handleFocus() {
const { onFocus } = this.props;
if (onFocus) {
onFocus();
}
}
handleBlur() {
const { onBlur } = this.props;
if (onBlur) {
onBlur();
}
}
render(props, state) {
const { username, isModerator } = props;
const { displayForm } = state;
const styles = {
info: {
display: displayForm ? 'none' : 'flex',
},
form: {
display: displayForm ? 'flex' : 'none',
},
};
const moderatorFlag = html`
<img src="/img/moderator-nobackground.svg" class="moderator-flag" />
`;
const userIcon = html`
<img src="/img/user-icon.svg" class="user-icon-flag" />
`;
return html`
<div id="user-info" class="whitespace-nowrap">
<div
id="user-info-display"
style=${styles.info}
title="Click to update user name"
class="flex flex-row justify-end items-center align-middle cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75"
onClick=${this.handleDisplayForm}
>
<span
id="username-display"
class="text-indigo-100 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap ${isModerator &&
'moderator-flag'}"
>${isModerator ? moderatorFlag : userIcon}${username}</span
>
</div>
<div
id="user-info-change"
class="flex flex-row flex-no-wrap p-1 items-center justify-end"
style=${styles.form}
>
<input
type="text"
id="username-change-input"
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight text-xs focus:bg-white"
maxlength="60"
placeholder="Update username"
defaultValue=${username}
onKeydown=${this.handleKeydown}
onFocus=${this.handleFocus}
onBlur=${this.handleBlur}
ref=${this.textInput}
/>
<button
id="button-update-username"
onClick=${this.handleUpdateUsername}
type="button"
class="bg-blue-500 hover:bg-blue-700 text-white text-xs uppercase p-1 mx-1 rounded cursor-pointer user-btn"
>
Update
</button>
<button
id="button-cancel-change"
onClick=${this.handleHideForm}
type="button"
class="bg-gray-900 hover:bg-gray-800 py-1 px-2 mx-1 rounded cursor-pointer user-btn text-white text-xs uppercase text-opacity-50"
title="cancel"
>
X
</button>
</div>
</div>
`;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

198
webroot/js/utils/chat.js Normal file
View 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(/&nbsp;/gi, ' ');
value = value.replace(/&amp;/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(/^(?:&nbsp;|\s)+|(?:&nbsp;|\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');
}

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

View 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"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long