app-standalone-chat.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import { h, Component } from '/js/web_modules/preact.js';
  2. import htm from '/js/web_modules/htm.js';
  3. const html = htm.bind(h);
  4. import UsernameForm from './components/chat/username.js';
  5. import Chat from './components/chat/chat.js';
  6. import Websocket, {
  7. CALLBACKS,
  8. SOCKET_MESSAGE_TYPES,
  9. } from './utils/websocket.js';
  10. import { registerChat } from './chat/register.js';
  11. import { getLocalStorage, setLocalStorage } from './utils/helpers.js';
  12. import {
  13. CHAT_MAX_MESSAGE_LENGTH,
  14. EST_SOCKET_PAYLOAD_BUFFER,
  15. KEY_EMBED_CHAT_ACCESS_TOKEN,
  16. KEY_ACCESS_TOKEN,
  17. KEY_USERNAME,
  18. TIMER_DISABLE_CHAT_AFTER_OFFLINE,
  19. URL_STATUS,
  20. URL_CONFIG,
  21. TIMER_STATUS_UPDATE,
  22. } from './utils/constants.js';
  23. import { URL_WEBSOCKET } from './utils/constants.js';
  24. export default class StandaloneChat extends Component {
  25. constructor(props, context) {
  26. super(props, context);
  27. this.state = {
  28. websocket: null,
  29. canChat: false,
  30. chatEnabled: true, // always true for standalone chat
  31. chatInputEnabled: false, // chat input box state
  32. accessToken: null,
  33. username: null,
  34. isRegistering: false,
  35. streamOnline: null, // stream is active/online
  36. lastDisconnectTime: null,
  37. configData: {
  38. loading: true,
  39. },
  40. };
  41. this.disableChatInputTimer = null;
  42. this.hasConfiguredChat = false;
  43. this.handleUsernameChange = this.handleUsernameChange.bind(this);
  44. this.handleOfflineMode = this.handleOfflineMode.bind(this);
  45. this.handleOnlineMode = this.handleOnlineMode.bind(this);
  46. this.handleFormFocus = this.handleFormFocus.bind(this);
  47. this.handleFormBlur = this.handleFormBlur.bind(this);
  48. this.getStreamStatus = this.getStreamStatus.bind(this);
  49. this.getConfig = this.getConfig.bind(this);
  50. this.disableChatInput = this.disableChatInput.bind(this);
  51. this.setupChatAuth = this.setupChatAuth.bind(this);
  52. this.disableChat = this.disableChat.bind(this);
  53. this.socketHostOverride = null;
  54. // user events
  55. this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this);
  56. this.getConfig();
  57. this.getStreamStatus();
  58. this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
  59. }
  60. // fetch /config data
  61. getConfig() {
  62. fetch(URL_CONFIG)
  63. .then((response) => {
  64. if (!response.ok) {
  65. throw new Error(`Network response was not ok ${response.ok}`);
  66. }
  67. return response.json();
  68. })
  69. .then((json) => {
  70. this.setConfigData(json);
  71. })
  72. .catch((error) => {
  73. this.handleNetworkingError(`Fetch config: ${error}`);
  74. });
  75. }
  76. // fetch stream status
  77. getStreamStatus() {
  78. fetch(URL_STATUS)
  79. .then((response) => {
  80. if (!response.ok) {
  81. throw new Error(`Network response was not ok ${response.ok}`);
  82. }
  83. return response.json();
  84. })
  85. .then((json) => {
  86. this.updateStreamStatus(json);
  87. })
  88. .catch((error) => {
  89. this.handleOfflineMode();
  90. this.handleNetworkingError(`Stream status: ${error}`);
  91. });
  92. }
  93. setConfigData(data = {}) {
  94. const { chatDisabled, socketHostOverride } = data;
  95. // If this is the first time setting the config
  96. // then setup chat if it's enabled.
  97. if (!this.hasConfiguredChat && !chatDisabled) {
  98. this.setupChatAuth();
  99. }
  100. this.hasConfiguredChat = true;
  101. this.socketHostOverride = socketHostOverride;
  102. this.setState({
  103. canChat: !chatDisabled,
  104. configData: {
  105. ...data,
  106. },
  107. });
  108. }
  109. // handle UI things from stream status result
  110. updateStreamStatus(status = {}) {
  111. const { streamOnline: curStreamOnline } = this.state;
  112. if (!status) {
  113. return;
  114. }
  115. const { online, lastDisconnectTime } = status;
  116. this.setState({
  117. lastDisconnectTime,
  118. streamOnline: online,
  119. });
  120. if (status.online !== curStreamOnline) {
  121. if (status.online) {
  122. // stream has just come online.
  123. this.handleOnlineMode();
  124. } else {
  125. // stream has just flipped offline or app just got loaded and stream is offline.
  126. this.handleOfflineMode(lastDisconnectTime);
  127. }
  128. }
  129. }
  130. // stop status timer and disable chat after some time.
  131. handleOfflineMode(lastDisconnectTime) {
  132. if (lastDisconnectTime) {
  133. const remainingChatTime =
  134. TIMER_DISABLE_CHAT_AFTER_OFFLINE -
  135. (Date.now() - new Date(lastDisconnectTime));
  136. const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
  137. if (countdown > 0) {
  138. this.setState({
  139. chatInputEnabled: true,
  140. });
  141. }
  142. this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
  143. }
  144. this.setState({
  145. streamOnline: false,
  146. });
  147. }
  148. handleOnlineMode() {
  149. clearTimeout(this.disableChatInputTimer);
  150. this.disableChatInputTimer = null;
  151. this.setState({
  152. streamOnline: true,
  153. chatInputEnabled: true,
  154. });
  155. }
  156. handleUsernameChange(newName) {
  157. this.setState({
  158. username: newName,
  159. });
  160. this.sendUsernameChange(newName);
  161. }
  162. disableChatInput() {
  163. this.setState({
  164. chatInputEnabled: false,
  165. });
  166. }
  167. handleNetworkingError(error) {
  168. console.error(`>>> App Error: ${error}`);
  169. }
  170. handleWebsocketMessage(e) {
  171. if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
  172. // User has been actively disabled on the backend. Turn off chat for them.
  173. this.handleBlockedChat();
  174. } else if (
  175. e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION &&
  176. !this.isRegistering
  177. ) {
  178. // User needs an access token, so start the user auth flow.
  179. this.state.websocket.shutdown();
  180. this.setState({ websocket: null });
  181. this.setupChatAuth(true);
  182. } else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) {
  183. // Chat server cannot support any more chat clients. Turn off chat for them.
  184. this.disableChat();
  185. } else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
  186. // When connected the user will return an event letting us know what our
  187. // user details are so we can display them properly.
  188. const { user } = e;
  189. const { displayName } = user;
  190. this.setState({ username: displayName });
  191. }
  192. }
  193. handleBlockedChat() {
  194. setLocalStorage('owncast_chat_blocked', true);
  195. this.disableChat();
  196. }
  197. handleFormFocus() {
  198. if (this.hasTouchScreen) {
  199. this.setState({
  200. touchKeyboardActive: true,
  201. });
  202. }
  203. }
  204. handleFormBlur() {
  205. if (this.hasTouchScreen) {
  206. this.setState({
  207. touchKeyboardActive: false,
  208. });
  209. }
  210. }
  211. disableChat() {
  212. this.state.websocket.shutdown();
  213. this.setState({ websocket: null, canChat: false });
  214. }
  215. async setupChatAuth(force) {
  216. const { readonly } = this.props;
  217. var accessToken = readonly
  218. ? getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN)
  219. : getLocalStorage(KEY_ACCESS_TOKEN);
  220. var randomIntArray = new Uint32Array(1);
  221. window.crypto.getRandomValues(randomIntArray);
  222. var username = readonly
  223. ? 'chat-embed-' + randomIntArray[0]
  224. : getLocalStorage(KEY_USERNAME);
  225. if (!accessToken || force) {
  226. try {
  227. this.isRegistering = true;
  228. const registration = await registerChat(username);
  229. accessToken = registration.accessToken;
  230. username = registration.displayName;
  231. if (readonly) {
  232. setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken);
  233. } else {
  234. setLocalStorage(KEY_ACCESS_TOKEN, accessToken);
  235. setLocalStorage(KEY_USERNAME, username);
  236. }
  237. this.isRegistering = false;
  238. } catch (e) {
  239. console.error('registration error:', e);
  240. }
  241. }
  242. if (this.state.websocket) {
  243. this.state.websocket.shutdown();
  244. this.setState({
  245. websocket: null,
  246. });
  247. }
  248. // Without a valid access token he websocket connection will be rejected.
  249. const websocket = new Websocket(
  250. accessToken,
  251. this.socketHostOverride || URL_WEBSOCKET
  252. );
  253. websocket.addListener(
  254. CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
  255. this.handleWebsocketMessage
  256. );
  257. this.setState({
  258. username,
  259. websocket,
  260. accessToken,
  261. });
  262. }
  263. sendUsernameChange(newName) {
  264. const nameChange = {
  265. type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
  266. newName,
  267. };
  268. this.state.websocket.send(nameChange);
  269. }
  270. render(props, state) {
  271. const { username, websocket, accessToken, chatInputEnabled, configData } =
  272. state;
  273. const { chatDisabled, maxSocketPayloadSize, customStyles, name } =
  274. configData;
  275. const { readonly } = props;
  276. return this.state.websocket
  277. ? html`${!readonly
  278. ? html`<style>
  279. ${customStyles}
  280. </style>
  281. <header
  282. class="flex flex-row-reverse fixed z-10 w-full bg-gray-900"
  283. >
  284. <${UsernameForm}
  285. username=${username}
  286. onUsernameChange=${this.handleUsernameChange}
  287. onFocus=${this.handleFormFocus}
  288. onBlur=${this.handleFormBlur}
  289. />
  290. </header>`
  291. : ''}
  292. <${Chat}
  293. websocket=${websocket}
  294. username=${username}
  295. accessToken=${accessToken}
  296. readonly=${readonly}
  297. instanceTitle=${name}
  298. chatInputEnabled=${chatInputEnabled && !chatDisabled}
  299. inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
  300. CHAT_MAX_MESSAGE_LENGTH}
  301. />`
  302. : null;
  303. }
  304. }