Remove webinterface server, HTTP routes and Svelte frontend
Drops the entire ChatTwo/Http/ tree (ServerCore, HostContext, RouteController, Processing, SSEConnection, the message protocol DTOs and the bundled Svelte frontend) plus WebinterfaceUtil. Also removes every ServerCore.Send* call site that fed the SSE stream: - MessageManager.ProcessMessage no longer broadcasts new messages - Chat.cs no longer notifies on login - PayloadHandler no longer rebroadcasts on screenshot-mode toggle - ChatLogWindow no longer announces tab and channel switches The Plugin class drops the ServerCore field, the auto-start branch and the Dispose hook. The DbViewer still imported a stale namespace from the message protocol; the using is removed. Language.resx and its generated Designer file keep the Webinterface string keys for now so future upstream cherry-picks do not break on missing resources. They are dead code from our perspective but harmless.
This commit is contained in:
@@ -156,9 +156,6 @@ internal sealed unsafe class Chat : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
ChangeChannelNameDetour(agent);
|
ChangeChannelNameDetour(agent);
|
||||||
|
|
||||||
// Inform all clients that a new login happened
|
|
||||||
Plugin.ServerCore.SendNewLogin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
|
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project in the current directory
|
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
Generated
-1573
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
|
||||||
"@sveltejs/kit": "^2.22.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
||||||
"svelte": "^5.39.2",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"sveltekit-sse": "^0.14.3",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"vite": "^7.0.4"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-23
@@ -1,23 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
interface Error {
|
|
||||||
code: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
|
|
||||||
interface Warning {
|
|
||||||
hasWarning: boolean;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Element { scrollTopMax: number } // Firefox only property
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {isChannelLocked, channelOptions} from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
let selectElement: HTMLSelectElement;
|
|
||||||
|
|
||||||
async function requestChannelSwitch(event: Event) {
|
|
||||||
if (!event.currentTarget)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let element = (event.currentTarget as HTMLSelectElement);
|
|
||||||
let requestedChannel = element.value;
|
|
||||||
|
|
||||||
console.log(element.value)
|
|
||||||
element.value = '0';
|
|
||||||
|
|
||||||
const rawResponse = await fetch('/channel', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ channel: requestedChannel })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | null = null;
|
|
||||||
function getTextWidth(text: string): number {
|
|
||||||
// re-use canvas object for better performance
|
|
||||||
if (canvas === null)
|
|
||||||
canvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
const context: CanvasRenderingContext2D | null = canvas.getContext("2d");
|
|
||||||
if (!context)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
context.font = getCanvasFont(selectElement);
|
|
||||||
const metrics = context.measureText(text);
|
|
||||||
return metrics.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCssStyle(element: Element, prop: string): string {
|
|
||||||
return window.getComputedStyle(element, null).getPropertyValue(prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvasFont(el = document.body) {
|
|
||||||
const fontWeight = getCssStyle(el, 'font-weight') || 'normal';
|
|
||||||
const fontSize = getCssStyle(el, 'font-size') || '16px';
|
|
||||||
const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman';
|
|
||||||
|
|
||||||
return `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<select
|
|
||||||
bind:this={selectElement}
|
|
||||||
id="channel-select"
|
|
||||||
style="pointer-events: {isChannelLocked.locked ? 'none' : 'inherit'}; width: {(channelOptions.length > 1 ? getTextWidth(channelOptions[0].text) : 1) + 40}px"
|
|
||||||
onchange={(e) => requestChannelSwitch(e)}>
|
|
||||||
{#each channelOptions as channelOption}
|
|
||||||
{#if channelOption.preview }
|
|
||||||
<option selected disabled hidden value={channelOption.value}>
|
|
||||||
{channelOption.text}
|
|
||||||
</option>
|
|
||||||
{:else}
|
|
||||||
<option value={channelOption.value}>
|
|
||||||
{channelOption.text}
|
|
||||||
</option>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
select {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { subscribe } from "$lib/utils.svelte";
|
|
||||||
import { chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
let textarea: HTMLTextAreaElement;
|
|
||||||
|
|
||||||
let skipNextCheck: boolean = $state(false);
|
|
||||||
let requiresResize: boolean = $state(true);
|
|
||||||
|
|
||||||
subscribe(
|
|
||||||
() => chatInput,
|
|
||||||
(v) => {
|
|
||||||
if (skipNextCheck) {
|
|
||||||
skipNextCheck = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input box has been reset to empty, so resize it back to smaller box
|
|
||||||
if (v.content === '') {
|
|
||||||
console.log("Empty chatbox, resize");
|
|
||||||
requiresResize = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove newline characters
|
|
||||||
let original = v.content;
|
|
||||||
v.content = v.content.replace(/(\r\n|\n|\r)/gm,"");
|
|
||||||
|
|
||||||
console.log(`${original.length} vs ${v.content.length}`);
|
|
||||||
let hasChanged = original.length != v.content.length;
|
|
||||||
if (hasChanged) {
|
|
||||||
skipNextCheck = true;
|
|
||||||
requiresResize = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function preventNewlines(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
// Prevent key from creating a newline
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// submit the data
|
|
||||||
const newEvent = new Event('submit', {bubbles: true, cancelable: true});
|
|
||||||
if (e.currentTarget !== null) {
|
|
||||||
(e.currentTarget as HTMLTextAreaElement).closest('form')?.dispatchEvent(newEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
if (!textarea)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const scrolledToBottom = messagesList.scrolledToBottom;
|
|
||||||
textarea.style.height = '1px';
|
|
||||||
textarea.style.height = `${textarea.scrollHeight + 10}px`; // with +10px extra padding
|
|
||||||
if (scrolledToBottom)
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
console.log(`Checking effect: ${requiresResize}`)
|
|
||||||
if (requiresResize) {
|
|
||||||
requiresResize = false;
|
|
||||||
resize();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
bind:this={textarea}
|
|
||||||
bind:value={chatInput.content}
|
|
||||||
oninput={() => resize()}
|
|
||||||
onkeydown={(e) => preventNewlines(e)}
|
|
||||||
|
|
||||||
id="chat-input"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Message"
|
|
||||||
enterkeyhint="send"
|
|
||||||
maxlength="500">
|
|
||||||
</textarea>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
textarea {
|
|
||||||
flex-grow: 0;
|
|
||||||
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-height: 2.5em;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { selectedTab, knownTabs, tabPaneState, tabPaneAnimationState, closeTabPane, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
async function selectTab(index: number) {
|
|
||||||
const rawResponse = await fetch('/tab', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ index })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
tabPaneAnimationState.noAnimation = false;
|
|
||||||
closeTabPane();
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrolledToBottom = true;
|
|
||||||
function ontransitionstart() {
|
|
||||||
scrolledToBottom = messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ontransitionend() {
|
|
||||||
if (scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
id="tabs"
|
|
||||||
class:no-animation={tabPaneAnimationState.noAnimation}
|
|
||||||
class:hidden={!tabPaneState.visible}
|
|
||||||
{ontransitionstart}
|
|
||||||
{ontransitionend}
|
|
||||||
>
|
|
||||||
<div class="inner">
|
|
||||||
<header>
|
|
||||||
<span>Tabs</span>
|
|
||||||
<button type="button" onclick={() => handleClose()}>
|
|
||||||
<!-- "chevron-left" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<ol id="tabs-list">
|
|
||||||
{#each knownTabs as tab}
|
|
||||||
<li class:active={selectedTab.index === tab.index}>
|
|
||||||
<button type="button" onclick={() => selectTab(tab.index)}>
|
|
||||||
{ tab.name } {tab.unreadCount > 0 ? `(${tab.unreadCount})`: '' }
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {tabPaneState, tabPaneAnimationState, openTabPane, knownTabs} from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
function onclick() {
|
|
||||||
tabPaneAnimationState.noAnimation = false;
|
|
||||||
openTabPane();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button type="button" aria-label="Open tab pane" class:visible={!tabPaneState.visible} class:unread={knownTabs.some((tab) => tab.unreadCount > 0)} {onclick} disabled={tabPaneState.visible}>
|
|
||||||
<!-- "chevron-right" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
button {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
padding: 25px 0;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 100;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 250ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.unread svg {
|
|
||||||
stroke: var(--unread-color);
|
|
||||||
filter: drop-shadow(0 0 2px var(--unread-color));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>%sveltekit.error.message%</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>Status: %sveltekit.status%</p>
|
|
||||||
<p>Message: %sveltekit.error.message%</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import { channelOptions, isChannelLocked, selectedTab, knownTabs, chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
import { WebPayloadType } from "$lib/payload";
|
|
||||||
import { source, type Source } from "sveltekit-sse";
|
|
||||||
|
|
||||||
interface ChatElements {
|
|
||||||
messagesContainer: Element | null,
|
|
||||||
messagesList: HTMLElement | null,
|
|
||||||
|
|
||||||
timestampWidthProbe: HTMLElement | null,
|
|
||||||
|
|
||||||
inputForm: Element | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.Messages`
|
|
||||||
interface Messages {
|
|
||||||
messages: MessageResponse[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.MessageResponse`
|
|
||||||
interface MessageResponse {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
templates: Template[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.MessageTemplate`
|
|
||||||
interface Template {
|
|
||||||
payloadType: WebPayloadType;
|
|
||||||
content: string;
|
|
||||||
iconId: number;
|
|
||||||
color: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.SwitchChannel`
|
|
||||||
interface SwitchChannel {
|
|
||||||
channelName: Template[];
|
|
||||||
channelValue: number;
|
|
||||||
channelLocked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChannelList`
|
|
||||||
interface ChannelList {
|
|
||||||
channels: {[key: string]: number};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTab`
|
|
||||||
export interface ChatTab {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTabList`
|
|
||||||
interface ChatTabList {
|
|
||||||
tabs: ChatTab[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTabUnreadState`
|
|
||||||
interface ChatTabUnreadState {
|
|
||||||
index: number;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatTwoWeb {
|
|
||||||
elements!: ChatElements;
|
|
||||||
maxTimestampWidth: number = 0;
|
|
||||||
|
|
||||||
sse!: EventSource;
|
|
||||||
connection!: Source;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.setupDOMElements();
|
|
||||||
this.setupSSEConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDOMElements() {
|
|
||||||
this.elements = {
|
|
||||||
messagesContainer: document.querySelector('#messages > .scroll-container')!,
|
|
||||||
messagesList: document.getElementById('messages-list'),
|
|
||||||
|
|
||||||
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
|
|
||||||
|
|
||||||
inputForm: document.querySelector('#input > form'),
|
|
||||||
};
|
|
||||||
messagesList.element = this.elements.messagesList;
|
|
||||||
|
|
||||||
// add indicator signaling more messages below
|
|
||||||
this.elements.messagesContainer?.addEventListener('scroll', (event) => {
|
|
||||||
if (event.currentTarget === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let parentElement = (event.currentTarget as HTMLDivElement).parentElement;
|
|
||||||
if (!this.messagesAreScrolledToBottom()) {
|
|
||||||
parentElement?.classList.add('more-messages');
|
|
||||||
} else {
|
|
||||||
parentElement?.classList.remove('more-messages');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard)
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (messagesList.scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// handle message sending
|
|
||||||
this.elements.inputForm?.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (chatInput.content.length > 500) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawResponse = await fetch('/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ message: chatInput.content })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
|
|
||||||
chatInput.content = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messagesAreScrolledToBottom() {
|
|
||||||
if (this.elements.messagesContainer === null) {
|
|
||||||
return messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
messagesList.scrolledToBottom =
|
|
||||||
(
|
|
||||||
this.elements.messagesContainer.scrollHeight -
|
|
||||||
this.elements.messagesContainer.clientHeight -
|
|
||||||
this.elements.messagesContainer.scrollTop
|
|
||||||
) < 1;
|
|
||||||
|
|
||||||
return messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChannelHint(channel: SwitchChannel) {
|
|
||||||
// Set storage to the current lock state
|
|
||||||
isChannelLocked.locked = channel.channelLocked;
|
|
||||||
|
|
||||||
const channelElement = this.processTemplate(channel.channelName);
|
|
||||||
if (!channelElement.firstChild)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let channelName = (channelElement.firstChild as HTMLSpanElement).innerText;
|
|
||||||
if (channel.channelLocked)
|
|
||||||
channelName = `(Locked) ${channelName}`;
|
|
||||||
|
|
||||||
channelOptions[0] = {text: channelName, value: 0, preview: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChannels(channelList: ChannelList) {
|
|
||||||
channelOptions.length = 1;
|
|
||||||
|
|
||||||
for (const [ label, channel ] of Object.entries(channelList.channels)) {
|
|
||||||
channelOptions.push( { text: label, value: channel, preview: false } )
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculate timestamp width to ensure that all timestamps have the same width.
|
|
||||||
// some typefaces have the same width across all number glyphs, others do not.
|
|
||||||
// then there's AM/PM vs 24 hour, and so on
|
|
||||||
calculateTimestampWidth(timestamp: string) {
|
|
||||||
if (this.elements.timestampWidthProbe === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.elements.timestampWidthProbe.innerText = timestamp;
|
|
||||||
if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) {
|
|
||||||
this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth;
|
|
||||||
document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(messageData: MessageResponse) {
|
|
||||||
if (this.elements.messagesList === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const scrolledToBottom = this.messagesAreScrolledToBottom();
|
|
||||||
this.calculateTimestampWidth(messageData.timestamp);
|
|
||||||
|
|
||||||
const liMessage = document.createElement('li');
|
|
||||||
const spanTimestamp = document.createElement('span');
|
|
||||||
spanTimestamp.classList.add('timestamp');
|
|
||||||
spanTimestamp.innerText = messageData.timestamp;
|
|
||||||
|
|
||||||
const spanMessage = document.createElement('span');
|
|
||||||
spanMessage.classList.add('message');
|
|
||||||
spanMessage.appendChild(this.processTemplate(messageData.templates))
|
|
||||||
|
|
||||||
liMessage.appendChild(spanTimestamp);
|
|
||||||
liMessage.appendChild(spanMessage);
|
|
||||||
this.elements.messagesList.appendChild(liMessage);
|
|
||||||
|
|
||||||
if (scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processTemplate(templates: Template[]) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
|
|
||||||
for( const template of templates ) {
|
|
||||||
const spanElement = document.createElement('span');
|
|
||||||
switch (template.payloadType) {
|
|
||||||
case WebPayloadType.RawText:
|
|
||||||
this.processTextTemplate(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.CustomUri:
|
|
||||||
this.processUrlTemplate(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.CustomEmote:
|
|
||||||
this.processEmote(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.Icon:
|
|
||||||
this.processIcon(template, spanElement);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
frag.appendChild(spanElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
|
|
||||||
processTextTemplate(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
spanElement.innerText = template.content;
|
|
||||||
if (template.color !== 0)
|
|
||||||
{
|
|
||||||
this.processColor(template, spanElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processUrlTemplate(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const urlElement = document.createElement('a');
|
|
||||||
let url = template.content;
|
|
||||||
if (!url.startsWith('https://')) {
|
|
||||||
url = `https://${url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
urlElement.innerText = template.content;
|
|
||||||
urlElement.href = encodeURI(url);
|
|
||||||
urlElement.target = '_blank'
|
|
||||||
|
|
||||||
if (template.color !== 0)
|
|
||||||
{
|
|
||||||
this.processColor(template, spanElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
spanElement.appendChild(urlElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// converts a RGBA uint number to components
|
|
||||||
processColor(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const r = (template.color & 0xFF000000) >>> 24;
|
|
||||||
const g = (template.color & 0xFF0000) >>> 16;
|
|
||||||
const b = (template.color & 0xFF00) >>> 8;
|
|
||||||
const a = (template.color & 0xFF) / 255.0;
|
|
||||||
|
|
||||||
spanElement.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
processEmote(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const imgElement = document.createElement('img');
|
|
||||||
imgElement.src = `/emote/${template.content}`;
|
|
||||||
|
|
||||||
spanElement.classList.add('emote-icon');
|
|
||||||
spanElement.appendChild(imgElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
processIcon(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
spanElement.classList.add('gfd-icon');
|
|
||||||
spanElement.classList.add(`gfd-icon-hq-${template.iconId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAllMessages() {
|
|
||||||
if (this.elements.messagesList === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.elements.messagesList.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSSEConnection() {
|
|
||||||
this.connection = source('/sse')
|
|
||||||
|
|
||||||
this.connection.select('close').subscribe((data: string) => {
|
|
||||||
console.log(`close: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
console.log('Closing SSE connection.');
|
|
||||||
this.connection.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// new messages to be appended to the message list
|
|
||||||
this.connection.select('new-message').subscribe((data: string) => {
|
|
||||||
console.log(`new-message: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let message: MessageResponse = JSON.parse(data);
|
|
||||||
this.addMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// a bulk of new messages, with a clear of the message list beforehand
|
|
||||||
this.connection.select('bulk-messages').subscribe((data: string) => {
|
|
||||||
console.log(`bulk-messages: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
this.clearAllMessages();
|
|
||||||
try {
|
|
||||||
let messages: Messages = JSON.parse(data);
|
|
||||||
for (const message of messages.messages) {
|
|
||||||
this.addMessage(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connection.select('channel-switched').subscribe((data: string) => {
|
|
||||||
console.log(`channel-switched: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let channel: SwitchChannel = JSON.parse(data);
|
|
||||||
this.updateChannelHint(channel);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// list of all channels
|
|
||||||
this.connection.select('channel-list').subscribe((data: string) => {
|
|
||||||
console.log(`channel-list: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let channelList: ChannelList = JSON.parse(data);
|
|
||||||
this.updateChannels(channelList);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// tab switched
|
|
||||||
this.connection.select('tab-switched').subscribe((data: string) => {
|
|
||||||
console.log(`tab-switched: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTab: ChatTab = JSON.parse(data);
|
|
||||||
selectedTab.index = chatTab.index;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// list of all tabs
|
|
||||||
this.connection.select('tab-list').subscribe((data: string) => {
|
|
||||||
console.log(`tab-list: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTabList: ChatTabList = JSON.parse(data);
|
|
||||||
knownTabs.length = 0;
|
|
||||||
for (const tab of chatTabList.tabs) {
|
|
||||||
knownTabs.push(tab);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// the unread state of a specific tab has changed
|
|
||||||
this.connection.select('tab-unread-state').subscribe((data: string) => {
|
|
||||||
console.log(`tab-unread-state`, data)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTabUnreadState: ChatTabUnreadState = JSON.parse(data);
|
|
||||||
let tab = knownTabs.find((tab) => tab.index === chatTabUnreadState.index);
|
|
||||||
if (tab) {
|
|
||||||
tab.unreadCount = chatTabUnreadState.unreadCount;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.error("Unable to find tab!")
|
|
||||||
console.error(chatTabUnreadState)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
// from kizer, gfd icons
|
|
||||||
interface GdfEntry {
|
|
||||||
id: number,
|
|
||||||
left: number,
|
|
||||||
top: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
unk0A: number,
|
|
||||||
redirect: number,
|
|
||||||
unk0E: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StylesheetEntry {
|
|
||||||
ids: number[],
|
|
||||||
style1: string,
|
|
||||||
style2: string,
|
|
||||||
width: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addGfdStylesheet(gfdPath: string, texPath: string) {
|
|
||||||
const texPromise = loadTexAsBlob(texPath);
|
|
||||||
const gfdPromise = loadGfd(gfdPath);
|
|
||||||
const texUrl = URL.createObjectURL(await texPromise);
|
|
||||||
const gfd = await gfdPromise;
|
|
||||||
|
|
||||||
const stylesheets: {[id: number]: StylesheetEntry} = [];
|
|
||||||
for (const entry of gfd) {
|
|
||||||
if (entry.width * entry.height <= 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (entry.redirect !== 0) {
|
|
||||||
stylesheets[entry.redirect].ids.push(entry.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stylesheets[entry.id] = {
|
|
||||||
ids: [entry.id],
|
|
||||||
style1: [
|
|
||||||
`background-position: -${entry.left}px -${entry.top}px`,
|
|
||||||
`background-image: url('${texUrl}')`,
|
|
||||||
`width: ${entry.width}px`,
|
|
||||||
`height: ${entry.height}px`
|
|
||||||
].join(';'),
|
|
||||||
style2: [
|
|
||||||
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
|
|
||||||
`background-image: url('${texUrl}')`,
|
|
||||||
`width: ${entry.width * 2}px`,
|
|
||||||
`height: ${entry.height * 2}px`
|
|
||||||
].join(';'),
|
|
||||||
width: entry.width
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let stylesheet = '';
|
|
||||||
for (const entry of Object.values(stylesheets)) {
|
|
||||||
if (!entry)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry.style1};}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry.style2};}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry.width}px;}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry.width * 2}px;}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styleNode = document.createElement('style');
|
|
||||||
styleNode.appendChild(document.createTextNode(stylesheet));
|
|
||||||
document.head.appendChild(styleNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTexAsBlob(path: string) {
|
|
||||||
const tex = parseTex(await (await fetch(path)).arrayBuffer());
|
|
||||||
if (tex.format !== 0x1450) // B8G8R8A8
|
|
||||||
throw 'Not supported';
|
|
||||||
|
|
||||||
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
|
|
||||||
for (let i = 0; i < dataArray.length; i += 4) {
|
|
||||||
const t = dataArray[i];
|
|
||||||
dataArray[i] = dataArray[i + 2];
|
|
||||||
dataArray[i + 2] = t;
|
|
||||||
}
|
|
||||||
const imageData = new ImageData(dataArray, tex.width, tex.height);
|
|
||||||
const bitmap = await createImageBitmap(imageData);
|
|
||||||
|
|
||||||
const canvas = new OffscreenCanvas(tex.width, tex.height);
|
|
||||||
canvas.getContext('bitmaprenderer')?.transferFromImageBitmap(bitmap);
|
|
||||||
return await canvas.convertToBlob();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGfd(path: string) {
|
|
||||||
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
|
|
||||||
const count = buffer.getInt32(8, true);
|
|
||||||
const entries: GdfEntry[] = new Array(count);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const offset = 0x10 + (i * 0x10);
|
|
||||||
entries[i] = {
|
|
||||||
id: buffer.getInt16(offset, true),
|
|
||||||
left: buffer.getInt16(offset + 2, true),
|
|
||||||
top: buffer.getInt16(offset + 4, true),
|
|
||||||
width: buffer.getInt16(offset + 6, true),
|
|
||||||
height: buffer.getInt16(offset + 8, true),
|
|
||||||
unk0A: buffer.getInt16(offset + 10, true),
|
|
||||||
redirect: buffer.getInt16(offset + 12, true),
|
|
||||||
unk0E: buffer.getInt16(offset + 14, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTex(arrayBuffer: ArrayBuffer) {
|
|
||||||
const buffer = new DataView(arrayBuffer);
|
|
||||||
const type = buffer.getInt32(0, true);
|
|
||||||
const format = buffer.getInt32(4, true);
|
|
||||||
const width = buffer.getInt16(8, true);
|
|
||||||
const height = buffer.getInt16(10, true);
|
|
||||||
const depth = buffer.getInt16(12, true);
|
|
||||||
const mipsAndFlag = buffer.getInt8(14);
|
|
||||||
const arraySize = buffer.getInt8(15);
|
|
||||||
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
|
|
||||||
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer: arrayBuffer,
|
|
||||||
type,
|
|
||||||
format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth,
|
|
||||||
mipsAndFlag,
|
|
||||||
arraySize,
|
|
||||||
lodOffsets,
|
|
||||||
offsetToSurface,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export enum WebPayloadType {
|
|
||||||
// Dalamud
|
|
||||||
Unknown,
|
|
||||||
Player,
|
|
||||||
Item,
|
|
||||||
Status,
|
|
||||||
RawText,
|
|
||||||
UIForeground,
|
|
||||||
UIGlow,
|
|
||||||
MapLink,
|
|
||||||
AutoTranslateText,
|
|
||||||
EmphasisItalic,
|
|
||||||
Icon,
|
|
||||||
Quest,
|
|
||||||
DalamudLink,
|
|
||||||
NewLine,
|
|
||||||
SeHyphen,
|
|
||||||
PartyFinder,
|
|
||||||
|
|
||||||
// Custom
|
|
||||||
CustomPartyFinder = 0x50,
|
|
||||||
CustomAchievement = 0x51,
|
|
||||||
CustomUri = 0x52,
|
|
||||||
CustomEmote = 0x53,
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ChatTab } from "./chat.svelte";
|
|
||||||
|
|
||||||
export const isChannelLocked: { locked: boolean } = $state({ locked: false });
|
|
||||||
export const channelOptions: ChannelOption[] = $state([ { text: 'Invalid', value: 0, preview: true } ]);
|
|
||||||
|
|
||||||
export interface ChannelOption {
|
|
||||||
text: string;
|
|
||||||
value: number;
|
|
||||||
preview: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selectedTab: { index: number } = $state({ index: 0 });
|
|
||||||
export const knownTabs: ChatTab[] = $state([]);
|
|
||||||
export const tabPaneState: { visible: boolean } = $state({ visible: true });
|
|
||||||
export const tabPaneAnimationState: { noAnimation: boolean } = $state({ noAnimation: true });
|
|
||||||
export const persistentTabPabeStateKey = 'chat2_tab_pane_visible';
|
|
||||||
|
|
||||||
export function openTabPane() {
|
|
||||||
tabPaneState.visible = true;
|
|
||||||
window.localStorage.setItem(persistentTabPabeStateKey, 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeTabPane() {
|
|
||||||
tabPaneState.visible = false;
|
|
||||||
window.localStorage.setItem(persistentTabPabeStateKey, 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatInput: { content: string } = $state({ content: ''} );
|
|
||||||
export const messagesList: {
|
|
||||||
element: HTMLElement | null,
|
|
||||||
scrolledToBottom: boolean
|
|
||||||
} = $state({ element: null, scrolledToBottom: true });
|
|
||||||
|
|
||||||
export function scrollMessagesToBottom() {
|
|
||||||
if (messagesList.element === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
messagesList.element.lastElementChild?.scrollIntoView();
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import {writable} from "svelte/store";
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/79696571
|
|
||||||
export const subscribe = <T>(functionToState: () => T, callback: (v: T) => void) => {
|
|
||||||
let value = writable<T>(functionToState());
|
|
||||||
value.subscribe(callback);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
value.set(functionToState());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<link rel="stylesheet" href="/static/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="/static/start.css">
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{@render children?.()}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const prerender = true;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state'
|
|
||||||
import { Alert } from '@sveltestrap/sveltestrap';
|
|
||||||
|
|
||||||
let data: App.Warning = $state({ hasWarning: false, content: '' });
|
|
||||||
$effect.pre(() => {
|
|
||||||
if (page.url.searchParams.has('message')) {
|
|
||||||
data = {
|
|
||||||
hasWarning: true,
|
|
||||||
content: page.url.searchParams.get('message') ?? '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
hasWarning: false,
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="auth">
|
|
||||||
<h1>Authcode</h1>
|
|
||||||
{#if data?.hasWarning }
|
|
||||||
<Alert content={data.content} color="warning" dismissible={true}/>
|
|
||||||
{/if}
|
|
||||||
<form action="/auth" method="POST">
|
|
||||||
<label><input type="password" name="authcode"></label>
|
|
||||||
<button type="submit" class="submitButton">Submit</button>
|
|
||||||
</form>
|
|
||||||
<div data-sveltekit-preload-data="false">
|
|
||||||
<img src="/emote/Sure" alt=":Sure:" data-sveltekit-preload-data="off">
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state'
|
|
||||||
import { Alert } from "@sveltestrap/sveltestrap";
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { ChatTwoWeb } from '$lib/chat.svelte'
|
|
||||||
import { tabPaneState, persistentTabPabeStateKey } from "$lib/shared.svelte";
|
|
||||||
import { addGfdStylesheet } from "$lib/gfd";
|
|
||||||
import DynamicTextArea from "../../components/DynamicTextArea.svelte";
|
|
||||||
import ChannelSelector from "../../components/ChannelSelector.svelte";
|
|
||||||
import TabPane from "../../components/TabPane.svelte";
|
|
||||||
import TabPaneOpener from "../../components/TabPaneOpener.svelte";
|
|
||||||
|
|
||||||
let data: App.Warning = $state({ hasWarning: false, content: '' });
|
|
||||||
$effect.pre(() => {
|
|
||||||
if (page.url.searchParams.has('message')) {
|
|
||||||
data = {
|
|
||||||
hasWarning: true,
|
|
||||||
content: page.url.searchParams.get('message') ?? '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
hasWarning: false,
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
console.log('the component has mounted');
|
|
||||||
|
|
||||||
// Populate the stylesheet with gfd data
|
|
||||||
addGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex');
|
|
||||||
|
|
||||||
// read saved tab pane state from localStorage
|
|
||||||
try {
|
|
||||||
const tabPaneVisible = window.localStorage.getItem(persistentTabPabeStateKey);
|
|
||||||
if (tabPaneVisible !== null) {
|
|
||||||
tabPaneState.visible = JSON.parse(tabPaneVisible);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// JSON.parse() failed, let's reset what's in localStorage
|
|
||||||
window.localStorage.removeItem(persistentTabPabeStateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all web functions in the background
|
|
||||||
const _ = new ChatTwoWeb();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="chat">
|
|
||||||
<TabPane />
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<TabPaneOpener />
|
|
||||||
|
|
||||||
<section id="messages">
|
|
||||||
<div class="scroll-container">
|
|
||||||
<ol id="messages-list"></ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="more-messages-indicator">
|
|
||||||
<!-- "arrow-down" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if data?.hasWarning }
|
|
||||||
<section id="warnings">
|
|
||||||
<Alert content={data.content} color="warning" dismissible={true}/>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section id="input">
|
|
||||||
<form>
|
|
||||||
<div class="input-container">
|
|
||||||
<DynamicTextArea />
|
|
||||||
<ChannelSelector />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="timestamp-width-probe"></div>
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
/* fonts */
|
|
||||||
@font-face {
|
|
||||||
font-family: Lodestone;
|
|
||||||
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
|
||||||
unicode-range: U+E020-E0DB;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter var';
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: oblique 0deg 10deg;
|
|
||||||
src: url('/static/Inter.var.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* variables */
|
|
||||||
:root {
|
|
||||||
--fg: white;
|
|
||||||
--fg-faint: #a0a0a0;
|
|
||||||
--fg-scrollbar: #404040;
|
|
||||||
--bg: #101010;
|
|
||||||
--bg-sidebar: #080808;
|
|
||||||
--bg-input: #202020;
|
|
||||||
--bg-input-hover: #282828;
|
|
||||||
--focus-color: #4060a0;
|
|
||||||
--unread-color: #beffa0;
|
|
||||||
|
|
||||||
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
|
|
||||||
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
|
|
||||||
|
|
||||||
--timestamp-width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* reset */
|
|
||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: Lodestone, 'Inter var', sans-serif;
|
|
||||||
font-feature-settings: 'tnum', 'calt' 0; /* calt appears to be on by default */
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span > a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* layout and global styles */
|
|
||||||
body {
|
|
||||||
padding: 25px;
|
|
||||||
height: 100dvh;
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
main.chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bg);
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
& > .main-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main.auth {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
input { width: 150px; }
|
|
||||||
|
|
||||||
input, .submitButton {
|
|
||||||
padding: 5px 20px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitButton {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border: 3px solid var(--bg-input);
|
|
||||||
background-image: var(--gradient-clickable);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tab list */
|
|
||||||
aside#tabs {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-color: var(--fg-scrollbar) var(--bg-sidebar);
|
|
||||||
background-color: var(--bg-sidebar);
|
|
||||||
transition: width 250ms ease;
|
|
||||||
|
|
||||||
width: 200px;
|
|
||||||
&.hidden { width: 0px; }
|
|
||||||
|
|
||||||
&.no-animation {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.inner {
|
|
||||||
width: 200px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 550;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 0.6rem 0 0.75rem;
|
|
||||||
border-color: var(--fg-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol#tabs-list {
|
|
||||||
margin: 0 -5px;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding: 3px 5px;
|
|
||||||
color: var(--fg-faint);
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
li + li {
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li:has(button:hover) {
|
|
||||||
color: var(--fg);
|
|
||||||
background-color: rgb(from var(--bg-input) r g b / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.active {
|
|
||||||
color: var(--fg);
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.unread button {
|
|
||||||
color: var(--unread-color);
|
|
||||||
text-shadow: 0 0 5px var(--unread-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* message list */
|
|
||||||
section#messages {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
.scroll-container {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
scrollbar-color: var(--fg-scrollbar) var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol#messages-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
flex: 0 0 var(--timestamp-width);
|
|
||||||
color: var(--fg-faint);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#more-messages-indicator {
|
|
||||||
position: absolute;
|
|
||||||
display: none;
|
|
||||||
right: 30px;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
filter: drop-shadow(0 0 5px #60a0ff) drop-shadow(0 0 15px #60a0ff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.more-messages #more-messages-indicator {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#timestamp-width-probe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* alerts */
|
|
||||||
section#warnings {
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 20px 20px 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* input bar, channel selector, ... */
|
|
||||||
section#input {
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, button {
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border: 3px solid var(--bg-input);
|
|
||||||
background-image: var(--gradient-clickable);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
position: relative;
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding-left: calc(20px + 1.5rem);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
|
|
||||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 15px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 1.3rem;
|
|
||||||
height: 1.3rem;
|
|
||||||
background-color: var(--fg);
|
|
||||||
mask-size: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
#chat-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#channel-select {
|
|
||||||
position: absolute;
|
|
||||||
top: -1.5em;
|
|
||||||
left: 23px;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 550;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
padding: 5px 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* icons, emotes */
|
|
||||||
.gfd-icon {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
vertical-align: middle;
|
|
||||||
zoom: 0.75;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emote-icon {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 2rem;
|
|
||||||
height: 1px;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*** mobile ***/
|
|
||||||
@media ((max-width: 600px) and (orientation: portrait)) or (max-height: 400px) {
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
main.chat {
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
section#messages {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
align-items: baseline;
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#timestamp-width-probe {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section#input {
|
|
||||||
button {
|
|
||||||
max-width: 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
button::before {
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container #channel-select {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gfd-icon { zoom: 0.65; }
|
|
||||||
.emote-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#tabs {
|
|
||||||
position: fixed;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
div.inner {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
kit: {
|
|
||||||
prerender: {
|
|
||||||
handleHttpError: 'warn'
|
|
||||||
},
|
|
||||||
adapter: adapter({
|
|
||||||
// default options are shown. On some platforms
|
|
||||||
// these options are set automatically — see below
|
|
||||||
pages: 'build',
|
|
||||||
assets: 'build',
|
|
||||||
fallback: undefined,
|
|
||||||
precompress: false,
|
|
||||||
strict: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
|
||||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [sveltekit()]
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
using WatsonWebserver.Core;
|
|
||||||
using WatsonWebserver.Lite;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class HostContext
|
|
||||||
{
|
|
||||||
public readonly ServerCore Core;
|
|
||||||
|
|
||||||
public bool IsActive;
|
|
||||||
public bool IsStopping;
|
|
||||||
|
|
||||||
// Initialized at webserver start
|
|
||||||
public WebserverLite Host = null!;
|
|
||||||
public Processing Processing = null!;
|
|
||||||
public RouteController RouteController = null!;
|
|
||||||
|
|
||||||
public readonly List<SSEConnection> EventConnections = [];
|
|
||||||
|
|
||||||
public readonly CancellationTokenSource TokenSource = new();
|
|
||||||
public readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Frontend/");
|
|
||||||
|
|
||||||
public HostContext(ServerCore core)
|
|
||||||
{
|
|
||||||
Core = core;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Start()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Host = new WebserverLite(new WebserverSettings("*", Plugin.Config.WebinterfacePort), DefaultRoute);
|
|
||||||
|
|
||||||
Processing = new Processing(this);
|
|
||||||
RouteController = new RouteController(this);
|
|
||||||
|
|
||||||
Host.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
|
|
||||||
Host.Routes.AuthenticateRequest = CheckAuthenticationCookie;
|
|
||||||
Host.Events.ExceptionEncountered += ExceptionEncountered;
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
#if DEBUG
|
|
||||||
Host.Settings.Debug.Requests = true;
|
|
||||||
Host.Settings.Debug.Routing = true;
|
|
||||||
Host.Settings.Debug.Responses = true;
|
|
||||||
Host.Settings.Debug.AccessControl = true;
|
|
||||||
#endif
|
|
||||||
Host.Events.Logger = logMessage => Plugin.Log.Debug(logMessage);
|
|
||||||
|
|
||||||
IsActive = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
Plugin.Log.Error(ex, "Initialization of the webserver failed.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Host.Start(TokenSource.Token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Webserver failed to boot up.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<bool> Stop()
|
|
||||||
{
|
|
||||||
// Is already stopped
|
|
||||||
if (!IsActive)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
IsStopping = true;
|
|
||||||
Host.Stop();
|
|
||||||
|
|
||||||
// Save our session tokens
|
|
||||||
Core.Plugin.SaveConfig();
|
|
||||||
|
|
||||||
// We get a copy, so that the original can be cleaned up successfully
|
|
||||||
foreach (var eventServer in EventConnections.ToArray())
|
|
||||||
await eventServer.DisposeAsync();
|
|
||||||
|
|
||||||
EventConnections.Clear();
|
|
||||||
Host.Dispose();
|
|
||||||
RouteController.Dispose();
|
|
||||||
IsStopping = false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Webserver failed to stop and dispose all resources.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region GeneralHandlers
|
|
||||||
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> DefaultRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
return await ctx.Response.Send("Nothing to see here.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (Plugin.Config.AuthStore.Count == 0)
|
|
||||||
{
|
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
|
||||||
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.AuthStore.Contains(token))
|
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
|
||||||
|
|
||||||
// Do nothing to let auth pass
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
using ChatTwo.Code;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
#region Outgoing SSE
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a valid tab with its assigned index
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTab(string name, int index, uint unreadCount)
|
|
||||||
{
|
|
||||||
[JsonProperty("name")] public string Name = name;
|
|
||||||
[JsonProperty("index")] public int Index = index;
|
|
||||||
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a number of tabs that are valid for the user to pick from
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTabList(ChatTab[] tabs)
|
|
||||||
{
|
|
||||||
[JsonProperty("tabs")] public ChatTab[] Tabs = tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a valid tab index and the current unread state as a number unread of messages
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTabUnreadState(int index, uint unreadCount)
|
|
||||||
{
|
|
||||||
[JsonProperty("index")] public int Index = index;
|
|
||||||
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains the current channel name
|
|
||||||
/// </summary>
|
|
||||||
public struct SwitchChannel((MessageTemplate[] Name, bool Locked) channel)
|
|
||||||
{
|
|
||||||
[JsonProperty("channelName")] public MessageTemplate[] ChannelName = channel.Name;
|
|
||||||
[JsonProperty("channelLocked")] public bool Locked = channel.Locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a number of channels that are valid for the user to pick from
|
|
||||||
/// </summary>
|
|
||||||
public struct ChannelList(Dictionary<string, uint> channels)
|
|
||||||
{
|
|
||||||
[JsonProperty("channels")] public Dictionary<string, uint> Channels = channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains one or multiple messages
|
|
||||||
/// </summary>
|
|
||||||
public struct Messages(MessageResponse[] set)
|
|
||||||
{
|
|
||||||
[JsonProperty("messages")] public MessageResponse[] Set = set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a single message with all its templates and a timestamp
|
|
||||||
/// </summary>
|
|
||||||
public struct MessageResponse()
|
|
||||||
{
|
|
||||||
[JsonProperty("id")] public Guid Id = Guid.Empty;
|
|
||||||
[JsonProperty("timestamp")] public string Timestamp = "";
|
|
||||||
[JsonProperty("templates")] public MessageTemplate[] Templates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Template that is used for the channel name or any message posted to the chatlog
|
|
||||||
/// </summary>
|
|
||||||
public struct MessageTemplate()
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The type of payload.
|
|
||||||
/// Dalamuds enum is just a baseline, there exists more that are expressed through raw values.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("payloadType")] public WebPayloadType PayloadType = WebPayloadType.Unknown;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for text and emote.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("content")] public string Content = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for an icon.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("iconId")] public uint IconId;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for text and url
|
|
||||||
///
|
|
||||||
/// Note:
|
|
||||||
/// 0 is used for invalid colors
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("color")] public uint Color;
|
|
||||||
|
|
||||||
public static MessageTemplate Empty => new();
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Outgoing POST
|
|
||||||
public struct OkResponse(string message)
|
|
||||||
{
|
|
||||||
[JsonProperty("message")] public string Message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ErrorResponse(string reason)
|
|
||||||
{
|
|
||||||
[JsonProperty("reason")] public string Reason = reason;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Incoming POST
|
|
||||||
/// <summary>
|
|
||||||
/// Message must fulfill the posting requirement
|
|
||||||
/// Greater than or equal 2 characters
|
|
||||||
/// Less than or equal 500 characters
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingMessage()
|
|
||||||
{
|
|
||||||
[JsonProperty("message")] public string Message = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The channel type must be a valid <see cref="InputChannel"/>
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingChannel()
|
|
||||||
{
|
|
||||||
[JsonProperty("channel")] public InputChannel Channel = InputChannel.Invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The tabs index must be a valid int
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingTab()
|
|
||||||
{
|
|
||||||
[JsonProperty("index")] public int Index = -1;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
// General
|
|
||||||
public class CloseEvent() : BaseEvent("close");
|
|
||||||
|
|
||||||
// Tab related
|
|
||||||
public class ChatTabListEvent(ChatTabList list) : BaseEvent("tab-list", JsonConvert.SerializeObject(list));
|
|
||||||
public class ChatTabSwitchedEvent(ChatTab chatTab) : BaseEvent("tab-switched", JsonConvert.SerializeObject(chatTab));
|
|
||||||
public class ChatTabUnreadStateEvent(ChatTabUnreadState unreadState) : BaseEvent("tab-unread-state", JsonConvert.SerializeObject(unreadState));
|
|
||||||
|
|
||||||
// Input channel related
|
|
||||||
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
|
|
||||||
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("channel-switched", JsonConvert.SerializeObject(switchChannel));
|
|
||||||
|
|
||||||
// Chat message related
|
|
||||||
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
|
|
||||||
public class NewMessageEvent(MessageResponse message) : BaseEvent("new-message", JsonConvert.SerializeObject(message));
|
|
||||||
|
|
||||||
public class BaseEvent(string eventType, string? data = null)
|
|
||||||
{
|
|
||||||
private string Event = eventType;
|
|
||||||
private string Data = data ?? "0"; // SSE requires data on each response
|
|
||||||
|
|
||||||
public byte[] Build()
|
|
||||||
{
|
|
||||||
// SSE always ends with \n\n
|
|
||||||
return Encoding.UTF8.GetBytes($"event: {Event}\ndata: {Data}\n\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Baseline: <see cref="Dalamud.Game.Text.SeStringHandling.PayloadType"/>
|
|
||||||
/// </summary>
|
|
||||||
public enum WebPayloadType
|
|
||||||
{
|
|
||||||
// Dalamud
|
|
||||||
Unknown,
|
|
||||||
Player,
|
|
||||||
Item,
|
|
||||||
Status,
|
|
||||||
RawText,
|
|
||||||
UIForeground,
|
|
||||||
UIGlow,
|
|
||||||
MapLink,
|
|
||||||
AutoTranslateText,
|
|
||||||
EmphasisItalic,
|
|
||||||
Icon,
|
|
||||||
Quest,
|
|
||||||
DalamudLink,
|
|
||||||
NewLine,
|
|
||||||
SeHyphen,
|
|
||||||
PartyFinder,
|
|
||||||
|
|
||||||
// Custom
|
|
||||||
CustomPartyFinder = 0x50,
|
|
||||||
CustomAchievement = 0x51,
|
|
||||||
CustomUri = 0x52,
|
|
||||||
CustomEmote = 0x53,
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using ChatTwo.Code;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class Processing
|
|
||||||
{
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
public Processing(HostContext hostContext)
|
|
||||||
{
|
|
||||||
HostContext = hostContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal (MessageTemplate[] Name, bool Locked) ReadChannelName(Chunk[] channelName)
|
|
||||||
{
|
|
||||||
var locked = HostContext.Core.Plugin.CurrentTab is not { Channel: null };
|
|
||||||
return (channelName.Select(ProcessChunk).ToArray(), locked);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<MessageResponse[]> ReadMessageList()
|
|
||||||
{
|
|
||||||
var tabMessages = await HostContext.Core.Plugin.CurrentTab.Messages.GetCopy();
|
|
||||||
return tabMessages.TakeLast(Plugin.Config.WebinterfaceMaxLinesToSend).Select(ReadMessageContent).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal MessageResponse ReadMessageContent(Message message)
|
|
||||||
{
|
|
||||||
var response = new MessageResponse
|
|
||||||
{
|
|
||||||
Id = message.Id,
|
|
||||||
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
|
|
||||||
};
|
|
||||||
|
|
||||||
var sender = message.Sender.Select(ProcessChunk);
|
|
||||||
var content = message.Content.Select(ProcessChunk);
|
|
||||||
response.Templates = sender.Concat(content).ToArray();
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageTemplate ProcessChunk(Chunk chunk)
|
|
||||||
{
|
|
||||||
if (chunk is IconChunk { } icon)
|
|
||||||
{
|
|
||||||
var iconId = (uint)icon.Icon;
|
|
||||||
return IconUtil.GfdFileView.TryGetEntry(iconId, out _) ? new MessageTemplate {PayloadType = WebPayloadType.Icon, IconId = iconId}: MessageTemplate.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk is TextChunk { } text)
|
|
||||||
{
|
|
||||||
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
|
|
||||||
{
|
|
||||||
var image = EmoteCache.GetEmote(emotePayload.Code);
|
|
||||||
|
|
||||||
if (image is { Failed: false })
|
|
||||||
return new MessageTemplate { PayloadType = WebPayloadType.CustomEmote, Color = 0, Content = emotePayload.Code };
|
|
||||||
}
|
|
||||||
|
|
||||||
var color = text.Foreground;
|
|
||||||
if (color == null && text.FallbackColour != null)
|
|
||||||
{
|
|
||||||
var type = text.FallbackColour.Value;
|
|
||||||
color = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
color ??= 0;
|
|
||||||
|
|
||||||
var userContent = text.Content;
|
|
||||||
if (HostContext.Core.Plugin.ChatLogWindow.ScreenshotMode)
|
|
||||||
{
|
|
||||||
if (chunk.Link is PlayerPayload playerPayload)
|
|
||||||
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
|
|
||||||
else if (Plugin.PlayerState.IsLoaded)
|
|
||||||
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, Plugin.PlayerState.CharacterName, Plugin.PlayerState.HomeWorld.RowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNotUrl = text.Link is not UriPayload;
|
|
||||||
return new MessageTemplate { PayloadType = isNotUrl ? WebPayloadType.RawText : WebPayloadType.CustomUri, Color = color.Value, Content = userContent };
|
|
||||||
}
|
|
||||||
|
|
||||||
return MessageTemplate.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Messages> GetAllMessages()
|
|
||||||
{
|
|
||||||
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
|
|
||||||
return new Messages(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SwitchChannel GetCurrentChannel()
|
|
||||||
{
|
|
||||||
var channel = ReadChannelName(HostContext.Core.Plugin.ChatLogWindow.PreviousChannel);
|
|
||||||
return new SwitchChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChannelList GetValidChannels()
|
|
||||||
{
|
|
||||||
var channels = HostContext.Core.Plugin.ChatLogWindow.GetValidChannels();
|
|
||||||
return new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatTab GetCurrentTab()
|
|
||||||
{
|
|
||||||
var currentTab = HostContext.Core.Plugin.CurrentTab;
|
|
||||||
return new ChatTab(currentTab.Name, HostContext.Core.Plugin.LastTab, currentTab.Unread);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatTabList GetAllTabs()
|
|
||||||
{
|
|
||||||
var tabs = Plugin.Config.Tabs.Select((tab, idx) => new ChatTab(tab.Name, idx, tab.Unread)).ToArray();
|
|
||||||
return new ChatTabList(tabs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Web;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Lumina.Data.Files;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using WatsonWebserver.Core;
|
|
||||||
using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
|
|
||||||
using HttpMethod = WatsonWebserver.Core.HttpMethod;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class RouteController
|
|
||||||
{
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
private readonly string AuthTemplate;
|
|
||||||
private readonly string ChatBoxTemplate;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, long> RateLimit = [];
|
|
||||||
|
|
||||||
private readonly JsonSerializerSettings JsonSettings = new()
|
|
||||||
{
|
|
||||||
Error = delegate(object? _, ErrorEventArgs args) { args.ErrorContext.Handled = true; }
|
|
||||||
};
|
|
||||||
|
|
||||||
public RouteController(HostContext hostContext)
|
|
||||||
{
|
|
||||||
HostContext = hostContext;
|
|
||||||
|
|
||||||
AuthTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "index.html"));
|
|
||||||
ChatBoxTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "chat.html"));
|
|
||||||
|
|
||||||
// Pre Auth
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
|
||||||
|
|
||||||
// Post Auth
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/tab", ReceiveTabSwitch, ExceptionRoute);
|
|
||||||
|
|
||||||
// Ship all other static files dynamically
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Content.Add("/_app/", true, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Content.Add("/static/", true, ExceptionRoute);
|
|
||||||
|
|
||||||
// Server-Sent Events Route
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/sse", NewSSEConnection, ExceptionRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 500;
|
|
||||||
await ctx.Response.Send("Internal Server Error, please try again");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AuthRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (Plugin.Config.AuthStore.Count > 0)
|
|
||||||
{
|
|
||||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
|
||||||
if (cookies.TryGetValue("ChatTwo-token", out var value) && Plugin.Config.AuthStore.Contains(value))
|
|
||||||
{
|
|
||||||
await Redirect(ctx, "/chat");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.Response.Send(AuthTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#region FileHandlerRoutes
|
|
||||||
private async Task GetTexData(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetGfdData(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetLodestoneFont(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = HostContext.Core.Plugin.FontManager.GameSymFont;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetFavicon(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 404;
|
|
||||||
await ctx.Response.Send();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetEmote(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var name = ctx.Request.Url.Parameters["name"] ?? "";
|
|
||||||
if (name == "" || !EmoteCache.Exists(name))
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send("Malformed emote name.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var emote = EmoteCache.GetEmote(name);
|
|
||||||
if (emote is null)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send("Emote not valid.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the emote to be loaded a maximum of 5 times
|
|
||||||
var timeout = 5;
|
|
||||||
while (!emote.IsLoaded && timeout > 0)
|
|
||||||
{
|
|
||||||
timeout--;
|
|
||||||
await Task.Delay(25);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
|
|
||||||
await ctx.Response.Send(emote.RawData);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PreAuthRoutes
|
|
||||||
private async Task<bool> AuthenticateClient(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var currentTick = Environment.TickCount64;
|
|
||||||
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
|
|
||||||
{
|
|
||||||
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
|
|
||||||
return await Redirect(ctx, "/", ("message", "Rate limit active (10s)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The next request will be rate limited for 10s
|
|
||||||
RateLimit[ctx.Request.Source.IpAddress] = currentTick + 10_000;
|
|
||||||
|
|
||||||
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
|
|
||||||
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
|
|
||||||
return await Redirect(ctx, "/", ("message", "Authentication failed"));
|
|
||||||
|
|
||||||
var token = WebinterfaceUtil.GenerateSimpleToken();
|
|
||||||
Plugin.Config.AuthStore.Add(token);
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
|
||||||
return await Redirect(ctx, "/chat");
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PostAuthRoutes
|
|
||||||
private async Task ChatBoxRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
await ctx.Response.Send(ChatBoxTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveMessage(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (content.Message.Length is < 2 or > 500)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid message received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() =>
|
|
||||||
{
|
|
||||||
HostContext.Core.Plugin.ChatLogWindow.Chat = content.Message;
|
|
||||||
HostContext.Core.Plugin.ChatLogWindow.SendChatBox(HostContext.Core.Plugin.CurrentTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Message was send to the channel.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (!Enum.IsDefined(channel.Channel))
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.ChatLogWindow.SetChannel(channel.Channel); });
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch was initiated.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveTabSwitch(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var tab = JsonConvert.DeserializeObject<IncomingTab>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (tab.Index < 0 || tab.Index >= Plugin.Config.Tabs.Count)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid tab received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.WantedTab = tab.Index; });
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Tab switch was initiated.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NewSSEConnection(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Log.Debug($"Client connected: {ctx.Guid}");
|
|
||||||
|
|
||||||
var sse = new SSEConnection(HostContext.TokenSource.Token);
|
|
||||||
await HostContext.Core.PrepareNewClient(sse);
|
|
||||||
HostContext.EventConnections.Add(sse);
|
|
||||||
|
|
||||||
await sse.HandleEventLoop(ctx);
|
|
||||||
|
|
||||||
// It should always be done after return
|
|
||||||
if (sse.Done)
|
|
||||||
HostContext.EventConnections.Remove(sse);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Failed to finish the server event function");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region RedirectHelper
|
|
||||||
public static async Task<bool> Redirect(HttpContextBase ctx, string location, params (string, string)[] parameter)
|
|
||||||
{
|
|
||||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
|
||||||
foreach (var (key, value) in parameter)
|
|
||||||
query.Add(key, value);
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Location", $"{location}?{query}");
|
|
||||||
ctx.Response.StatusCode = 303;
|
|
||||||
return await ctx.Response.Send();
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PreChecks
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check that the request has the correct media type that the functions expects.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ctx"></param>
|
|
||||||
/// <param name="requiredMediaType"></param>
|
|
||||||
/// <returns>True if media type is correct, otherwise handled and false</returns>
|
|
||||||
private async Task<bool> EnforceMediaType(HttpContextBase ctx, string requiredMediaType)
|
|
||||||
{
|
|
||||||
if (ctx.Request.ContentType == requiredMediaType)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 415;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using WatsonWebserver.Core;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class SSEConnection
|
|
||||||
{
|
|
||||||
private bool Stopping;
|
|
||||||
private readonly CancellationToken Token;
|
|
||||||
|
|
||||||
public bool Done;
|
|
||||||
public readonly ConcurrentQueue<BaseEvent> OutboundQueue = new();
|
|
||||||
|
|
||||||
public SSEConnection(CancellationToken token)
|
|
||||||
{
|
|
||||||
Token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task HandleEventLoop(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ctx.Response.Headers.Add("Content-Type", "text/event-stream");
|
|
||||||
ctx.Response.Headers.Add("Cache-Control", "no-cache");
|
|
||||||
ctx.Response.Headers.Add("Connection", "keep-alive");
|
|
||||||
|
|
||||||
ctx.Response.ChunkedTransfer = true;
|
|
||||||
while (!Token.IsCancellationRequested && !Stopping)
|
|
||||||
{
|
|
||||||
await Task.Delay(10, Token);
|
|
||||||
if (Token.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), false, Token))
|
|
||||||
{
|
|
||||||
Plugin.Log.Debug("SSE connection was unable to send new data");
|
|
||||||
Plugin.Log.Debug($"Client disconnected: {ctx.Guid}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "SSE handler failed.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
|
||||||
await ctx.Response.SendChunk(new CloseEvent().Build(), true, Token);
|
|
||||||
|
|
||||||
// Manually confirm that we have finished our connection, even if the final response failed
|
|
||||||
// This can happen if the client disconnects before the server does
|
|
||||||
ctx.Response.ResponseSent = true;
|
|
||||||
|
|
||||||
Done = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Stopping = true;
|
|
||||||
|
|
||||||
var timeout = 1000; // 1000ms
|
|
||||||
while (timeout > 0)
|
|
||||||
{
|
|
||||||
if (Done)
|
|
||||||
break;
|
|
||||||
|
|
||||||
timeout -= 100;
|
|
||||||
await Task.Delay(100);
|
|
||||||
Plugin.Log.Debug("Sleeping because EventServer still alive");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using Dalamud.Plugin.Services;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class ServerCore : IAsyncDisposable
|
|
||||||
{
|
|
||||||
public readonly Plugin Plugin;
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
public ServerCore(Plugin plugin)
|
|
||||||
{
|
|
||||||
Plugin = plugin;
|
|
||||||
HostContext = new HostContext(this);
|
|
||||||
|
|
||||||
Plugin.Framework.Update += FrameworkUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Plugin.Framework.Update -= FrameworkUpdate;
|
|
||||||
await HostContext.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void FrameworkUpdate(IFramework _)
|
|
||||||
{
|
|
||||||
foreach (var (idx, tab) in Plugin.Config.Tabs.Index())
|
|
||||||
{
|
|
||||||
if (tab.Unread == tab.LastSendUnread)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
tab.LastSendUnread = tab.Unread;
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(new ChatTabUnreadStateEvent(new ChatTabUnreadState(idx, tab.Unread)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region SSE Helper
|
|
||||||
internal async Task PrepareNewClient(SSEConnection sse)
|
|
||||||
{
|
|
||||||
// This takes long, so keep it outside the next frame
|
|
||||||
var messages = await HostContext.Processing.GetAllMessages();
|
|
||||||
|
|
||||||
// Using the bulk message event to clear everything on the client side that may still exist
|
|
||||||
await Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(messages));
|
|
||||||
|
|
||||||
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(HostContext.Processing.GetCurrentChannel()));
|
|
||||||
sse.OutboundQueue.Enqueue(new ChannelListEvent(HostContext.Processing.GetValidChannels()));
|
|
||||||
|
|
||||||
sse.OutboundQueue.Enqueue(new ChatTabSwitchedEvent(HostContext.Processing.GetCurrentTab()));
|
|
||||||
sse.OutboundQueue.Enqueue(new ChatTabListEvent(HostContext.Processing.GetAllTabs()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendNewMessage(Message message)
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new NewMessageEvent(HostContext.Processing.ReadMessageContent(message));
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending message over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendBulkMessageList()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(HostContext.Processing.ReadMessageList().Result)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendChannelSwitch(Chunk[] channelName)
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(HostContext.Processing.ReadChannelName(channelName)));
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendChannelList()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new ChannelListEvent(HostContext.Processing.GetValidChannels());
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendNewLogin()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(async () =>
|
|
||||||
{
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
await HostContext.Core.PrepareNewClient(eventServer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Preparing all clients after login failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public void InvalidateSessions()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Plugin.Config.AuthStore.Clear();
|
|
||||||
Plugin.SaveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsActive()
|
|
||||||
{
|
|
||||||
return HostContext is { IsActive: true, Host.IsListening: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsStopping()
|
|
||||||
{
|
|
||||||
return HostContext is { IsActive: false, IsStopping: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public bool Start()
|
|
||||||
{
|
|
||||||
return HostContext.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run()
|
|
||||||
{
|
|
||||||
HostContext.Run();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<bool> Stop()
|
|
||||||
{
|
|
||||||
return await HostContext.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public static class WebserverUtil
|
|
||||||
{
|
|
||||||
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> func)
|
|
||||||
{
|
|
||||||
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// From: https://github.com/NancyFx/Nancy/blob/master/src/Nancy/Request.cs#L176
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the cookie data from the provided string if it exists
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cookieHeader">The string containing cookie data</param>
|
|
||||||
/// <returns>Cookies dictionary</returns>
|
|
||||||
public static Dictionary<string, string> GetCookieData(string cookieHeader)
|
|
||||||
{
|
|
||||||
var cookieDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (cookieHeader.Length == 0)
|
|
||||||
return cookieDictionary;
|
|
||||||
|
|
||||||
var values = cookieHeader.TrimEnd(';').Split(';');
|
|
||||||
foreach (var parts in values.Select(c => c.Split(['='], 2)))
|
|
||||||
{
|
|
||||||
var cookieName = parts[0].Trim();
|
|
||||||
var cookieValue = parts.Length == 1 ? string.Empty : parts[1]; //Cookie attribute
|
|
||||||
|
|
||||||
cookieDictionary[cookieName] = cookieValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookieDictionary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -258,19 +258,13 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
|
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
|
||||||
Store.UpsertMessage(message);
|
Store.UpsertMessage(message);
|
||||||
|
|
||||||
var currentTabId = Plugin.CurrentTab.Identifier;
|
|
||||||
var currentMatches = Plugin.CurrentTab.Matches(message);
|
var currentMatches = Plugin.CurrentTab.Matches(message);
|
||||||
foreach (var tab in Plugin.Config.Tabs)
|
foreach (var tab in Plugin.Config.Tabs)
|
||||||
{
|
{
|
||||||
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches);
|
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches);
|
||||||
|
|
||||||
if (tab.Matches(message))
|
if (tab.Matches(message))
|
||||||
{
|
|
||||||
tab.AddMessage(message, unread);
|
tab.AddMessage(message, unread);
|
||||||
|
|
||||||
if (tab.Identifier == currentTabId)
|
|
||||||
Plugin.ServerCore.SendNewMessage(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,7 @@ public sealed class PayloadHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScreenshotMode changed, so we inform the webinterface about the new message format
|
ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode);
|
||||||
if (ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode))
|
|
||||||
LogWindow.Plugin.ServerCore.SendBulkMessageList();
|
|
||||||
|
|
||||||
if (ImGui.Selectable(Language.Context_HideChat))
|
if (ImGui.Selectable(Language.Context_HideChat))
|
||||||
LogWindow.UserHide();
|
LogWindow.UserHide();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using ChatTwo.Http;
|
|
||||||
using ChatTwo.Ipc;
|
using ChatTwo.Ipc;
|
||||||
using ChatTwo.Resources;
|
using ChatTwo.Resources;
|
||||||
using ChatTwo.Ui;
|
using ChatTwo.Ui;
|
||||||
@@ -63,8 +62,6 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
internal TypingIpc TypingIpc { get; }
|
internal TypingIpc TypingIpc { get; }
|
||||||
internal FontManager FontManager { get; }
|
internal FontManager FontManager { get; }
|
||||||
|
|
||||||
public readonly ServerCore ServerCore;
|
|
||||||
|
|
||||||
internal int DeferredSaveFrames = -1;
|
internal int DeferredSaveFrames = -1;
|
||||||
|
|
||||||
internal DateTime GameStarted { get; }
|
internal DateTime GameStarted { get; }
|
||||||
@@ -150,9 +147,6 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
FileDialogManager = new FileDialogManager();
|
FileDialogManager = new FileDialogManager();
|
||||||
|
|
||||||
// Function call this in its ctor if the player is already logged in
|
|
||||||
ServerCore = new ServerCore(this);
|
|
||||||
|
|
||||||
Commands = new Commands();
|
Commands = new Commands();
|
||||||
Functions = new GameFunctions.GameFunctions(this);
|
Functions = new GameFunctions.GameFunctions(this);
|
||||||
Ipc = new IpcManager();
|
Ipc = new IpcManager();
|
||||||
@@ -219,16 +213,6 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
// profiling difficult.
|
// profiling difficult.
|
||||||
AutoTranslate.PreloadCache();
|
AutoTranslate.PreloadCache();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Automatically start the webserver if requested
|
|
||||||
if (Config.WebinterfaceAutoStart)
|
|
||||||
{
|
|
||||||
Task.Run(() =>
|
|
||||||
{
|
|
||||||
ServerCore.Start();
|
|
||||||
ServerCore.Run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -267,7 +251,6 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
Commands?.Dispose();
|
Commands?.Dispose();
|
||||||
|
|
||||||
EmoteCache.Dispose();
|
EmoteCache.Dispose();
|
||||||
ServerCore?.DisposeAsync().AsTask().Wait();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MigrateFromChatTwoLayout()
|
private static void MigrateFromChatTwoLayout()
|
||||||
|
|||||||
@@ -375,10 +375,6 @@ public sealed class ChatLogWindow : Window
|
|||||||
newTab.CurrentChannel = previousTab.CurrentChannel;
|
newTab.CurrentChannel = previousTab.CurrentChannel;
|
||||||
|
|
||||||
SetChannel(newTab.CurrentChannel.Channel);
|
SetChannel(newTab.CurrentChannel.Channel);
|
||||||
|
|
||||||
// Inform the webinterface about tab switch
|
|
||||||
// TODO implement tabs in the webinterface
|
|
||||||
Plugin.ServerCore.SendNewLogin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum HideState
|
private enum HideState
|
||||||
@@ -772,10 +768,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
{
|
{
|
||||||
var currentChannel = ReadChannelName(activeTab);
|
var currentChannel = ReadChannelName(activeTab);
|
||||||
if (!currentChannel.SequenceEqual(PreviousChannel))
|
if (!currentChannel.SequenceEqual(PreviousChannel))
|
||||||
{
|
|
||||||
PreviousChannel = currentChannel;
|
PreviousChannel = currentChannel;
|
||||||
Plugin.ServerCore.SendChannelSwitch(currentChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
DrawChunks(currentChannel);
|
DrawChunks(currentChannel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Globalization;
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ChatTwo.Code;
|
using ChatTwo.Code;
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using ChatTwo.Resources;
|
using ChatTwo.Resources;
|
||||||
using ChatTwo.Util;
|
using ChatTwo.Util;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
namespace ChatTwo.Util;
|
|
||||||
|
|
||||||
public static class WebinterfaceUtil
|
|
||||||
{
|
|
||||||
private static readonly Random Rng = new();
|
|
||||||
|
|
||||||
public static string GenerateSimpleAuthCode()
|
|
||||||
{
|
|
||||||
return (100000 + Rng.Next() % 100000).ToString()[1..];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GenerateSimpleToken()
|
|
||||||
{
|
|
||||||
var buffer = new byte[15];
|
|
||||||
Rng.NextBytes(buffer);
|
|
||||||
|
|
||||||
return Convert.ToHexString(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user