First implementation of sveltekit for webinterface
This commit is contained in:
@@ -7,8 +7,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="JetBrains.Annotations" Version="2025.2.0" />
|
<PackageReference Include="JetBrains.Annotations" Version="2025.2.2" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="MSTest.TestAdapter" Version="3.6.3" />
|
<PackageReference Include="MSTest.TestAdapter" Version="3.6.3" />
|
||||||
<PackageReference Include="MSTest.TestFramework" Version="3.6.3" />
|
<PackageReference Include="MSTest.TestFramework" Version="3.6.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Dalamud.NET.Sdk/13.0.0">
|
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.31.2</Version>
|
<Version>1.31.2</Version>
|
||||||
<TargetFramework>net9.0-windows</TargetFramework>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
|
||||||
<PackageReference Include="Pidgin" Version="3.3.0" />
|
<PackageReference Include="Pidgin" Version="3.3.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||||
<PackageReference Include="Watson.Lite" Version="6.2.3" />
|
<PackageReference Include="Watson.Lite" Version="6.3.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -47,5 +47,8 @@
|
|||||||
<Content Include="Http\templates\*.*">
|
<Content Include="Http\templates\*.*">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Http\Frontend\build\**">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace ChatTwo.Code;
|
namespace ChatTwo.Code;
|
||||||
|
|
||||||
internal enum InputChannel : uint
|
public enum InputChannel : uint
|
||||||
{
|
{
|
||||||
Tell = 0,
|
Tell = 0,
|
||||||
Say = 1,
|
Say = 1,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ internal class Configuration : IPluginConfiguration
|
|||||||
public bool WebinterfaceAutoStart;
|
public bool WebinterfaceAutoStart;
|
||||||
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
||||||
public int WebinterfacePort = 9000;
|
public int WebinterfacePort = 9000;
|
||||||
public ConcurrentDictionary<string, bool> SessionTokens = [];
|
public HashSet<string> AuthStore = [];
|
||||||
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
|
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
|
||||||
|
|
||||||
internal void UpdateFrom(Configuration other, bool backToOriginal)
|
internal void UpdateFrom(Configuration other, bool backToOriginal)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 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
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,23 @@
|
|||||||
|
// 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 {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import {type Source, source} from "sveltekit-sse";
|
||||||
|
|
||||||
|
interface ChatElements {
|
||||||
|
channelHint: HTMLElement | null,
|
||||||
|
channelSelect: HTMLElement | null,
|
||||||
|
|
||||||
|
messagesContainer: Element | null,
|
||||||
|
messagesList: HTMLElement | null,
|
||||||
|
|
||||||
|
timestampWidthProbe: HTMLElement | null,
|
||||||
|
|
||||||
|
inputForm: Element | null,
|
||||||
|
chatInput: HTMLElement | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.Messages`
|
||||||
|
interface Messages {
|
||||||
|
messages: MessageResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.MessageResponse`
|
||||||
|
interface MessageResponse {
|
||||||
|
timestamp: string;
|
||||||
|
templates: Template[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.MessageTemplate`
|
||||||
|
interface Template {
|
||||||
|
id: number;
|
||||||
|
payload: string;
|
||||||
|
content: string;
|
||||||
|
color: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.SwitchChannel`
|
||||||
|
interface SwitchChannel {
|
||||||
|
channelName: Template[];
|
||||||
|
channelLocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.ChannelList`
|
||||||
|
interface ChannelList {
|
||||||
|
channels: {[key: string]: number};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.ChatTab`
|
||||||
|
interface ChatTab {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref `DataStructure.ChatTabList`
|
||||||
|
interface ChatTabList {
|
||||||
|
tabs: ChatTab[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatTwoWeb {
|
||||||
|
elements!: ChatElements;
|
||||||
|
maxTimestampWidth: number = 0;
|
||||||
|
scrolledToBottom: boolean = true;
|
||||||
|
channelLocked: boolean = false;
|
||||||
|
|
||||||
|
sse!: EventSource;
|
||||||
|
connection!: Source;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupDOMElements();
|
||||||
|
this.setupSSEConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDOMElements() {
|
||||||
|
this.elements = {
|
||||||
|
channelHint: document.getElementById('channel-hint'),
|
||||||
|
channelSelect: document.getElementById('channel-select'),
|
||||||
|
|
||||||
|
messagesContainer: document.querySelector('#messages > .scroll-container')!,
|
||||||
|
messagesList: document.getElementById('messages-list'),
|
||||||
|
|
||||||
|
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
|
||||||
|
|
||||||
|
inputForm: document.querySelector('#input > form'),
|
||||||
|
chatInput: document.getElementById('chat-input')
|
||||||
|
};
|
||||||
|
|
||||||
|
// channel selector
|
||||||
|
this.elements.channelSelect?.addEventListener('change', async (event) => {
|
||||||
|
if (event.currentTarget === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const rawResponse = await fetch('/channel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ channel: (event.currentTarget as HTMLSelectElement).value })
|
||||||
|
});
|
||||||
|
// const content = await rawResponse.json();
|
||||||
|
// TODO: use the response
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (this.scrolledToBottom) {
|
||||||
|
this.scrollMessagesToBottom();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// handle message sending
|
||||||
|
this.elements.inputForm?.addEventListener('submit', async (event) => {
|
||||||
|
if (this.elements.chatInput === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
// @ts-ignore
|
||||||
|
const message = this.elements.chatInput.value;
|
||||||
|
if (message.length > 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawResponse = await fetch('/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: message })
|
||||||
|
});
|
||||||
|
// const content = await rawResponse.json();
|
||||||
|
// TODO: use the response
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
this.elements.chatInput.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesAreScrolledToBottom() {
|
||||||
|
if (this.elements.messagesContainer === null) {
|
||||||
|
return this.scrolledToBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.elements.messagesContainer.scrollTopMax) {
|
||||||
|
this.scrolledToBottom = this.elements.messagesContainer.scrollTop === this.elements.messagesContainer.scrollTopMax;
|
||||||
|
} else {
|
||||||
|
this.scrolledToBottom =
|
||||||
|
(
|
||||||
|
this.elements.messagesContainer.scrollHeight -
|
||||||
|
this.elements.messagesContainer.clientHeight -
|
||||||
|
this.elements.messagesContainer.scrollTop
|
||||||
|
) < 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.scrolledToBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChannelHint(channel: SwitchChannel) {
|
||||||
|
if (this.elements.channelHint === null || this.elements.channelSelect === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.elements.channelHint.innerHTML = '';
|
||||||
|
|
||||||
|
const channelElement = this.processTemplate(channel.channelName);
|
||||||
|
|
||||||
|
// Makes the channel selector unclickable if the channel is fixed
|
||||||
|
this.channelLocked = channel.channelLocked;
|
||||||
|
if (this.channelLocked) {
|
||||||
|
if (channelElement.firstChild === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
channelElement.firstChild.innerText = `(Locked) ${channelElement.firstChild.innerText}`;
|
||||||
|
this.elements.channelSelect.style.pointerEvents = 'none';
|
||||||
|
} else {
|
||||||
|
this.elements.channelSelect.style.removeProperty('pointer-events');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elements.channelHint.appendChild(channelElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChannels(channelList: ChannelList) {
|
||||||
|
if (this.elements.channelSelect === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.elements.channelSelect.innerHTML = '';
|
||||||
|
for (const [ label, channel ] of Object.entries(channelList.channels)) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = channel.toString();
|
||||||
|
option.innerText = label;
|
||||||
|
this.elements.channelSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollMessagesToBottom() {
|
||||||
|
if (this.elements.messagesContainer === null || this.elements.messagesList === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.elements.messagesContainer.scrollTopMax) {
|
||||||
|
this.elements.messagesContainer.scrollTop = this.elements.messagesContainer.scrollTopMax;
|
||||||
|
} else {
|
||||||
|
if (this.elements.messagesList.lastElementChild === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.elements.messagesList.lastElementChild.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this.scrollMessagesToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processTemplate(templates: Template[]) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for( const template of templates ) {
|
||||||
|
const spanElement = document.createElement('span');
|
||||||
|
switch (template.payload) {
|
||||||
|
case 'text':
|
||||||
|
this.processTextTemplate(template, spanElement);
|
||||||
|
break;
|
||||||
|
case 'url':
|
||||||
|
this.processUrlTemplate(template, spanElement);
|
||||||
|
break;
|
||||||
|
case 'emote':
|
||||||
|
this.processEmote(template, spanElement);
|
||||||
|
break;
|
||||||
|
case 'icon':
|
||||||
|
this.processIcon(template, spanElement);
|
||||||
|
break;
|
||||||
|
case 'empty':
|
||||||
|
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');
|
||||||
|
urlElement.innerText = template.content;
|
||||||
|
urlElement.href = encodeURI(template.content);
|
||||||
|
urlElement.target = '_blank'
|
||||||
|
|
||||||
|
if (template.color !== 0)
|
||||||
|
{
|
||||||
|
this.processColor(template, spanElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`Data received: ${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(`Data received: ${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(`Data received: ${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(`Data received: ${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(`Data received: ${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(`Data received: ${data}`)
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
let chatTab: ChatTab = JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// list of all tabs
|
||||||
|
this.connection.select('tab-list').subscribe((data: string) => {
|
||||||
|
console.log(`Data received: ${data}`)
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
let chatTabLit: ChatTabList = JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<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?.()}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const prerender = true;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { Alert } from '@sveltestrap/sveltestrap';
|
||||||
|
|
||||||
|
let data: App.Warning | null = null;
|
||||||
|
$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>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import {Alert} from "@sveltestrap/sveltestrap";
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { ChatTwoWeb } from '$lib/chat'
|
||||||
|
import {addGfdStylesheet} from "$lib/gfd";
|
||||||
|
|
||||||
|
let data: App.Warning | null = null;
|
||||||
|
$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');
|
||||||
|
|
||||||
|
// Load all web functions in the background
|
||||||
|
const _ = new ChatTwoWeb();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="chat">
|
||||||
|
<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="select-container">
|
||||||
|
<select id="channel-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-container">
|
||||||
|
<input type="text" id="chat-input" autocomplete="off" placeholder="Message" enterkeyhint="send" maxlength="500">
|
||||||
|
<div id="channel-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="timestamp-width-probe"></div>
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/* 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-input: #202020;
|
||||||
|
--bg-input-hover: #282828;
|
||||||
|
--focus-color: #4060a0;
|
||||||
|
|
||||||
|
--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: 50px;
|
||||||
|
height: 100dvh;
|
||||||
|
background-color: var(--bg-input-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > div > main.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--bg);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > div > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* message list */
|
||||||
|
section#messages {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-color: var(--fg-scrollbar) var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
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, select {
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--focus-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button, select {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-container, button {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-container {
|
||||||
|
flex-basis: 0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
/* "message-circle" 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"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
appearance: none;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-container::before, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-container::before {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
#chat-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channel-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.2em;
|
||||||
|
left: 23px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 550;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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-width: 400px) {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > 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-hint {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfd-icon { zoom: 0.65; }
|
||||||
|
.emote-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ public class HostContext
|
|||||||
internal readonly List<SSEConnection> EventConnections = [];
|
internal readonly List<SSEConnection> EventConnections = [];
|
||||||
|
|
||||||
internal readonly CancellationTokenSource TokenSource = new();
|
internal readonly CancellationTokenSource TokenSource = new();
|
||||||
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
|
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http/Frontend/build");
|
||||||
|
|
||||||
public HostContext(Plugin plugin)
|
public HostContext(Plugin plugin)
|
||||||
{
|
{
|
||||||
@@ -120,14 +120,14 @@ public class HostContext
|
|||||||
|
|
||||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
if (Plugin.Config.SessionTokens.IsEmpty)
|
if (Plugin.Config.AuthStore.Count == 0)
|
||||||
{
|
{
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
||||||
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.SessionTokens.ContainsKey(token))
|
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.AuthStore.Contains(token))
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||||
|
|
||||||
// Do nothing to let auth pass
|
// Do nothing to let auth pass
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
using Newtonsoft.Json;
|
using ChatTwo.Code;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
namespace ChatTwo.Http.MessageProtocol;
|
||||||
|
|
||||||
#region Outgoing SSE
|
#region Outgoing SSE
|
||||||
|
/// <summary>
|
||||||
|
/// Contains a valid tab with its assigned index
|
||||||
|
/// </summary>
|
||||||
|
public struct ChatTab(string name, int index)
|
||||||
|
{
|
||||||
|
[JsonProperty("name")] public string Name = name;
|
||||||
|
[JsonProperty("index")] public int Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Contains the current channel name
|
/// Contains the current channel name
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -13,7 +31,7 @@ public struct SwitchChannel((MessageTemplate[] ChannelName, bool Locked) channel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains one or multiple channels that are valid for the user to pick from
|
/// Contains a number of channels that are valid for the user to pick from
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct ChannelList(Dictionary<string, uint> channels)
|
public struct ChannelList(Dictionary<string, uint> channels)
|
||||||
{
|
{
|
||||||
@@ -50,7 +68,8 @@ public struct MessageTemplate()
|
|||||||
/// url = Simple url that should be clickable
|
/// url = Simple url that should be clickable
|
||||||
/// text = Simple text content of the message
|
/// text = Simple text content of the message
|
||||||
///
|
///
|
||||||
/// empty = Ignore
|
/// Note:
|
||||||
|
/// Empty is used for invalid payloads
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("payload")] public required string Payload;
|
[JsonProperty("payload")] public required string Payload;
|
||||||
|
|
||||||
@@ -60,14 +79,15 @@ public struct MessageTemplate()
|
|||||||
[JsonProperty("content")] public string Content = "";
|
[JsonProperty("content")] public string Content = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used for icon.
|
/// Used for an icon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("id")] public uint Id;
|
[JsonProperty("id")] public uint Id;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used for text and url
|
/// Used for text and url
|
||||||
///
|
///
|
||||||
/// Ignore if 0!
|
/// Note:
|
||||||
|
/// 0 is used for invalid colors
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("color")] public uint Color;
|
[JsonProperty("color")] public uint Color;
|
||||||
|
|
||||||
@@ -99,10 +119,18 @@ public struct IncomingMessage()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Channel must be a valid uint number
|
/// The channel type must be a valid <see cref="InputChannel"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct IncomingChannel()
|
public struct IncomingChannel()
|
||||||
{
|
{
|
||||||
[JsonProperty("channel")] public uint Channel = uint.MaxValue;
|
[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
|
#endregion
|
||||||
@@ -3,15 +3,20 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
namespace ChatTwo.Http.MessageProtocol;
|
||||||
|
|
||||||
|
// General
|
||||||
public class CloseEvent() : BaseEvent("close");
|
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));
|
||||||
|
|
||||||
|
// Input channel related
|
||||||
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
|
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
|
||||||
|
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("channel-switched", JsonConvert.SerializeObject(switchChannel));
|
||||||
|
|
||||||
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("switch-channel", JsonConvert.SerializeObject(switchChannel));
|
// Chat message related
|
||||||
|
|
||||||
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
|
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
|
||||||
|
public class NewMessageEvent(MessageResponse message) : BaseEvent("new-message", JsonConvert.SerializeObject(message));
|
||||||
public class NewMessageEvent(Messages messages) : BaseEvent("new-message", JsonConvert.SerializeObject(messages));
|
|
||||||
|
|
||||||
public class BaseEvent(string eventType, string? data = null)
|
public class BaseEvent(string eventType, string? data = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,14 +43,20 @@ public class Processing
|
|||||||
|
|
||||||
internal async Task PrepareNewClient(SSEConnection sse)
|
internal async Task PrepareNewClient(SSEConnection sse)
|
||||||
{
|
{
|
||||||
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
|
// This takes long, so keep it outside the next frame
|
||||||
var channels = await Plugin.Framework.RunOnTick(Plugin.ChatLogWindow.GetAvailableChannels);
|
var messages = await GetAllMessages();
|
||||||
var channel = await Plugin.Framework.RunOnTick(() => ReadChannelName(Plugin.ChatLogWindow.PreviousChannel));
|
|
||||||
|
|
||||||
// Using the bulk message event to clear everything on the client side that may still exist
|
// Using the bulk message event to clear everything on the client side that may still exist
|
||||||
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(messages)));
|
await Plugin.Framework.RunOnTick(() =>
|
||||||
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(new SwitchChannel(channel)));
|
{
|
||||||
sse.OutboundQueue.Enqueue(new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value))));
|
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(messages));
|
||||||
|
|
||||||
|
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(GetCurrentChannel()));
|
||||||
|
sse.OutboundQueue.Enqueue(new ChannelListEvent(GetValidChannels()));
|
||||||
|
|
||||||
|
sse.OutboundQueue.Enqueue(new ChatTabSwitchedEvent(GetCurrentTab()));
|
||||||
|
sse.OutboundQueue.Enqueue(new ChatTabListEvent(GetAllTabs()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageTemplate ProcessChunk(Chunk chunk)
|
private MessageTemplate ProcessChunk(Chunk chunk)
|
||||||
@@ -95,4 +101,33 @@ public class Processing
|
|||||||
|
|
||||||
return MessageTemplate.Empty;
|
return MessageTemplate.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Messages> GetAllMessages()
|
||||||
|
{
|
||||||
|
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
|
||||||
|
return new Messages(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SwitchChannel GetCurrentChannel()
|
||||||
|
{
|
||||||
|
var channel = ReadChannelName(Plugin.ChatLogWindow.PreviousChannel);
|
||||||
|
return new SwitchChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelList GetValidChannels()
|
||||||
|
{
|
||||||
|
var channels = Plugin.ChatLogWindow.GetValidChannels();
|
||||||
|
return new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatTab GetCurrentTab()
|
||||||
|
{
|
||||||
|
return new ChatTab(Plugin.CurrentTab.Name, Plugin.LastTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatTabList GetAllTabs()
|
||||||
|
{
|
||||||
|
var tabs = Plugin.Config.Tabs.Select((tab, idx) => new ChatTab(tab.Name, idx)).ToArray();
|
||||||
|
return new ChatTabList(tabs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using ChatTwo.Code;
|
using ChatTwo.Code;
|
||||||
using ChatTwo.Http.MessageProtocol;
|
using ChatTwo.Http.MessageProtocol;
|
||||||
@@ -31,8 +32,8 @@ public class RouteController
|
|||||||
Plugin = plugin;
|
Plugin = plugin;
|
||||||
Core = core;
|
Core = core;
|
||||||
|
|
||||||
AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "auth.html"));
|
AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "index.html"));
|
||||||
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "chat.html"));
|
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "chat.html"));
|
||||||
|
|
||||||
// Pre Auth
|
// Pre Auth
|
||||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
||||||
@@ -42,15 +43,19 @@ public class RouteController
|
|||||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
||||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
|
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
|
||||||
Core.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
Core.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||||
Core.Host.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
|
||||||
|
|
||||||
// Post Auth
|
// Post Auth
|
||||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
||||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
||||||
|
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/tab", ReceiveTabSwitch, ExceptionRoute);
|
||||||
|
|
||||||
|
// Ship all other static files dynamically
|
||||||
|
Core.Host.Routes.PreAuthentication.Content.Add("/_app/", true, ExceptionRoute);
|
||||||
|
Core.Host.Routes.PreAuthentication.Content.Add("/static/", true, ExceptionRoute);
|
||||||
|
|
||||||
// Server-Sent Events Route
|
// Server-Sent Events Route
|
||||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewSSEConnection, ExceptionRoute);
|
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/sse", NewSSEConnection, ExceptionRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
||||||
@@ -61,6 +66,16 @@ public class RouteController
|
|||||||
|
|
||||||
private async Task AuthRoute(HttpContextBase ctx)
|
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);
|
await ctx.Response.Send(AuthTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +148,7 @@ public class RouteController
|
|||||||
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
|
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
|
||||||
{
|
{
|
||||||
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
|
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
|
||||||
return await Redirect(ctx, "/", ("message", "Rate limit active."));
|
return await Redirect(ctx, "/", ("message", "Rate limit active (10s)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The next request will be rate limited for 10s
|
// The next request will be rate limited for 10s
|
||||||
@@ -141,10 +156,10 @@ public class RouteController
|
|||||||
|
|
||||||
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
|
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
|
||||||
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
|
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
|
||||||
return await Redirect(ctx, "/", ("message", "Authentication failed."));
|
return await Redirect(ctx, "/", ("message", "Authentication failed"));
|
||||||
|
|
||||||
var token = WebinterfaceUtil.GenerateSimpleToken();
|
var token = WebinterfaceUtil.GenerateSimpleToken();
|
||||||
Plugin.Config.SessionTokens.TryAdd(token, true);
|
Plugin.Config.AuthStore.Add(token);
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
||||||
return await Redirect(ctx, "/chat");
|
return await Redirect(ctx, "/chat");
|
||||||
@@ -159,12 +174,8 @@ public class RouteController
|
|||||||
|
|
||||||
private async Task ReceiveMessage(HttpContextBase ctx)
|
private async Task ReceiveMessage(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
if (ctx.Request.ContentType != "application/json")
|
if (!await EnforceMediaType(ctx, "application/json"))
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 415;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
|
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
|
||||||
if (content.Message.Length is < 2 or > 500)
|
if (content.Message.Length is < 2 or > 500)
|
||||||
@@ -186,28 +197,40 @@ public class RouteController
|
|||||||
|
|
||||||
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
|
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
if (ctx.Request.ContentType != "application/json")
|
if (!await EnforceMediaType(ctx, "application/json"))
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 415;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
|
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
|
||||||
if (!Enum.IsDefined(typeof(InputChannel), channel.Channel))
|
if (!Enum.IsDefined((InputChannel)channel.Channel))
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 400;
|
ctx.Response.StatusCode = 400;
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
|
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() =>
|
await Plugin.Framework.RunOnFrameworkThread(() => { Plugin.ChatLogWindow.SetChannel((InputChannel)channel.Channel); });
|
||||||
{
|
|
||||||
Plugin.ChatLogWindow.SetChannel((InputChannel)channel.Channel);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
ctx.Response.StatusCode = 201;
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch got initiated.")));
|
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(() => { 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)
|
private async Task NewSSEConnection(HttpContextBase ctx)
|
||||||
@@ -245,4 +268,24 @@ public class RouteController
|
|||||||
return await ctx.Response.Send();
|
return await ctx.Response.Send();
|
||||||
}
|
}
|
||||||
#endregion
|
#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
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ public class SSEConnection
|
|||||||
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
|
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), Token))
|
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), false, Token))
|
||||||
{
|
{
|
||||||
Plugin.Log.Information("SSE connection was unable to send new data");
|
Plugin.Log.Information("SSE connection was unable to send new data");
|
||||||
Plugin.Log.Information($"Client disconnected: {ctx.Guid}");
|
Plugin.Log.Information($"Client disconnected: {ctx.Guid}");
|
||||||
@@ -53,7 +53,7 @@ public class SSEConnection
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
||||||
await ctx.Response.SendFinalChunk(new CloseEvent().Build());
|
await ctx.Response.SendChunk(new CloseEvent().Build(), true, Token);
|
||||||
|
|
||||||
// Manually confirm that we have finished our connection, even if the final response failed
|
// 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
|
// This can happen if the client disconnects before the server does
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class ServerCore : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
Plugin.Framework.RunOnTick(() =>
|
Plugin.Framework.RunOnTick(() =>
|
||||||
{
|
{
|
||||||
var bundledResponse = new NewMessageEvent(new Messages([HostContext.Processing.ReadMessageContent(message)]));
|
var bundledResponse = new NewMessageEvent(HostContext.Processing.ReadMessageContent(message));
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
foreach (var eventServer in HostContext.EventConnections)
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||||
});
|
});
|
||||||
@@ -82,7 +82,7 @@ public class ServerCore : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
Plugin.Framework.RunOnTick(() =>
|
Plugin.Framework.RunOnTick(() =>
|
||||||
{
|
{
|
||||||
var channels = Plugin.ChatLogWindow.GetAvailableChannels();
|
var channels = Plugin.ChatLogWindow.GetValidChannels();
|
||||||
var bundledResponse = new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value)));
|
var bundledResponse = new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value)));
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
foreach (var eventServer in HostContext.EventConnections)
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||||
@@ -119,7 +119,7 @@ public class ServerCore : IAsyncDisposable
|
|||||||
if (!HostContext.IsActive)
|
if (!HostContext.IsActive)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Plugin.Config.SessionTokens.Clear();
|
Plugin.Config.AuthStore.Clear();
|
||||||
Plugin.SaveConfig();
|
Plugin.SaveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
/* fonts */
|
/* fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Lodestone;
|
font-family: Lodestone;
|
||||||
|
/*noinspection CssUnknownTarget*/
|
||||||
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
||||||
unicode-range: U+E020-E0DB;
|
unicode-range: U+E020-E0DB;
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ body > main.auth {
|
|||||||
|
|
||||||
input { width: 150px; }
|
input { width: 150px; }
|
||||||
|
|
||||||
input, button {
|
input, .submitButton {
|
||||||
padding: 5px 20px;
|
padding: 5px 20px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 3px solid transparent;
|
border: 3px solid transparent;
|
||||||
@@ -94,7 +95,7 @@ body > main.auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.submitButton {
|
||||||
padding: 5px 15px;
|
padding: 5px 15px;
|
||||||
border: 3px solid var(--bg-input);
|
border: 3px solid var(--bg-input);
|
||||||
background-image: var(--gradient-clickable);
|
background-image: var(--gradient-clickable);
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<title>Authentication</title>
|
<title>Authentication</title>
|
||||||
|
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<link href="static/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="static//bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="static/start.css">
|
<link rel="stylesheet" href="static/start.css">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function appendAlert(message, type) {
|
||||||
|
const alertPlaceholder = document.getElementById('alert-placeholder')
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.innerHTML = [
|
||||||
|
`<div class="alert alert-${type} alert-dismissible" role="alert">`,
|
||||||
|
` <div>${message}</div>`,
|
||||||
|
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
alertPlaceholder.append(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const params = new Proxy(new URLSearchParams(window.location.search), {
|
||||||
|
get: (searchParams, prop) => searchParams.get(prop),
|
||||||
|
});
|
||||||
|
|
||||||
|
let value = params.message;
|
||||||
|
if (value) {
|
||||||
|
appendAlert(value, "warning")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main class="auth">
|
<main class="auth">
|
||||||
<h1>Authcode</h1>
|
<h1>Authcode</h1>
|
||||||
|
<div id="alert-placeholder"></div>
|
||||||
<form action="/auth" method="POST">
|
<form action="/auth" method="POST">
|
||||||
<label><input type="password" name="authcode"></label>
|
<label><input type="password" name="authcode"></label>
|
||||||
<button type="submit">Submit</button>
|
<button type="submit" class="submitButton">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
<img src="/emote/Sure">
|
<img src="/emote/Sure">
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
internal DateTime GameStarted { get; }
|
internal DateTime GameStarted { get; }
|
||||||
|
|
||||||
// Tab managed needs to happen outside the chatlog window class for access reasons
|
// Tab management needs to happen outside the chatlog window class for access reasons
|
||||||
internal int LastTab { get; set; }
|
internal int LastTab { get; set; }
|
||||||
internal int? WantedTab { get; set; }
|
internal int? WantedTab { get; set; }
|
||||||
internal Tab CurrentTab
|
internal Tab CurrentTab
|
||||||
|
|||||||
+14
-14
@@ -365,9 +365,8 @@ public sealed class ChatLogWindow : Window
|
|||||||
ChangeTab(newIndex);
|
ChangeTab(newIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TabChannelSwitch(Tab newTab, Tab previousTab)
|
private void TabSwitched(Tab newTab, Tab previousTab)
|
||||||
{
|
{
|
||||||
Plugin.Log.Information("Channel switch");
|
|
||||||
// Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
|
// Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
|
||||||
if (newTab.Channel is not null)
|
if (newTab.Channel is not null)
|
||||||
newTab.CurrentChannel.Channel = newTab.Channel.Value;
|
newTab.CurrentChannel.Channel = newTab.Channel.Value;
|
||||||
@@ -566,7 +565,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
{
|
{
|
||||||
if (popup)
|
if (popup)
|
||||||
{
|
{
|
||||||
var channels = GetAvailableChannels();
|
var channels = GetValidChannels();
|
||||||
foreach (var (name, channel) in channels)
|
foreach (var (name, channel) in channels)
|
||||||
if (ImGui.Selectable(name))
|
if (ImGui.Selectable(name))
|
||||||
SetChannel(channel);
|
SetChannel(channel);
|
||||||
@@ -713,7 +712,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
GameFunctions.GameFunctions.ClickNoviceNetworkButton();
|
GameFunctions.GameFunctions.ClickNoviceNetworkButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal Dictionary<string, InputChannel> GetAvailableChannels()
|
internal Dictionary<string, InputChannel> GetValidChannels()
|
||||||
{
|
{
|
||||||
var channels = new Dictionary<string, InputChannel>();
|
var channels = new Dictionary<string, InputChannel>();
|
||||||
foreach (var channel in Enum.GetValues<InputChannel>())
|
foreach (var channel in Enum.GetValues<InputChannel>())
|
||||||
@@ -1233,20 +1232,21 @@ public sealed class ChatLogWindow : Window
|
|||||||
var flags = ImGuiTabItemFlags.None;
|
var flags = ImGuiTabItemFlags.None;
|
||||||
if (Plugin.WantedTab == tabI)
|
if (Plugin.WantedTab == tabI)
|
||||||
flags |= ImGuiTabItemFlags.SetSelected;
|
flags |= ImGuiTabItemFlags.SetSelected;
|
||||||
|
|
||||||
using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags);
|
using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags);
|
||||||
DrawTabContextMenu(tab, tabI);
|
DrawTabContextMenu(tab, tabI);
|
||||||
|
|
||||||
if (!tabItem.Success)
|
if (!tabItem.Success)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var switchedTab = Plugin.LastTab != tabI;
|
var hasTabSwitched = Plugin.LastTab != tabI;
|
||||||
|
|
||||||
Plugin.LastTab = tabI;
|
Plugin.LastTab = tabI;
|
||||||
if (switchedTab)
|
|
||||||
TabChannelSwitch(tab, previousTab);
|
if (hasTabSwitched)
|
||||||
|
TabSwitched(tab, previousTab);
|
||||||
|
|
||||||
tab.Unread = 0;
|
tab.Unread = 0;
|
||||||
DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), switchedTab);
|
DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), hasTabSwitched);
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.WantedTab = null;
|
Plugin.WantedTab = null;
|
||||||
@@ -1264,7 +1264,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
|
|
||||||
ImGui.TableNextColumn();
|
ImGui.TableNextColumn();
|
||||||
|
|
||||||
var switchedTab = false;
|
var hasTabSwitched = false;
|
||||||
var childHeight = GetRemainingHeightForMessageLog();
|
var childHeight = GetRemainingHeightForMessageLog();
|
||||||
using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
|
using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
|
||||||
{
|
{
|
||||||
@@ -1285,10 +1285,10 @@ public sealed class ChatLogWindow : Window
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
currentTab = tabI;
|
currentTab = tabI;
|
||||||
switchedTab = Plugin.LastTab != tabI;
|
hasTabSwitched = Plugin.LastTab != tabI;
|
||||||
Plugin.LastTab = tabI;
|
Plugin.LastTab = tabI;
|
||||||
if (switchedTab)
|
if (hasTabSwitched)
|
||||||
TabChannelSwitch(tab, previousTab);
|
TabSwitched(tab, previousTab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1302,7 +1302,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentTab > -1)
|
if (currentTab > -1)
|
||||||
DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, switchedTab);
|
DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, hasTabSwitched);
|
||||||
|
|
||||||
Plugin.WantedTab = null;
|
Plugin.WantedTab = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-24
@@ -4,9 +4,9 @@
|
|||||||
"net9.0-windows7.0": {
|
"net9.0-windows7.0": {
|
||||||
"DalamudPackager": {
|
"DalamudPackager": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[13.0.0, )",
|
"requested": "[13.1.0, )",
|
||||||
"resolved": "13.0.0",
|
"resolved": "13.1.0",
|
||||||
"contentHash": "Mb3cUDSK/vDPQ8gQIeuCw03EMYrej1B4J44a1AvIJ9C759p9XeqdU9Hg4WgOmlnlPe0G7ILTD32PKSUpkQNa8w=="
|
"contentHash": "XdoNhJGyFby5M/sdcRhnc5xTop9PHy+H50PTWpzLhJugjB19EDBiHD/AsiDF66RETM+0qKUdJBZrNuebn7qswQ=="
|
||||||
},
|
},
|
||||||
"DotNet.ReproducibleBuilds": {
|
"DotNet.ReproducibleBuilds": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
@@ -50,23 +50,23 @@
|
|||||||
},
|
},
|
||||||
"Watson.Lite": {
|
"Watson.Lite": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.2.3, )",
|
"requested": "[6.3.13, )",
|
||||||
"resolved": "6.2.3",
|
"resolved": "6.3.13",
|
||||||
"contentHash": "NGVWwQTulT016tiSuwK/AUU+Ebgyq0r0xLaWxll29xv/LTV0w4pHtoPskUPRqnzV8Po+ZndCd5dsUzGCjP0BVA==",
|
"contentHash": "AoDQXFPTf/C38Afj4A1p+Siw46sqgr6SOZV6tL2gWmfoAAuiCJ3DymCD9OcY2ZasvvDOQk2NDNSLyXthkS5xhg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"CavemanTcp": "2.0.3",
|
"CavemanTcp": "2.0.9",
|
||||||
"Watson.Core": "6.2.3"
|
"Watson.Core": "6.3.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"CavemanTcp": {
|
"CavemanTcp": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.0.3",
|
"resolved": "2.0.9",
|
||||||
"contentHash": "/feSFO+5A1tS69Vv9XgD8+Ahz9iRcPj8r1kXIIVxeL+VjUzpPpVoAOtNG70oyu+b8M1RqthZAszaVQHpSPzuwg=="
|
"contentHash": "KgIwYhPhGkBTm+wwVAmWonkKPw4xYVnutzzlIeqOLcX1fti+8d+MEGTvbern1smf3S/UpjFjihkf6XRziTddzQ=="
|
||||||
},
|
},
|
||||||
"IpMatcher": {
|
"IpMatcher": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "1.0.4.4",
|
"resolved": "1.0.5",
|
||||||
"contentHash": "93zP6KaDdRttUI3fFet6mYQGed1gTVK2amIOxpq4tDl3NRY+2tP/iA30hvRrs+ZY8u8KGF/J7kX6l7jrhKHfZA=="
|
"contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
|
||||||
},
|
},
|
||||||
"MessagePack.Annotations": {
|
"MessagePack.Annotations": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
@@ -93,8 +93,8 @@
|
|||||||
},
|
},
|
||||||
"RegexMatcher": {
|
"RegexMatcher": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "1.0.8",
|
"resolved": "1.0.9",
|
||||||
"contentHash": "h3wa6sKJnyojCH2wJpvHdQRGWn+eKiSYGKPInIzvdl+SaKhUN9Qpr0CK4A3aqrYFBTEJKFN7pzYlNR4S5gZnaw=="
|
"contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
|
||||||
},
|
},
|
||||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
@@ -138,24 +138,24 @@
|
|||||||
},
|
},
|
||||||
"Timestamps": {
|
"Timestamps": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "1.0.9",
|
"resolved": "1.0.11",
|
||||||
"contentHash": "xdCVp+T4VFXOT+Ube2Cz527fnFXFUxQK24uPMGcCmICIpIcGCXPtI7vKLnWsJ6Nfc1B92tNBK9e/I8NDq2ee6g=="
|
"contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
|
||||||
},
|
},
|
||||||
"UrlMatcher": {
|
"UrlMatcher": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "3.0.0",
|
"resolved": "3.0.1",
|
||||||
"contentHash": "IavFDKxhcg5ehi3baGEejxnZoj4bqfkDnoMxTV+6Y4WHVxW8wgxTzdSl9q5WSMi366LG0amDfOxaUJxuHawNLw=="
|
"contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
|
||||||
},
|
},
|
||||||
"Watson.Core": {
|
"Watson.Core": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.2.3",
|
"resolved": "6.3.13",
|
||||||
"contentHash": "ccdGhC60dHTV1CA+C+U8zZOTQD4yLoPUDV0ZyXqF4dYmQ/zgwhegAxBGkV3WIw+2fqnTKYwMhJFU/tbkykybvA==",
|
"contentHash": "f1qVBxEOYvRGNVfqoQDBuRK5lMsfYRpsWxv9qRiGH22eRtXvvaloQbxeouVtd1X2oiamdICPNAAhBVj/KYZ9Mw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"IpMatcher": "1.0.4.4",
|
"IpMatcher": "1.0.5",
|
||||||
"RegexMatcher": "1.0.8",
|
"RegexMatcher": "1.0.9",
|
||||||
"System.Text.Json": "8.0.5",
|
"System.Text.Json": "8.0.5",
|
||||||
"Timestamps": "1.0.9",
|
"Timestamps": "1.0.11",
|
||||||
"UrlMatcher": "3.0.0"
|
"UrlMatcher": "3.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user