Skip to content

Chatbox Websocket client #5744

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 16, 2025
2 changes: 2 additions & 0 deletions tgui/packages/tgui-panel/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { pingMiddleware, pingReducer } from './ping';
import { settingsMiddleware, settingsReducer } from './settings';
import { telemetryMiddleware } from './telemetry';
import { setGlobalStore } from 'tgui/backend';
import { websocketMiddleware } from './websocket';

perf.mark('inception', window.performance?.timing?.navigationStart);
perf.mark('init');
Expand All @@ -37,6 +38,7 @@ const store = configureStore({
}),
middleware: {
pre: [
websocketMiddleware,
chatMiddleware,
pingMiddleware,
telemetryMiddleware,
Expand Down
91 changes: 91 additions & 0 deletions tgui/packages/tgui-panel/settings/SettingsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
selectHighlightSettings,
selectHighlightSettingById,
} from './selectors';
import { reconnectWebsocket, disconnectWebsocket } from '../websocket';
import { chatRenderer } from '../chat/renderer';

export const SettingsPanel = (props, context) => {
const activeTab = useSelector(context, selectActiveTab);
Expand Down Expand Up @@ -71,6 +73,7 @@ export const SettingsPanel = (props, context) => {
{activeTab === 'general' && <SettingsGeneral />}
{activeTab === 'chatPage' && <ChatPageSettings />}
{activeTab === 'textHighlight' && <TextHighlightSettings />}
{activeTab === 'experimental' && <ExperimentalSettings />}
</Stack.Item>
</Stack>
);
Expand Down Expand Up @@ -393,3 +396,91 @@ const TextHighlightSetting = (props, context) => {
</Stack.Item>
);
};

const ExperimentalSettings = (props, context) => {
const { websocketEnabled, websocketServer } = useSelector(
context,
selectSettings,
);
const dispatch = useDispatch(context);

return (
<Section>
<Stack vertical>
<Stack.Item>
<LabeledList>
<LabeledList.Item label="Websocket Client">
<Button.Checkbox
content={'Enabled'}
checked={websocketEnabled}
color="transparent"
onClick={() =>
dispatch(
updateSettings({
websocketEnabled: !websocketEnabled,
}),
)
}
/>
<Button
icon={'question'}
onClick={() => {
chatRenderer.processBatch([
{
html:
'<div class="boxed_message"><b>Websocket Information</b><br><span class="notice">' +
'Quick rundown. This connects to the specified websocket server, and ' +
'forwards all data/payloads from the server, to the websocket. Allowing ' +
'you to have in-game actions reflect in other services, or the real ' +
'world, (ex. Reactive RGB, haptics, play effects/animations in vtubing ' +
'software, etc). You can find more information ' +
'<a href="https://github.com/Monkestation/Monkestation2.0/pull/5744">here in the pull request.</a></span></div>',
},
]);
}}
/>
</LabeledList.Item>
<LabeledList.Item label="Websocket Server">
<Stack.Item>
<Stack>
<Input
width={'100%'}
value={websocketServer}
placeholder="localhost:1990"
onChange={(e, value) =>
dispatch(
updateSettings({
websocketServer: value,
}),
)
}
/>
</Stack>
</Stack.Item>
</LabeledList.Item>
<LabeledList.Item label="Websocket Controls">
<Button
ml={0.5}
content="Force Reconnect"
icon={'globe'}
color={'good'}
onClick={() => {
dispatch(reconnectWebsocket());
}}
/>
<Button
ml={0.5}
content="Force Disconnect"
icon={'globe'}
color={'bad'}
onClick={() => {
dispatch(disconnectWebsocket());
}}
/>
</LabeledList.Item>
</LabeledList>
</Stack.Item>
</Stack>
</Section>
);
};
4 changes: 4 additions & 0 deletions tgui/packages/tgui-panel/settings/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const SETTINGS_TABS = [
id: 'chatPage',
name: 'Chat Tabs',
},
{
id: 'experimental',
name: 'Experimental',
},
];

export const FONTS_DISABLED = 'Default';
Expand Down
2 changes: 2 additions & 0 deletions tgui/packages/tgui-panel/settings/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const initialState = {
visible: false,
activeTab: SETTINGS_TABS[0].id,
},
websocketEnabled: false,
websocketServer: '',
};

