diff --git a/controllers/chat.go b/controllers/chat.go
index 4b1566a23..6dd40bdd0 100644
--- a/controllers/chat.go
+++ b/controllers/chat.go
@@ -18,6 +18,7 @@ func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWr
// GetChatMessages gets all of the chat messages.
func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) {
+ middleware.EnableCors(w)
getChatMessages(w, r)
}
@@ -41,7 +42,16 @@ func getChatMessages(w http.ResponseWriter, r *http.Request) {
// RegisterAnonymousChatUser will register a new user.
func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
- if r.Method != POST {
+ middleware.EnableCors(w)
+
+ if r.Method == "OPTIONS" {
+ // All OPTIONS requests should have a wildcard CORS header.
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if r.Method != http.MethodPost {
WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
diff --git a/web/.eslintrc.js b/web/.eslintrc.js
index e25c5092d..18ecdce20 100644
--- a/web/.eslintrc.js
+++ b/web/.eslintrc.js
@@ -38,6 +38,8 @@ module.exports = {
'no-console': 'off',
'no-use-before-define': [0],
'@typescript-eslint/no-use-before-define': [1],
+ 'no-shadow': 'off',
+ '@typescript-eslint/no-shadow': ['error'],
'react/jsx-no-target-blank': [
1,
{
diff --git a/web/components/UserDropdownMenu.tsx b/web/components/UserDropdownMenu.tsx
index c4b8d8c1c..fdc1490e1 100644
--- a/web/components/UserDropdownMenu.tsx
+++ b/web/components/UserDropdownMenu.tsx
@@ -2,7 +2,7 @@ import { Menu, Dropdown } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { useRecoilState } from 'recoil';
import { ChatVisibilityState, ChatState } from '../interfaces/application-state';
-import { chatVisibility as chatVisibilityAtom } from './stores/ClientConfigStore';
+import { chatVisibilityAtom as chatVisibilityAtom } from './stores/ClientConfigStore';
interface Props {
username: string;
diff --git a/web/components/chat/ChatContainer.tsx b/web/components/chat/ChatContainer.tsx
index 2a2995164..0871d3617 100644
--- a/web/components/chat/ChatContainer.tsx
+++ b/web/components/chat/ChatContainer.tsx
@@ -21,7 +21,7 @@ export default function ChatContainer(props: Props) {
({
- key: 'chatVisibility',
- default: ChatVisibilityState.Hidden,
+export const appStateAtom = atom({
+ key: 'appStateAtom',
+ default: AppState.Loading,
});
-export const chatDisplayName = atom({
+export const chatStateAtom = atom({
+ key: 'chatStateAtom',
+ default: ChatState.Offline,
+});
+
+export const chatVisibilityAtom = atom({
+ key: 'chatVisibility',
+ default: ChatVisibilityState.Visible,
+});
+
+export const chatDisplayNameAtom = atom({
key: 'chatDisplayName',
default: null,
});
-export const accessTokenAtom = atom({
- key: 'accessToken',
+export const accessTokenAtom = atom({
+ key: 'accessTokenAtom',
default: null,
});
-export const chatMessages = atom({
+export const chatMessagesAtom = atom({
key: 'chatMessages',
default: [] as ChatMessage[],
});
export function ClientConfigStore() {
- const [, setClientConfig] = useRecoilState(clientConfigState);
- const [, setChatMessages] = useRecoilState(chatMessages);
+ const setClientConfig = useSetRecoilState(clientConfigStateAtom);
+ const [appState, setAppState] = useRecoilState(appStateAtom);
+ const setChatVisibility = useSetRecoilState(chatVisibilityAtom);
+ const [chatState, setChatState] = useRecoilState(chatStateAtom);
+ const setChatMessages = useSetRecoilState(chatMessagesAtom);
const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom);
- const [, setChatDisplayName] = useRecoilState(chatDisplayName);
+ const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom);
const updateClientConfig = async () => {
try {
const config = await ClientConfigService.getConfig();
- console.log(`ClientConfig: ${JSON.stringify(config)}`);
+ // console.log(`ClientConfig: ${JSON.stringify(config)}`);
setClientConfig(config);
+ setAppState(AppState.Online);
} catch (error) {
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
}
};
- const handleUserRegistration = async (optionalDisplayName: string) => {
+ const handleUserRegistration = async (optionalDisplayName?: string) => {
try {
+ setAppState(AppState.Registering);
const response = await ChatService.registerUser(optionalDisplayName);
- console.log(`ChatService -> registerUser() response: \n${JSON.stringify(response)}`);
- const { accessToken: newAccessToken, displayName } = response;
+ console.log(`ChatService -> registerUser() response: \n${response}`);
+ const { accessToken: newAccessToken, displayName: newDisplayName } = response;
if (!newAccessToken) {
return;
}
- setAccessToken(accessToken);
- setLocalStorage('accessToken', newAccessToken);
- setChatDisplayName(displayName);
+ console.log('setting access token', newAccessToken);
+ setAccessToken(newAccessToken);
+ // setLocalStorage('accessToken', newAccessToken);
+ setChatDisplayName(newDisplayName);
+ setAppState(AppState.Online);
} catch (e) {
console.error(`ChatService -> registerUser() ERROR: \n${e}`);
}
};
- // TODO: Requires access token.
const getChatHistory = async () => {
+ setChatState(ChatState.Loading);
try {
const messages = await ChatService.getChatHistory(accessToken);
- console.log(`ChatService -> getChatHistory() messages: \n${JSON.stringify(messages)}`);
+ // console.log(`ChatService -> getChatHistory() messages: \n${JSON.stringify(messages)}`);
setChatMessages(messages);
} catch (error) {
console.error(`ChatService -> getChatHistory() ERROR: \n${error}`);
}
+ setChatState(ChatState.Available);
};
useEffect(() => {
@@ -81,9 +106,29 @@ export function ClientConfigStore() {
handleUserRegistration();
}, []);
- useEffect(() => {
+ useLayoutEffect(() => {
+ if (!accessToken) {
+ return;
+ }
+
+ console.log('access token changed', accessToken);
getChatHistory();
}, [accessToken]);
+ useEffect(() => {
+ const updatedChatState = getChatState(appState);
+ setChatState(updatedChatState);
+ const updatedChatVisibility = getChatVisibilityState(appState);
+ console.log(
+ 'app state: ',
+ AppState[appState],
+ 'chat state:',
+ ChatState[updatedChatState],
+ 'chat visibility:',
+ ChatVisibilityState[updatedChatVisibility],
+ );
+ setChatVisibility(updatedChatVisibility);
+ }, [appState]);
+
return null;
}
diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx
index 2e5467fd9..ff143c043 100644
--- a/web/components/ui/Content/Content.tsx
+++ b/web/components/ui/Content/Content.tsx
@@ -1,6 +1,6 @@
import { useRecoilValue } from 'recoil';
import { Layout, Row, Col, Tabs } from 'antd';
-import { clientConfigState } from '../../stores/ClientConfigStore';
+import { clientConfigStateAtom } from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model';
import CustomPageContent from '../../CustomPageContent';
import OwncastPlayer from '../../video/OwncastPlayer';
@@ -11,7 +11,7 @@ const { TabPane } = Tabs;
const { Content } = Layout;
export default function FooterComponent() {
- const clientConfig = useRecoilValue(clientConfigState);
+ const clientConfig = useRecoilValue(clientConfigStateAtom);
const { extraPageContent } = clientConfig;
return (
diff --git a/web/components/ui/Sidebar/Sidebar.tsx b/web/components/ui/Sidebar/Sidebar.tsx
index 5ee44eb4e..6984b0a14 100644
--- a/web/components/ui/Sidebar/Sidebar.tsx
+++ b/web/components/ui/Sidebar/Sidebar.tsx
@@ -1,18 +1,25 @@
import Sider from 'antd/lib/layout/Sider';
import { useRecoilValue } from 'recoil';
+import { useEffect } from 'react';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import ChatContainer from '../../chat/ChatContainer';
-import { chatMessages, chatVisibility as chatVisibilityAtom } from '../../stores/ClientConfigStore';
-import { ChatVisibilityState } from '../../../interfaces/application-state';
+import {
+ chatMessagesAtom,
+ chatVisibilityAtom,
+ chatStateAtom,
+} from '../../stores/ClientConfigStore';
+import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
import ChatTextField from '../../chat/ChatTextField';
export default function Sidebar() {
- const messages = useRecoilValue(chatMessages);
+ const messages = useRecoilValue(chatMessagesAtom);
const chatVisibility = useRecoilValue(chatVisibilityAtom);
+ const chatState = useRecoilValue(chatStateAtom);
return (
-
+
);
diff --git a/web/interfaces/application-state.ts b/web/interfaces/application-state.ts
index 96e9f6838..2e627562f 100644
--- a/web/interfaces/application-state.ts
+++ b/web/interfaces/application-state.ts
@@ -1,5 +1,6 @@
export enum AppState {
Loading, // Initial loading state as config + status is loading.
+ Registering, // Creating a default anonymous chat account.
Online, // Stream is active.
Offline, // Stream is not active.
OfflineWaiting, // Period of time after going offline chat is still available.
@@ -30,6 +31,8 @@ export function getChatState(state: AppState): ChatState {
return ChatState.NotAvailable;
case AppState.OfflineWaiting:
return ChatState.Available;
+ case AppState.Registering:
+ return ChatState.Loading;
default:
return ChatState.Offline;
}
@@ -47,6 +50,8 @@ export function getChatVisibilityState(state: AppState): ChatVisibilityState {
return ChatVisibilityState.Hidden;
case AppState.OfflineWaiting:
return ChatVisibilityState.Visible;
+ case AppState.Registering:
+ return ChatVisibilityState.Visible;
default:
return ChatVisibilityState.Hidden;
}
diff --git a/web/services/chat-service.ts b/web/services/chat-service.ts
index 8ba62909b..07e18cf7e 100644
--- a/web/services/chat-service.ts
+++ b/web/services/chat-service.ts
@@ -1,4 +1,5 @@
import { ChatMessage } from '../interfaces/chat-message.model';
+import { getUnauthedData } from '../utils/apis';
const ENDPOINT = `http://localhost:8080/api/chat`;
const URL_CHAT_REGISTRATION = `http://localhost:8080/api/chat/register`;
@@ -10,9 +11,8 @@ interface UserRegistrationResponse {
class ChatService {
public static async getChatHistory(accessToken: string): Promise {
- const response = await fetch(`${ENDPOINT}?accessToken=${accessToken}`);
- const status = await response.json();
- return status;
+ const response = await getUnauthedData(`${ENDPOINT}?accessToken=${accessToken}`);
+ return response;
}
public static async registerUser(username: string): Promise {
@@ -24,15 +24,8 @@ class ChatService {
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);
- }
-
- return null;
+ const response = await getUnauthedData(URL_CHAT_REGISTRATION, options);
+ return response;
}
}
diff --git a/web/services/websocket-service.ts b/web/services/websocket-service.ts
new file mode 100644
index 000000000..c0591c92c
--- /dev/null
+++ b/web/services/websocket-service.ts
@@ -0,0 +1,138 @@
+import { message } from "antd";
+
+enum SocketMessageType {
+ 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',
+};
+
+interface SocketMessage {
+ type: SocketMessageType;
+ data: any;
+}
+
+export default class WebsocketService {
+ websocket: WebSocket;
+
+ accessToken: string;
+
+ path: string;
+
+ websocketReconnectTimer: ReturnType;
+
+ constructor(accessToken, path) {
+ this.accessToken = accessToken;
+ this.path = 'http://localhost:8080/ws';
+ // 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(this.path);
+ 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;
+ }
+
+ onOpen() {
+ if (this.websocketReconnectTimer) {
+ clearTimeout(this.websocketReconnectTimer);
+ }
+ }
+
+ // On ws error just close the socket and let it re-connect again for now.
+ onError(e) {
+ handleNetworkingError(`Socket error: ${JSON.parse(e)}`);
+ this.websocket.close();
+ // if (!this.isShutdown) {
+ // this.scheduleReconnect();
+ // }
+ }
+
+ /*
+ 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: SocketMessage) {
+ // Optimization where multiple events can be sent within a
+ // single websocket message. So split them if needed.
+ const messages = e.data.split('\n');
+ let message: SocketMessage;
+
+ // eslint-disable-next-line no-plusplus
+ for (let i = 0; i < messages.length; i++) {
+ try {
+ message = JSON.parse(messages[i]);
+ } catch (e) {
+ console.error(e, e.data);
+ return;
+ }
+
+ if (!message.type) {
+ console.error('No type provided', message);
+ return;
+ }
+
+ // Send PONGs
+ if (message.type === SocketMessageType.PING) {
+ this.sendPong();
+ return;
+ }
+ }
+ }
+
+ // Outbound: Other components can pass an object to `send`.
+ send(message: any) {
+ // Sanity check that what we're sending is a valid type.
+ if (!message.type || !SocketMessageType[message.type]) {
+ console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`);
+ }
+
+ const messageJSON = JSON.stringify(message);
+ this.websocket.send(messageJSON);
+ }
+
+ // Reply to a PING as a keep alive.
+ sendPong() {
+ const pong = { type: SocketMessageType.PONG };
+ this.send(pong);
+ }
+}
+
+function 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}`
+ );
+}
\ No newline at end of file
diff --git a/web/utils/apis.ts b/web/utils/apis.ts
index b8f792524..a7c87bb50 100644
--- a/web/utils/apis.ts
+++ b/web/utils/apis.ts
@@ -120,6 +120,8 @@ interface FetchOptions {
auth?: boolean;
}
+
+
export async function fetchData(url: string, options?: FetchOptions) {
const { data, method = 'GET', auth = true } = options || {};
@@ -151,12 +153,22 @@ export async function fetchData(url: string, options?: FetchOptions) {
}
return json;
} catch (error) {
+ console.error(error);
return error;
// console.log(error)
// throw new Error(error)
}
}
+export async function getUnauthedData(url: string, options?: FetchOptions) {
+ const opts = {
+ method: 'GET',
+ auth: false,
+ ...options,
+ };
+ return fetchData(url, opts);
+}
+
export async function fetchExternalData(url: string) {
try {
const response = await fetch(url, {