export const settingsReducer = (state = initialState, action) => {
Expand Down
135 changes: 135 additions & 0 deletions tgui/packages/tgui-panel/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { createAction } from 'common/redux';
import { chatRenderer } from './chat/renderer';
import { loadSettings, updateSettings } from './settings/actions';
import { selectSettings } from './settings/selectors';

const sendWSNotice = (message, small = false) => {
chatRenderer.processBatch([
{
html: small
? `<span class='adminsay'>${message}</span>`
: `<div class="boxed_message"><center><span class='alertwarning'>${message}</span></center></div>`,
},
]);
};

export const reconnectWebsocket = createAction('websocket/reconnect');
export const disconnectWebsocket = createAction('websocket/disconnect');

// Websocket close codes
const WEBSOCKET_DISABLED = 4555;
const WEBSOCKET_REATTEMPT = 4556;

export const websocketMiddleware = (store) => {
let websocket: WebSocket | null = null;

const setupWebsocket = (store) => {
const { websocketEnabled, websocketServer } = selectSettings(
store.getState(),
);
if (!websocketEnabled) {
websocket?.close(WEBSOCKET_REATTEMPT);
return;
}

websocket?.close(WEBSOCKET_REATTEMPT);

try {
websocket = new WebSocket(`ws://${websocketServer}`);
} catch (e) {
if (e.name === 'SyntaxError') {
sendWSNotice(
`Error creating websocket: Invalid address! Make sure you're following the placeholder. Example: <code>localhost:1234</code>`,
);
return;
}
sendWSNotice(`Error creating websocket: ${e.name} - ${e.message}`);
return;
}

websocket.addEventListener('open', () => {
sendWSNotice('Websocket connected!', true);
});

websocket.addEventListener('close', function closeEventThing(ev) {
const { websocketEnabled } = selectSettings(store.getState());
if (!websocketEnabled) {
// Doing this because eitherwise it 'close' will get called
// thousands of times per second if the connection wasn't closed properly.
// I don't know WHY it does that but it just does.
ev.target?.removeEventListener('close', closeEventThing);
websocket?.removeEventListener('close', closeEventThing);
return;
}
if (ev.code !== WEBSOCKET_DISABLED && ev.code !== WEBSOCKET_REATTEMPT) {
sendWSNotice(
`Websocket disconnected! Code: ${ev.code} Reason: ${ev.reason || 'None provided'}`,
);
}
});

websocket.addEventListener('error', () => {
// Really don't think we should do anything here.
// setTimeout(() => setupWebsocket(store), 2000);
});
};

setTimeout(() => setupWebsocket(store));

return (next) => (action) => {
const { type, payload } = action as {
type: string;
payload: {
websocketEnabled: boolean;
websocketServer: string;
};
};
if (!payload) return next(action);
if (type === updateSettings.type || type === loadSettings.type) {
if (typeof payload?.websocketEnabled === 'undefined') {
store.dispatch(
updateSettings({
websocketEnabled: false,
}),
);
return next(action);
}
if (!payload.websocketEnabled) {
websocket?.close(WEBSOCKET_DISABLED);
websocket = null;
} else if (
!websocket ||
websocket.url !== payload.websocketServer ||
(payload.websocketEnabled &&
(!websocket || websocket.readyState !== websocket.OPEN))
) {
websocket?.close(WEBSOCKET_REATTEMPT, 'Websocket settings changed');
sendWSNotice('Websocket enabled.', true);
setupWebsocket(store);
}
return next(action);
}

if (type === reconnectWebsocket.type) {
const settings = selectSettings(store.getState());
if (settings.websocketEnabled) setupWebsocket(store);
return next(action);
}

if (type === disconnectWebsocket.type) {
websocket?.close(WEBSOCKET_DISABLED);
websocket = null;
sendWSNotice('Websocket forcefully disconnected.', true);
}

websocket &&
websocket.readyState === websocket.OPEN &&
websocket?.send(
JSON.stringify({
type,
payload,
}),
);
return next(action);
};
};
Loading