Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 705c7d3116 | |||
| bf5d03c7ea | |||
| 960ce980d3 | |||
| c09aa26ffc | |||
| c2801c4113 | |||
| 7bacd1aaba | |||
| 23e0f37dfb | |||
| 96fa05dc9b | |||
| d891ec5e50 | |||
| e219b3e1fe | |||
| 135f7a9bf7 | |||
| 81d3c9ca6b | |||
| cb90c6ab93 | |||
| 2ad81cc3ef |
+11
-23
@@ -4,7 +4,7 @@
|
|||||||
0.1.0 is our bootstrap release; the underlying Chat 2 base is
|
0.1.0 is our bootstrap release; the underlying Chat 2 base is
|
||||||
called out in the yaml changelog so users can see what it
|
called out in the yaml changelog so users can see what it
|
||||||
derives from. -->
|
derives from. -->
|
||||||
<Version>0.1.0</Version>
|
<Version>0.2.0</Version>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<!-- HellionChat fork: assembly is renamed so Dalamud uses
|
<!-- HellionChat fork: assembly is renamed so Dalamud uses
|
||||||
pluginConfigs/HellionChat instead of pluginConfigs/ChatTwo,
|
pluginConfigs/HellionChat instead of pluginConfigs/ChatTwo,
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
<PackageReference Include="morelinq" Version="4.4.0" />
|
<PackageReference Include="morelinq" Version="4.4.0" />
|
||||||
<PackageReference Include="Pidgin" Version="3.3.0" />
|
<PackageReference Include="Pidgin" Version="3.3.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
<PackageReference Include="Watson.Lite" Version="6.3.9" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -62,26 +61,15 @@
|
|||||||
<Folder Include="images\" />
|
<Folder Include="images\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--This doesn't work until Plogon is updated to include NodeJS-->
|
<!-- Copy images/icon.png next to the built DLL so Dalamud's local
|
||||||
<!-- <Target Name="NodeJS Compile" BeforeTargets="BeforeCompile">-->
|
plugin loader finds it at <plugindir>/images/icon.png. The
|
||||||
<!-- <Exec Command="npm install" WorkingDirectory="Http\Frontend"/>-->
|
DalamudPackager.targets file in this directory then includes
|
||||||
<!-- <Exec Command="npm run build" WorkingDirectory="Http\Frontend"/>-->
|
the same path inside the release ZIP — see that file for the
|
||||||
<!-- </Target>-->
|
full packaging override. -->
|
||||||
<!-- -->
|
<ItemGroup>
|
||||||
<!-- <Target Name="CopyFiles" AfterTargets="Build">-->
|
<None Include="images\icon.png">
|
||||||
<!-- <ItemGroup>-->
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<!-- <Files Include="$(MSBuildThisFileDirectory)\Http\Frontend\build\**" />-->
|
</None>
|
||||||
<!-- </ItemGroup>-->
|
</ItemGroup>
|
||||||
<!-- -->
|
|
||||||
<!-- <Copy SourceFiles="@(Files)" DestinationFolder="$(TargetDir)\Frontend\%(RecursiveDir)" />-->
|
|
||||||
<!-- </Target>-->
|
|
||||||
|
|
||||||
<!-- <Target Name="NodeJS Compile" BeforeTargets="BeforeCompile" Condition="'$(Configuration)' == 'Debug'">-->
|
|
||||||
<!-- <Exec Command="npm install" WorkingDirectory="Http\Frontend"/>-->
|
|
||||||
<!-- <Exec Command="npm run build" WorkingDirectory="Http\Frontend"/>-->
|
|
||||||
<!-- </Target>-->
|
|
||||||
|
|
||||||
<Target Name="UnzipBuild" AfterTargets="Build">
|
|
||||||
<Unzip SourceFiles="websiteBuild.zip" DestinationFolder="$(TargetDir)\Frontend"/>
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class ConfigKeyBind
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public class Configuration : IPluginConfiguration
|
public class Configuration : IPluginConfiguration
|
||||||
{
|
{
|
||||||
private const int LatestVersion = 7;
|
private const int LatestVersion = 8;
|
||||||
|
|
||||||
public int Version { get; set; } = LatestVersion;
|
public int Version { get; set; } = LatestVersion;
|
||||||
|
|
||||||
@@ -171,14 +171,6 @@ public class Configuration : IPluginConfiguration
|
|||||||
public ConfigKeyBind? ChatTabForward;
|
public ConfigKeyBind? ChatTabForward;
|
||||||
public ConfigKeyBind? ChatTabBackward;
|
public ConfigKeyBind? ChatTabBackward;
|
||||||
|
|
||||||
// Webinterface
|
|
||||||
public bool WebinterfaceEnabled;
|
|
||||||
public bool WebinterfaceAutoStart;
|
|
||||||
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
|
||||||
public int WebinterfacePort = 9000;
|
|
||||||
public HashSet<string> AuthStore = [];
|
|
||||||
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
|
|
||||||
|
|
||||||
public void UpdateFrom(Configuration other, bool backToOriginal)
|
public void UpdateFrom(Configuration other, bool backToOriginal)
|
||||||
{
|
{
|
||||||
if (backToOriginal)
|
if (backToOriginal)
|
||||||
@@ -243,11 +235,6 @@ public class Configuration : IPluginConfiguration
|
|||||||
ChosenStyle = other.ChosenStyle;
|
ChosenStyle = other.ChosenStyle;
|
||||||
ChatTabForward = other.ChatTabForward;
|
ChatTabForward = other.ChatTabForward;
|
||||||
ChatTabBackward = other.ChatTabBackward;
|
ChatTabBackward = other.ChatTabBackward;
|
||||||
WebinterfaceEnabled = other.WebinterfaceEnabled;
|
|
||||||
WebinterfaceAutoStart = other.WebinterfaceAutoStart;
|
|
||||||
WebinterfacePassword = other.WebinterfacePassword;
|
|
||||||
WebinterfacePort = other.WebinterfacePort;
|
|
||||||
WebinterfaceMaxLinesToSend = other.WebinterfaceMaxLinesToSend;
|
|
||||||
|
|
||||||
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
||||||
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
HellionChat — DalamudPackager override.
|
||||||
|
|
||||||
|
The default DalamudPackager.targets shipped by the SDK does not set
|
||||||
|
HandleImages / ImagesPath, so the images/ directory is silently
|
||||||
|
excluded from the release ZIP. The presence of this file at
|
||||||
|
$(ProjectDir)DalamudPackager.targets disables the SDK's default
|
||||||
|
target (it guards on `!Exists('$(PackagerTargetFile)')`) and lets
|
||||||
|
us call the packager task ourselves with the image fields wired in.
|
||||||
|
|
||||||
|
Apart from HandleImages + ImagesPath the property list mirrors the
|
||||||
|
SDK default verbatim so we don't lose any other manifest field as
|
||||||
|
the upstream SDK evolves.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<Target Name="HellionDalamudPackagerDebug"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Condition="'$(Configuration)' == 'Debug'">
|
||||||
|
<DalamudPackager ProjectDir="$(ProjectDir)"
|
||||||
|
OutputPath="$(OutputPath)"
|
||||||
|
AssemblyName="$(AssemblyName)"
|
||||||
|
MakeZip="false"
|
||||||
|
Author="$(Author)"
|
||||||
|
Name="$(Name)"
|
||||||
|
MinimumDalamudVersion="$(MinimumDalamudVersion)"
|
||||||
|
Punchline="$(Punchline)"
|
||||||
|
Description="$(Description)"
|
||||||
|
ApplicableVersion="$(ApplicableVersion)"
|
||||||
|
RepoUrl="$(RepoUrl)"
|
||||||
|
Tags="$(Tags)"
|
||||||
|
CategoryTags="$(CategoryTags)"
|
||||||
|
DalamudApiLevel="$(DalamudApiLevel)"
|
||||||
|
LoadRequiredState="$(LoadRequiredState)"
|
||||||
|
LoadSync="$(LoadSync)"
|
||||||
|
CanUnloadAsync="$(CanUnloadAsync)"
|
||||||
|
LoadPriority="$(LoadPriority)"
|
||||||
|
ImageUrls="$(ImageUrls)"
|
||||||
|
IconUrl="$(IconUrl)"
|
||||||
|
Changelog="$(Changelog)"
|
||||||
|
AcceptsFeedback="$(AcceptsFeedback)"
|
||||||
|
FeedbackMessage="$(FeedbackMessage)"
|
||||||
|
HandleImages="true"
|
||||||
|
ImagesPath="$(ProjectDir)images" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="HellionDalamudPackagerRelease"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Condition="'$(Configuration)' == 'Release'">
|
||||||
|
<DalamudPackager ProjectDir="$(ProjectDir)"
|
||||||
|
OutputPath="$(OutputPath)"
|
||||||
|
AssemblyName="$(AssemblyName)"
|
||||||
|
MakeZip="true"
|
||||||
|
Author="$(Author)"
|
||||||
|
Name="$(Name)"
|
||||||
|
MinimumDalamudVersion="$(MinimumDalamudVersion)"
|
||||||
|
Punchline="$(Punchline)"
|
||||||
|
Description="$(Description)"
|
||||||
|
ApplicableVersion="$(ApplicableVersion)"
|
||||||
|
RepoUrl="$(RepoUrl)"
|
||||||
|
Tags="$(Tags)"
|
||||||
|
CategoryTags="$(CategoryTags)"
|
||||||
|
DalamudApiLevel="$(DalamudApiLevel)"
|
||||||
|
LoadRequiredState="$(LoadRequiredState)"
|
||||||
|
LoadSync="$(LoadSync)"
|
||||||
|
CanUnloadAsync="$(CanUnloadAsync)"
|
||||||
|
LoadPriority="$(LoadPriority)"
|
||||||
|
ImageUrls="$(ImageUrls)"
|
||||||
|
IconUrl="$(IconUrl)"
|
||||||
|
Changelog="$(Changelog)"
|
||||||
|
AcceptsFeedback="$(AcceptsFeedback)"
|
||||||
|
FeedbackMessage="$(FeedbackMessage)"
|
||||||
|
HandleImages="true"
|
||||||
|
ImagesPath="$(ProjectDir)images" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -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)
|
||||||
|
|||||||
+99
-16
@@ -1,16 +1,34 @@
|
|||||||
name: Hellion Chat
|
name: Hellion Chat
|
||||||
author: JonKazama-Hellion
|
author: JonKazama-Hellion
|
||||||
punchline: GDPR-compliant, Linux-aware fork of Chat 2
|
punchline: Chat 2 with privacy controls aligned to EU, US and JP rules
|
||||||
description: |-
|
description: |-
|
||||||
Hellion Chat is a privacy-focused, Linux-aware fork of Chat 2.
|
Hellion Chat is built on top of Chat 2 with one removal and a stack
|
||||||
|
of privacy controls on top. The /chat2 command, tabs, channel
|
||||||
|
filters, RGB colours, emotes, screenshot mode, IPC integration and
|
||||||
|
the chat replacement window itself work the same. The optional
|
||||||
|
webinterface that Chat 2 ships is intentionally not part of this
|
||||||
|
fork because it could not be hardened to the privacy guarantees
|
||||||
|
Hellion Chat makes by default.
|
||||||
|
|
||||||
Same chat replacement you know from upstream, with extra controls
|
On top of that, Hellion Chat adds privacy and data-handling controls
|
||||||
for what actually gets stored:
|
designed to align with the modern data protection rules that apply
|
||||||
|
across the EU, the United States and Japan. By default only your own
|
||||||
|
conversations are stored; messages from strangers, NPCs and system
|
||||||
|
spam stay out of the database. Retention windows are configurable per
|
||||||
|
channel, history can be wiped retroactively, and stored data can be
|
||||||
|
exported on demand.
|
||||||
|
|
||||||
- Channel whitelist for database persistence (GDPR Art. 25)
|
Key additions on top of Chat 2:
|
||||||
- Privacy-First defaults: only your own conversations are kept
|
|
||||||
- Failsafe for unknown ChatTypes (default OFF)
|
- Channel whitelist with a Privacy-First default
|
||||||
- Independent plugin state (own config + database directory)
|
- Per-channel retention with a daily background sweep
|
||||||
|
- Retroactive cleanup with a Ctrl+Shift confirm
|
||||||
|
- Export to Markdown, JSON or CSV
|
||||||
|
- First-run wizard with three preset profiles (Privacy-First, Casual,
|
||||||
|
Full History)
|
||||||
|
- Bilingual UI (English and German) with live language switching
|
||||||
|
- Independent plugin state — own config file and database directory,
|
||||||
|
so Hellion Chat does not share state with the upstream plugin
|
||||||
|
|
||||||
Based on Chat 2 by Infi and Anna, licensed under EUPL-1.2.
|
Based on Chat 2 by Infi and Anna, licensed under EUPL-1.2.
|
||||||
repo_url: https://github.com/JonKazama-Hellion/HellionChat
|
repo_url: https://github.com/JonKazama-Hellion/HellionChat
|
||||||
@@ -22,30 +40,95 @@ tags:
|
|||||||
- Replacement
|
- Replacement
|
||||||
- Privacy
|
- Privacy
|
||||||
changelog: |-
|
changelog: |-
|
||||||
|
**Hellion Chat 0.2.0 — Webinterface removed**
|
||||||
|
|
||||||
|
Following an internal security and consistency audit the upstream
|
||||||
|
webinterface has been removed in its entirety. Hardening it to the
|
||||||
|
privacy guarantees Hellion Chat makes by default would have meant
|
||||||
|
rewriting the auth flow (the upstream code uses a five-digit
|
||||||
|
numeric code from System.Random), changing the default bind address
|
||||||
|
(currently every interface), reworking cookie handling and adding
|
||||||
|
the privacy filter to the live message stream that the webinterface
|
||||||
|
was broadcasting around it. The cumulative cost did not match the
|
||||||
|
niche use case for a fork that wants less network surface, not more.
|
||||||
|
|
||||||
|
What changed in this release:
|
||||||
|
|
||||||
|
- Settings tab "Webinterface" is gone, the corresponding
|
||||||
|
Configuration fields (WebinterfaceEnabled / AutoStart / Password /
|
||||||
|
Port / AuthStore / MaxLinesToSend) are dropped and stale entries
|
||||||
|
fall out of the JSON on the next save automatically
|
||||||
|
- The whole ChatTwo/Http tree, the bundled Svelte frontend in
|
||||||
|
websiteBuild.zip and the WebinterfaceUtil helper are deleted
|
||||||
|
- Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by
|
||||||
|
the webinterface JSON wire format) are removed from the
|
||||||
|
package references
|
||||||
|
- DbViewer's "Chat2 JSON Export" button is dropped because it
|
||||||
|
serialised the database into the webinterface message protocol;
|
||||||
|
the Privacy tab's MessageExporter (Markdown, JSON, CSV with
|
||||||
|
channel and date filters) covers the same ground without the
|
||||||
|
proprietary shape
|
||||||
|
- About tab notes the absence so users coming from Chat 2 do not
|
||||||
|
look for it
|
||||||
|
- Configuration version bumps from 7 to 8 with a one-shot
|
||||||
|
notification (EN + DE)
|
||||||
|
|
||||||
|
No changes to the privacy filter, retention sweep, first-run wizard
|
||||||
|
or export pipeline. Existing chat history is preserved.
|
||||||
|
|
||||||
|
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||||
|
|
||||||
|
**Hellion Chat 0.1.2 — About tab rebrand, DBViewer polish**
|
||||||
|
|
||||||
|
- About tab now shows Hellion-specific maintainer, license, EU/US/JP
|
||||||
|
disclaimer and SQUARE ENIX disclaimer instead of the inherited
|
||||||
|
Chat 2 contact info; original ChatTwo translator credits stay
|
||||||
|
visible under a clearly labelled upstream tree node
|
||||||
|
- Localization clarified: Hellion-specific German strings are
|
||||||
|
maintained by the fork maintainer, the Crowdin contributor list
|
||||||
|
only covers the inherited upstream strings
|
||||||
|
- Cherry-picked DBViewer UI improvements from upstream Chat 2
|
||||||
|
(auto-scroll-reset on page change, tooltips on date reset,
|
||||||
|
folder export, page arrows, localized export-running messages)
|
||||||
|
- README rewritten in the Hellion project style with a tech-stack
|
||||||
|
table, architecture tree, database column list, install guide,
|
||||||
|
upstream-sync workflow notes and project-status checklist
|
||||||
|
|
||||||
|
**Hellion Chat 0.1.1 — Packaging and migration fixes**
|
||||||
|
|
||||||
|
- Plugin icon now ships inside the bundle, so the Hellion logo
|
||||||
|
renders locally in the Dalamud plugin list once installed (the
|
||||||
|
previous release relied only on the remote IconUrl)
|
||||||
|
- Plugin icon downsampled from 1024×1024 to 256×256 to match the
|
||||||
|
rendered size; loads faster and caches better
|
||||||
|
- Migration from upstream Chat 2 is more robust: each file move is
|
||||||
|
wrapped individually, a locked SQLite database no longer aborts
|
||||||
|
the rest of the migration, and a warning notification fires when
|
||||||
|
any file is held open (with a hint to disable Chat 2 and restart
|
||||||
|
the game)
|
||||||
|
- README ships a step-by-step migration guide (fresh install versus
|
||||||
|
coming from Chat 2) and a troubleshooting section with manual
|
||||||
|
recovery commands for Linux and Windows
|
||||||
|
|
||||||
**Hellion Chat 0.1.0 — Initial fork release**
|
**Hellion Chat 0.1.0 — Initial fork release**
|
||||||
|
|
||||||
Privacy
|
Privacy
|
||||||
- Channel whitelist filter in MessageStore.UpsertMessage with a
|
- Channel whitelist filter in MessageStore.UpsertMessage with a
|
||||||
Privacy-First default (own conversations only)
|
Privacy-First default (own conversations only)
|
||||||
- Per-channel retention with a 24-hour idempotent background sweep
|
- Per-channel retention with a 24-hour idempotent background sweep
|
||||||
(default OFF; spec defaults of 365 / 90 / 30 days)
|
|
||||||
- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM
|
- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM
|
||||||
- Export to Markdown / JSON / CSV via Dalamud's file dialog
|
- Export to Markdown / JSON / CSV via Dalamud's file dialog
|
||||||
(GDPR Art. 15 right of access)
|
|
||||||
|
|
||||||
Onboarding
|
Onboarding
|
||||||
- First-run wizard with three profiles: Privacy-First / Casual /
|
- First-run wizard with three profiles: Privacy-First / Casual /
|
||||||
Full History
|
Full History
|
||||||
- Configuration v6 → v7 migration that seeds defaults and shows a
|
- Configuration migration that seeds defaults on update
|
||||||
notification once on update
|
|
||||||
- One-shot migration from upstream Chat 2's pluginConfigs layout
|
- One-shot migration from upstream Chat 2's pluginConfigs layout
|
||||||
so the fork uses pluginConfigs/HellionChat without losing state
|
|
||||||
- Migrate3 idempotency recovery for half-migrated databases
|
- Migrate3 idempotency recovery for half-migrated databases
|
||||||
|
|
||||||
Look & feel
|
Look & feel
|
||||||
- Localized UI (English and German) with live language switching
|
- Localized UI (English and German) with live language switching
|
||||||
- Hellion industrial HUD theme with cyan-teal action accents,
|
- Industrial HUD theme with cyan-teal action accents, slate-violet
|
||||||
slate-violet tabs, amber active highlights and a window-opacity
|
tabs, amber active highlights and a window-opacity slider
|
||||||
slider for combat-friendly transparency
|
|
||||||
|
|
||||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+96
-47
@@ -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; }
|
||||||
@@ -142,6 +139,25 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hellion Chat v7→v8: webinterface removed in 0.2.0. Old config
|
||||||
|
// entries (WebinterfacePassword, AuthStore, etc.) get dropped on
|
||||||
|
// the next save because their properties no longer exist on the
|
||||||
|
// Configuration class. The bump is recorded so the notification
|
||||||
|
// only fires once.
|
||||||
|
if (Config.Version <= 7)
|
||||||
|
{
|
||||||
|
Config.Version = 8;
|
||||||
|
SaveConfig();
|
||||||
|
|
||||||
|
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
|
||||||
|
{
|
||||||
|
Title = HellionStrings.Migration_Webinterface_Removed_Title,
|
||||||
|
Content = HellionStrings.Migration_Webinterface_Removed_Content,
|
||||||
|
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info,
|
||||||
|
InitialDuration = TimeSpan.FromSeconds(20),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (Config.Tabs.Count == 0)
|
if (Config.Tabs.Count == 0)
|
||||||
Config.Tabs.Add(TabsUtil.VanillaGeneral);
|
Config.Tabs.Add(TabsUtil.VanillaGeneral);
|
||||||
|
|
||||||
@@ -150,9 +166,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 +232,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,66 +270,112 @@ 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()
|
||||||
{
|
{
|
||||||
|
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
||||||
|
if (pluginConfigsDir is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
|
||||||
|
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
|
||||||
|
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
|
||||||
|
var ourConfigDir = Interface.ConfigDirectory.FullName;
|
||||||
|
|
||||||
|
// Track whether anything legitimately blocked us. The most common
|
||||||
|
// cause is upstream Chat 2 still being loaded — its SQLite handle
|
||||||
|
// keeps chat-sqlite.db locked and File.Move throws IOException.
|
||||||
|
var lockedBlocker = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
|
||||||
if (pluginConfigsDir is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
|
|
||||||
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
|
|
||||||
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
|
|
||||||
var ourConfigDir = Interface.ConfigDirectory.FullName;
|
|
||||||
|
|
||||||
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
|
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
|
||||||
{
|
{
|
||||||
File.Move(legacyConfigFile, ourConfigFile);
|
File.Move(legacyConfigFile, ourConfigFile);
|
||||||
Log.Information($"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}");
|
Log.Information($"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (IOException e)
|
||||||
|
{
|
||||||
|
Log.Warning(e, $"HellionChat: config file move blocked, leaving {legacyConfigFile} in place");
|
||||||
|
lockedBlocker = true;
|
||||||
|
}
|
||||||
|
|
||||||
// The plugin's ConfigDirectory may already exist on first load
|
// The plugin's ConfigDirectory may already exist on first load
|
||||||
// (Dalamud creates it), so check at the file level instead of
|
// (Dalamud creates it), so check at the file level instead of
|
||||||
// skipping when the directory is present. Move every legacy
|
// skipping when the directory is present. Move every legacy
|
||||||
// entry whose target name is not occupied yet, then remove the
|
// entry whose target name is not occupied yet, then remove the
|
||||||
// source dir if it ends up empty.
|
// source dir if it ends up empty. Each move is wrapped on its
|
||||||
if (Directory.Exists(legacyConfigDir))
|
// own so a single locked file (the SQLite db while ChatTwo still
|
||||||
|
// runs) does not abandon the rest of the migration.
|
||||||
|
if (!Directory.Exists(legacyConfigDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ourConfigDir);
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(ourConfigDir);
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
||||||
|
if (File.Exists(target))
|
||||||
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
|
continue;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
|
||||||
if (File.Exists(target))
|
|
||||||
continue;
|
|
||||||
File.Move(file, target);
|
File.Move(file, target);
|
||||||
Log.Information($"HellionChat: migrated file {file} → {target}");
|
Log.Information($"HellionChat: migrated file {file} → {target}");
|
||||||
}
|
}
|
||||||
|
catch (IOException e)
|
||||||
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
|
{
|
||||||
|
Log.Warning(e, $"HellionChat: file move blocked for {file}, will retry on next load");
|
||||||
|
lockedBlocker = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
|
||||||
|
{
|
||||||
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
|
|
||||||
if (Directory.Exists(target))
|
|
||||||
continue;
|
|
||||||
Directory.Move(dir, target);
|
Directory.Move(dir, target);
|
||||||
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
|
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
|
||||||
}
|
}
|
||||||
|
catch (IOException e)
|
||||||
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
|
|
||||||
{
|
{
|
||||||
Directory.Delete(legacyConfigDir);
|
Log.Warning(e, $"HellionChat: subdir move blocked for {dir}, will retry on next load");
|
||||||
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
|
lockedBlocker = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
|
||||||
|
{
|
||||||
|
Directory.Delete(legacyConfigDir);
|
||||||
|
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
|
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lockedBlocker)
|
||||||
|
{
|
||||||
|
// Surface the most common cause to the user as a notification
|
||||||
|
// so they don't think Hellion Chat lost their history when in
|
||||||
|
// fact upstream Chat 2 was still holding the database file.
|
||||||
|
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
|
||||||
|
{
|
||||||
|
Title = "Hellion Chat",
|
||||||
|
Content = "Could not migrate the Chat 2 database — the file appears to be in use. " +
|
||||||
|
"Disable Chat 2, fully close the game, then start it again. " +
|
||||||
|
"See the README troubleshooting section if the issue persists.",
|
||||||
|
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
||||||
|
InitialDuration = TimeSpan.FromSeconds(30),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenMainUi()
|
private void OpenMainUi()
|
||||||
|
|||||||
+2
@@ -99,6 +99,8 @@ internal class HellionStrings
|
|||||||
|
|
||||||
internal static string Migration_Notification_Title => Get(nameof(Migration_Notification_Title));
|
internal static string Migration_Notification_Title => Get(nameof(Migration_Notification_Title));
|
||||||
internal static string Migration_Notification_Content => Get(nameof(Migration_Notification_Content));
|
internal static string Migration_Notification_Content => Get(nameof(Migration_Notification_Content));
|
||||||
|
internal static string Migration_Webinterface_Removed_Title => Get(nameof(Migration_Webinterface_Removed_Title));
|
||||||
|
internal static string Migration_Webinterface_Removed_Content => Get(nameof(Migration_Webinterface_Removed_Content));
|
||||||
|
|
||||||
internal static string Wizard_Title => Get(nameof(Wizard_Title));
|
internal static string Wizard_Title => Get(nameof(Wizard_Title));
|
||||||
internal static string Wizard_Intro => Get(nameof(Wizard_Intro));
|
internal static string Wizard_Intro => Get(nameof(Wizard_Intro));
|
||||||
|
|||||||
@@ -177,6 +177,12 @@
|
|||||||
<data name="Migration_Notification_Content" xml:space="preserve">
|
<data name="Migration_Notification_Content" xml:space="preserve">
|
||||||
<value>Datenschutz-Filter ist standardmäßig aktiviert. Einstellungen → Datenschutz zum Anpassen.</value>
|
<value>Datenschutz-Filter ist standardmäßig aktiviert. Einstellungen → Datenschutz zum Anpassen.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Migration_Webinterface_Removed_Title" xml:space="preserve">
|
||||||
|
<value>Hellion Chat 0.2.0</value>
|
||||||
|
</data>
|
||||||
|
<data name="Migration_Webinterface_Removed_Content" xml:space="preserve">
|
||||||
|
<value>Das Webinterface wurde in dieser Version entfernt, weil es nicht auf das Datenschutz-Niveau gehärtet werden konnte das Hellion Chat standardmäßig zusichert. Falls du es genutzt hast, schau bitte in die README für Hintergründe.</value>
|
||||||
|
</data>
|
||||||
<data name="Wizard_Title" xml:space="preserve">
|
<data name="Wizard_Title" xml:space="preserve">
|
||||||
<value>Hellion Chat — Willkommen</value>
|
<value>Hellion Chat — Willkommen</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -177,6 +177,12 @@
|
|||||||
<data name="Migration_Notification_Content" xml:space="preserve">
|
<data name="Migration_Notification_Content" xml:space="preserve">
|
||||||
<value>Privacy filter activated by default. Settings → Privacy to adjust.</value>
|
<value>Privacy filter activated by default. Settings → Privacy to adjust.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Migration_Webinterface_Removed_Title" xml:space="preserve">
|
||||||
|
<value>Hellion Chat 0.2.0</value>
|
||||||
|
</data>
|
||||||
|
<data name="Migration_Webinterface_Removed_Content" xml:space="preserve">
|
||||||
|
<value>The webinterface has been removed in this version because it could not be hardened to the privacy guarantees Hellion Chat makes by default. If you used it, please consult the README for context.</value>
|
||||||
|
</data>
|
||||||
<data name="Wizard_Title" xml:space="preserve">
|
<data name="Wizard_Title" xml:space="preserve">
|
||||||
<value>Hellion Chat — Welcome</value>
|
<value>Hellion Chat — Welcome</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
Generated
+100
@@ -131,6 +131,15 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Loading logs ....
|
||||||
|
/// </summary>
|
||||||
|
internal static string ChatExport_Initial {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ChatExport_Initial", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Input is disabled for this tab.
|
/// Looks up a localized string similar to Input is disabled for this tab.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1454,6 +1463,34 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Database migration has failed, a new database will be created.
|
||||||
|
///Your old database can still be recovered, please contact the plugin author for help..
|
||||||
|
/// </summary>
|
||||||
|
internal static string Database_Migration_Error_Desc {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Database_Migration_Error_Desc", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Chat2 Database Migration Error.
|
||||||
|
/// </summary>
|
||||||
|
internal static string Database_Migration_Error_Title {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Database_Migration_Error_Title", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Open date picker.
|
||||||
|
/// </summary>
|
||||||
|
internal static string DatePicker_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("DatePicker_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Fri.
|
/// Looks up a localized string similar to Fri.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1643,6 +1680,15 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Reset date selection..
|
||||||
|
/// </summary>
|
||||||
|
internal static string DbViewer_Date_Reset_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("DbViewer_Date_Reset_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to FromTo:.
|
/// Looks up a localized string similar to FromTo:.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1733,6 +1779,24 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Export the message history to a json file..
|
||||||
|
/// </summary>
|
||||||
|
internal static string Export_Json_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Export_Json_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Export the message history to a text file..
|
||||||
|
/// </summary>
|
||||||
|
internal static string Export_Txt_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Export_Txt_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Chinese (full).
|
/// Looks up a localized string similar to Chinese (full).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1796,6 +1860,24 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Pick a folder location for export..
|
||||||
|
/// </summary>
|
||||||
|
internal static string Folder_Export_Location_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Folder_Export_Location_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Pick an export location.
|
||||||
|
/// </summary>
|
||||||
|
internal static string Folder_Selection_Header {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Folder_Selection_Header", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Source.
|
/// Looks up a localized string similar to Source.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3713,6 +3795,24 @@ namespace ChatTwo.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Previous page.
|
||||||
|
/// </summary>
|
||||||
|
internal static string Page_ArrowLeft_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Page_ArrowLeft_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Next page.
|
||||||
|
/// </summary>
|
||||||
|
internal static string Page_ArrowRight_Tooltip {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Page_ArrowRight_Tooltip", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Unable to find ID for this message, please try another one..
|
/// Looks up a localized string similar to Unable to find ID for this message, please try another one..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1435,4 +1435,38 @@ Nachdem du 'Aktiviert' angeklickt und auf 'Start' gedrückt hast, wird die einge
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1432,4 +1432,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
Generated
+34
@@ -1433,4 +1433,38 @@
|
|||||||
<data name="ChannelSelector_Select" xml:space="preserve">
|
<data name="ChannelSelector_Select" xml:space="preserve">
|
||||||
<value>Select all</value>
|
<value>Select all</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Title" xml:space="preserve">
|
||||||
|
<value>Chat2 Database Migration Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="Database_Migration_Error_Desc" xml:space="preserve">
|
||||||
|
<value>Database migration has failed, a new database will be created.
|
||||||
|
Your old database can still be recovered, please contact the plugin author for help.</value>
|
||||||
|
</data>
|
||||||
|
<data name="DbViewer_Date_Reset_Tooltip" xml:space="preserve">
|
||||||
|
<value>Reset date selection.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Export_Location_Tooltip" xml:space="preserve">
|
||||||
|
<value>Pick a folder location for export.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Folder_Selection_Header" xml:space="preserve">
|
||||||
|
<value>Pick an export location</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Txt_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a text file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_Json_Tooltip" xml:space="preserve">
|
||||||
|
<value>Export the message history to a json file.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowLeft_Tooltip" xml:space="preserve">
|
||||||
|
<value>Previous page</value>
|
||||||
|
</data>
|
||||||
|
<data name="Page_ArrowRight_Tooltip" xml:space="preserve">
|
||||||
|
<value>Next page</value>
|
||||||
|
</data>
|
||||||
|
<data name="DatePicker_Tooltip" xml:space="preserve">
|
||||||
|
<value>Open date picker</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatExport_Initial" xml:space="preserve">
|
||||||
|
<value>Loading logs ...</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-163
@@ -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;
|
||||||
@@ -18,7 +17,6 @@ using Dalamud.Interface.ImGuiNotification;
|
|||||||
using Lumina.Data.Files;
|
using Lumina.Data.Files;
|
||||||
using Lumina.Text.ReadOnly;
|
using Lumina.Text.ReadOnly;
|
||||||
using MoreLinq;
|
using MoreLinq;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui;
|
namespace ChatTwo.Ui;
|
||||||
|
|
||||||
@@ -56,6 +54,8 @@ public class DbViewer : Window
|
|||||||
private string InputPath = string.Empty;
|
private string InputPath = string.Empty;
|
||||||
private IActiveNotification Notification = null!;
|
private IActiveNotification Notification = null!;
|
||||||
|
|
||||||
|
private bool NeedsScrollReset;
|
||||||
|
|
||||||
public DbViewer(Plugin plugin) : base("DBViewer###chat2-dbviewer")
|
public DbViewer(Plugin plugin) : base("DBViewer###chat2-dbviewer")
|
||||||
{
|
{
|
||||||
Plugin = plugin;
|
Plugin = plugin;
|
||||||
@@ -104,11 +104,17 @@ public class DbViewer : Window
|
|||||||
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
|
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
|
||||||
DateWidget.DatePickerWithInput("##FromDate", 1, ref MinDateString, ref AfterDate, DateFormat);
|
DateWidget.DatePickerWithInput("##FromDate", 1, ref MinDateString, ref AfterDate, DateFormat);
|
||||||
DateWidget.DatePickerWithInput("##ToDate", 2, ref MaxDateString, ref BeforeDate, DateFormat, true);
|
DateWidget.DatePickerWithInput("##ToDate", 2, ref MaxDateString, ref BeforeDate, DateFormat, true);
|
||||||
|
|
||||||
ImGui.SameLine(0, spacing);
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle))
|
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle))
|
||||||
DateReset();
|
DateReset();
|
||||||
ImGuiUtil.DrawArrows(ref CurrentPage, 1, totalPages, spacing);
|
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip(Language.DbViewer_Date_Reset_Tooltip);
|
||||||
|
|
||||||
ImGui.SameLine(0, spacing);
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
ChannelSelection();
|
ChannelSelection();
|
||||||
|
|
||||||
var skipText = Language.DbViewer_CharacterOption;
|
var skipText = Language.DbViewer_CharacterOption;
|
||||||
@@ -128,12 +134,12 @@ public class DbViewer : Window
|
|||||||
ImGui.OpenPopup("InputPathDialog");
|
ImGui.OpenPopup("InputPathDialog");
|
||||||
|
|
||||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||||
ImGui.SetTooltip("Pick a folder location for export.");
|
ImGui.SetTooltip(Language.Folder_Export_Location_Tooltip);
|
||||||
|
|
||||||
using (var innerPopup = ImRaii.Popup("InputPathDialog"))
|
using (var innerPopup = ImRaii.Popup("InputPathDialog"))
|
||||||
{
|
{
|
||||||
if (innerPopup.Success)
|
if (innerPopup.Success)
|
||||||
Plugin.FileDialogManager.OpenFolderDialog("Pick an export location", (b, s) => { if (b) InputPath = s; }, null, true);
|
Plugin.FileDialogManager.OpenFolderDialog(Language.Folder_Selection_Header, (b, s) => { if (b) InputPath = s; }, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.SameLine(0, spacing);
|
ImGui.SameLine(0, spacing);
|
||||||
@@ -145,7 +151,7 @@ public class DbViewer : Window
|
|||||||
new Notification
|
new Notification
|
||||||
{
|
{
|
||||||
Title = "Chat2 Text Export",
|
Title = "Chat2 Text Export",
|
||||||
Content = "Loading logs ...",
|
Content = Language.ChatExport_Initial,
|
||||||
Type = NotificationType.Info,
|
Type = NotificationType.Info,
|
||||||
Minimized = false,
|
Minimized = false,
|
||||||
UserDismissable = false,
|
UserDismissable = false,
|
||||||
@@ -157,36 +163,22 @@ public class DbViewer : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||||
ImGui.SetTooltip("Export the message history to a text file.");
|
ImGui.SetTooltip(Language.Export_Txt_Tooltip);
|
||||||
|
|
||||||
ImGui.SameLine(0, spacing);
|
// Hellion Chat: the JSON export button used to dump the database in
|
||||||
using (ImRaii.Disabled(InputPath.Length == 0 || IsExporting))
|
// the upstream webinterface's wire format. With the webinterface
|
||||||
{
|
// removed there is no consumer for that format any more, so the
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.FileExport))
|
// button is dropped. The Privacy tab's MessageExporter covers the
|
||||||
{
|
// same ground (Markdown / JSON / CSV) with channel and date filters
|
||||||
Notification = Plugin.Notification.AddNotification(
|
// and is the supported way to get history out of the plugin.
|
||||||
new Notification
|
|
||||||
{
|
|
||||||
Title = "Chat2 Json Export",
|
|
||||||
Content = "Loading logs ...",
|
|
||||||
Type = NotificationType.Info,
|
|
||||||
Minimized = false,
|
|
||||||
UserDismissable = false,
|
|
||||||
InitialDuration = TimeSpan.FromSeconds(10000),
|
|
||||||
Progress = 0.0f,
|
|
||||||
});
|
|
||||||
CreateTempJsonFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
|
||||||
ImGui.SetTooltip("Export the message history to a json file.");
|
|
||||||
|
|
||||||
var width = 350 * ImGuiHelpers.GlobalScale;
|
var width = 350 * ImGuiHelpers.GlobalScale;
|
||||||
var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64;
|
var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64;
|
||||||
|
|
||||||
ImGui.AlignTextToFramePadding();
|
ImGui.AlignTextToFramePadding();
|
||||||
ImGui.TextUnformatted(string.Format(Language.DbViewer_Page, CurrentPage, totalPages, Count, loadingIndicator ? Language.DbViewer_LoadingIndicator : ""));
|
ImGui.TextUnformatted(string.Format(Language.DbViewer_Page, CurrentPage, totalPages, Count, loadingIndicator ? Language.DbViewer_LoadingIndicator : ""));
|
||||||
|
ImGuiUtil.DrawArrows(ref CurrentPage, 1, totalPages, spacing, tooltipLeft: Language.Page_ArrowLeft_Tooltip, tooltipRight: Language.Page_ArrowRight_Tooltip);
|
||||||
|
|
||||||
ImGui.SameLine(ImGui.GetContentRegionMax().X - width);
|
ImGui.SameLine(ImGui.GetContentRegionMax().X - width);
|
||||||
ImGui.SetNextItemWidth(width);
|
ImGui.SetNextItemWidth(width);
|
||||||
if (ImGui.InputTextWithHint("##searchbar", Language.DbViewer_SearcHint, ref SimpleSearchTerm, 30))
|
if (ImGui.InputTextWithHint("##searchbar", Language.DbViewer_SearcHint, ref SimpleSearchTerm, 30))
|
||||||
@@ -222,6 +214,7 @@ public class DbViewer : Window
|
|||||||
Messages = rangeMessageEnumerator.ToArray();
|
Messages = rangeMessageEnumerator.ToArray();
|
||||||
|
|
||||||
Filtered = Filter(Messages);
|
Filtered = Filter(Messages);
|
||||||
|
NeedsScrollReset = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -246,6 +239,12 @@ public class DbViewer : Window
|
|||||||
if (!child.Success)
|
if (!child.Success)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (NeedsScrollReset)
|
||||||
|
{
|
||||||
|
NeedsScrollReset = false;
|
||||||
|
ImGui.SetScrollY(0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
using var table = ImRaii.Table("##messageHistory", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable);
|
using var table = ImRaii.Table("##messageHistory", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable);
|
||||||
if (!table.Success)
|
if (!table.Success)
|
||||||
return;
|
return;
|
||||||
@@ -281,6 +280,7 @@ public class DbViewer : Window
|
|||||||
private void ChannelSelection()
|
private void ChannelSelection()
|
||||||
{
|
{
|
||||||
const string addTabPopup = "add-channel-popup";
|
const string addTabPopup = "add-channel-popup";
|
||||||
|
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
|
||||||
|
|
||||||
if (ImGui.Button("Channels"))
|
if (ImGui.Button("Channels"))
|
||||||
ImGui.OpenPopup(addTabPopup);
|
ImGui.OpenPopup(addTabPopup);
|
||||||
@@ -306,7 +306,7 @@ public class DbViewer : Window
|
|||||||
if (ImGui.IsItemHovered())
|
if (ImGui.IsItemHovered())
|
||||||
ImGui.SetTooltip("Select all");
|
ImGui.SetTooltip("Select all");
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
||||||
{
|
{
|
||||||
@@ -317,7 +317,7 @@ public class DbViewer : Window
|
|||||||
if (ImGui.IsItemHovered())
|
if (ImGui.IsItemHovered())
|
||||||
ImGui.SetTooltip("Unselect all");
|
ImGui.SetTooltip("Unselect all");
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
using var headerNode = ImRaii.TreeNode(header);
|
using var headerNode = ImRaii.TreeNode(header);
|
||||||
if (!headerNode.Success)
|
if (!headerNode.Success)
|
||||||
@@ -444,136 +444,4 @@ public class DbViewer : Window
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateTempJsonFile()
|
|
||||||
{
|
|
||||||
IsExporting = true;
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var channels = SelectedChannels.Select(pair => (byte)pair.Key).ToArray();
|
|
||||||
|
|
||||||
var rangeMessageEnumerator = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, channels);
|
|
||||||
var messageHistory = rangeMessageEnumerator.ToArray();
|
|
||||||
await rangeMessageEnumerator.DisposeAsync();
|
|
||||||
|
|
||||||
var filteredHistory = Filter(messageHistory);
|
|
||||||
|
|
||||||
await using var stream = new StreamWriter(Path.Join(InputPath, $"Chat2_{DateTime.Now:yyyy_dd_M__HH_mm_ss}.json"));
|
|
||||||
|
|
||||||
var batch = 0;
|
|
||||||
var messageContainer = new Messages();
|
|
||||||
List<MessageResponse> templates = [];
|
|
||||||
foreach (var messages in filteredHistory.Batch(5000))
|
|
||||||
{
|
|
||||||
foreach (var message in messages)
|
|
||||||
{
|
|
||||||
templates.Add(ReadMessageContent(message));
|
|
||||||
batch++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification.Progress = (float)batch / filteredHistory.Count;
|
|
||||||
Notification.Content = $"Exported {batch} of {filteredHistory.Count} messages";
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
messageContainer.Set = templates.ToArray();
|
|
||||||
await stream.WriteAsync(JsonConvert.SerializeObject(messageContainer));
|
|
||||||
templates.Clear();
|
|
||||||
|
|
||||||
await using (var fileStream = File.Open(Path.Join(InputPath, "gfdata.gfd"), FileMode.OpenOrCreate))
|
|
||||||
{
|
|
||||||
await using var byteWriter = new BinaryWriter(fileStream);
|
|
||||||
byteWriter.Write(Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using (var fileStream = File.Open(Path.Join(InputPath, "fonticon_ps5.tex"), FileMode.OpenOrCreate))
|
|
||||||
{
|
|
||||||
await using var byteWriter = new BinaryWriter(fileStream);
|
|
||||||
byteWriter.Write(Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using (var fileStream = File.Open(Path.Join(InputPath, "FFXIV_Lodestone_SSF.ttf"), FileMode.OpenOrCreate))
|
|
||||||
{
|
|
||||||
await using var byteWriter = new BinaryWriter(fileStream);
|
|
||||||
byteWriter.Write(Plugin.FontManager.GameSymFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification.Progress = 1.0f;
|
|
||||||
Notification.Content = "Done!!!";
|
|
||||||
Notification.Type = NotificationType.Success;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Failed creating txt backup");
|
|
||||||
|
|
||||||
Notification.Content = "Error ...";
|
|
||||||
Notification.Type = NotificationType.Error;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsExporting = false;
|
|
||||||
Notification.UserDismissable = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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 (Plugin.ChatLogWindow.ScreenshotMode)
|
|
||||||
{
|
|
||||||
if (chunk.Link is PlayerPayload playerPayload)
|
|
||||||
userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
|
|
||||||
else if (Plugin.PlayerState.IsLoaded)
|
|
||||||
userContent = 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ public sealed class SettingsWindow : Window
|
|||||||
new Tabs(Plugin, Mutable),
|
new Tabs(Plugin, Mutable),
|
||||||
new SettingsTabs.Privacy(Plugin, Mutable),
|
new SettingsTabs.Privacy(Plugin, Mutable),
|
||||||
new Database(Plugin, Mutable),
|
new Database(Plugin, Mutable),
|
||||||
new Webinterface(Plugin, Mutable),
|
|
||||||
new Miscellaneous(Mutable),
|
new Miscellaneous(Mutable),
|
||||||
new Changelog(Mutable),
|
new Changelog(Mutable),
|
||||||
new About()
|
new About()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ internal sealed class About : ISettingsTab
|
|||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Discord);
|
ImGui.TextUnformatted(Language.Options_About_Discord);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "@infi");
|
ImGui.TextColored(ImGuiColors.ParsedGold, "@j.j_kazama");
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Version);
|
ImGui.TextUnformatted(Language.Options_About_Version);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
@@ -53,24 +53,55 @@ internal sealed class About : ISettingsTab
|
|||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
ImGuiHelpers.ScaledDummy(10.0f);
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Discord_Thread);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "discordThread"))
|
|
||||||
Dalamud.Utility.Util.OpenLink("https://canary.discord.com/channels/581875019861328007/1224865018789761126");
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Github_Issues);
|
ImGui.TextUnformatted(Language.Options_About_Github_Issues);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "githubIssues"))
|
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "githubIssues"))
|
||||||
Dalamud.Utility.Util.OpenLink("https://github.com/Infiziert90/ChatTwo/issues");
|
Dalamud.Utility.Util.OpenLink("https://github.com/JonKazama-Hellion/HellionChat/issues");
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
ImGuiHelpers.ScaledDummy(10.0f);
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_CrowdIn);
|
// Hellion-specific maintainer / attribution / license / SE-
|
||||||
|
// disclaimer block. Hand-rolled in English here rather than via
|
||||||
|
// HellionStrings — the legal-ish copy stays close to the EUPL-1.2
|
||||||
|
// wording and the SE disclaimer is the same in every locale.
|
||||||
|
ImGui.TextColored(ImGuiColors.ParsedGold, "Maintainer");
|
||||||
|
ImGui.TextUnformatted("Hellion Chat is maintained by Hellion Online Media (Florian Wathling).");
|
||||||
|
ImGui.TextUnformatted("Website:");
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "crowdin"))
|
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "hellionMedia"))
|
||||||
Dalamud.Utility.Util.OpenLink("https://crowdin.com/project/chattwo");
|
Dalamud.Utility.Util.OpenLink("https://hellion-media.de");
|
||||||
|
ImGui.TextUnformatted("For licensing, legal or contact inquiries please reach out via the website above.");
|
||||||
|
|
||||||
|
ImGuiHelpers.ScaledDummy(10.0f);
|
||||||
|
|
||||||
|
ImGui.TextColored(ImGuiColors.ParsedGold, "Built on Chat 2");
|
||||||
|
ImGui.TextUnformatted("Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens).");
|
||||||
|
ImGui.TextUnformatted("Every chat replacement feature, the IPC integration, the rendering engine and the storage core come from upstream Chat 2.");
|
||||||
|
ImGui.TextUnformatted("The upstream webinterface is intentionally not part of Hellion Chat — it could not be hardened to the privacy guarantees this fork makes by default.");
|
||||||
|
ImGui.TextUnformatted("Upstream repository:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "chatTwoUpstream"))
|
||||||
|
Dalamud.Utility.Util.OpenLink("https://github.com/Infiziert90/ChatTwo");
|
||||||
|
|
||||||
|
ImGuiHelpers.ScaledDummy(10.0f);
|
||||||
|
|
||||||
|
ImGui.TextColored(ImGuiColors.ParsedGold, "License");
|
||||||
|
ImGui.TextUnformatted("Hellion Chat and Chat 2 are licensed under the European Union Public License v1.2 (EUPL-1.2).");
|
||||||
|
ImGui.TextUnformatted("© 2023–2026 the Chat 2 authors (Infi, Anna and the upstream contributors).");
|
||||||
|
ImGui.TextUnformatted("© 2026 Hellion Online Media — for the Hellion Chat additions.");
|
||||||
|
|
||||||
|
ImGuiHelpers.ScaledDummy(10.0f);
|
||||||
|
|
||||||
|
ImGui.TextColored(ImGuiColors.DalamudOrange, "FINAL FANTASY XIV disclaimer");
|
||||||
|
ImGui.TextUnformatted("FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.");
|
||||||
|
ImGui.TextUnformatted("Hellion Chat is an unofficial, fan-made plugin and is not affiliated with, endorsed, sponsored or approved by Square Enix.");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
ImGui.TextColored(ImGuiColors.ParsedGold, "Localization");
|
||||||
|
ImGui.TextUnformatted("German translations of Hellion-specific UI strings (HellionStrings.de.resx) are written by the Hellion Online Media maintainer.");
|
||||||
|
ImGui.TextUnformatted("All other locales for Hellion-specific strings are not currently provided.");
|
||||||
|
ImGui.TextUnformatted("The translator list below covers the upstream Chat 2 community translators on Crowdin — their work covers the inherited Chat 2 strings, not the Hellion additions.");
|
||||||
|
|
||||||
ImGui.Spacing();
|
ImGui.Spacing();
|
||||||
|
|
||||||
@@ -79,7 +110,7 @@ internal sealed class About : ISettingsTab
|
|||||||
{
|
{
|
||||||
if (aboutChild)
|
if (aboutChild)
|
||||||
{
|
{
|
||||||
using var treeNode = ImRaii.TreeNode(Language.Options_About_Translators);
|
using var treeNode = ImRaii.TreeNode("Chat 2 community translators (upstream)");
|
||||||
if (treeNode)
|
if (treeNode)
|
||||||
{
|
{
|
||||||
using var translatorChild = ImRaii.Child("translators");
|
using var translatorChild = ImRaii.Child("translators");
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Interface.Colors;
|
|
||||||
using Dalamud.Interface.ImGuiNotification;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISettingsTab
|
|
||||||
{
|
|
||||||
private Plugin Plugin { get; } = plugin;
|
|
||||||
private Configuration Mutable { get; } = mutable;
|
|
||||||
public string Name => Language.Options_Webinterface_Tab + "###tabs-Webinterface";
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
if (ImGui.CollapsingHeader(Language.Webinterface_UsageNotice, ImGuiTreeNodeFlags.DefaultOpen))
|
|
||||||
{
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudWhite, Language.Options_Webinterface_Warning_Header);
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Reason);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_Warning_DoNot);
|
|
||||||
using (ImRaii.PushIndent(15.0f))
|
|
||||||
{
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Port);
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Share);
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Multibox);
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Support);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceEnabled, Language.Options_WebinterfaceEnable_Name, Language.Options_WebinterfaceEnable_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (!Mutable.WebinterfaceEnabled)
|
|
||||||
return;
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceAutoStart, Language.Options_WebinterfaceAutoStart_Name, Language.Options_WebinterfaceAutoStart_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Webinterface_Option_Port_Name, Language.Webinterface_Option_Port_Description, ref Mutable.WebinterfacePort))
|
|
||||||
Mutable.WebinterfacePort = Math.Clamp(Mutable.WebinterfacePort, 1024, 49151);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Options_WebinterfaceMaxLinesToSend_Name, Language.Options_WebinterfaceMaxLinesToSend_Description, ref Mutable.WebinterfaceMaxLinesToSend))
|
|
||||||
Mutable.WebinterfaceMaxLinesToSend = Math.Clamp(Mutable.WebinterfaceMaxLinesToSend, 1, 10_000);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Webinterface_CurrentPassword);
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Mutable.WebinterfacePassword);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
|
|
||||||
{
|
|
||||||
Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
|
||||||
Plugin.ServerCore.InvalidateSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls);
|
|
||||||
using (ImRaii.PushIndent(10.0f))
|
|
||||||
{
|
|
||||||
var isActive = Plugin.ServerCore.IsActive();
|
|
||||||
using (ImRaii.Disabled(isActive || Plugin.ServerCore.IsStopping()))
|
|
||||||
{
|
|
||||||
if (ImGui.Button(Language.Webinterface_Button_Start))
|
|
||||||
{
|
|
||||||
Task.Run(() =>
|
|
||||||
{
|
|
||||||
var ok = Plugin.ServerCore.Start();
|
|
||||||
if (ok)
|
|
||||||
{
|
|
||||||
Plugin.ServerCore.Run();
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Start_Success, NotificationType.Success);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Start_Failed, NotificationType.Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
|
|
||||||
using (ImRaii.Disabled(!isActive || Plugin.ServerCore.IsStopping()))
|
|
||||||
{
|
|
||||||
if (ImGui.Button(Language.Webinterface_Button_Stop))
|
|
||||||
{
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var ok = await Plugin.ServerCore.Stop();
|
|
||||||
if (ok)
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Stop_Success, NotificationType.Success);
|
|
||||||
else
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Stop_Failed, NotificationType.Error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls_Active);
|
|
||||||
ImGui.SameLine();
|
|
||||||
using (Plugin.FontManager.FontAwesome.Push())
|
|
||||||
using (ImRaii.PushColor(ImGuiCol.Text, isActive ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed))
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(isActive ? FontAwesomeIcon.Check.ToIconString() : FontAwesomeIcon.Times.ToIconString());
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri? uri;
|
|
||||||
try {
|
|
||||||
uri = new Uri($"http://{System.Net.Dns.GetHostName()}:{Mutable.WebinterfacePort}/");
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
uri = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls_Url);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (uri is not null)
|
|
||||||
{
|
|
||||||
var clicked = false;
|
|
||||||
clicked |= ImGui.Selectable(uri.AbsoluteUri);
|
|
||||||
ImGui.SameLine();
|
|
||||||
clicked |= ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "urlOpen");
|
|
||||||
|
|
||||||
if (clicked)
|
|
||||||
WrapperUtil.TryOpenUri(uri);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(Language.Options_Webinterface_Hostname_Fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Options_Webinterface_Note);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,6 +65,9 @@ public static class DateWidget
|
|||||||
ImGui.SameLine(0, 3.0f * ImGuiHelpers.GlobalScale);
|
ImGui.SameLine(0, 3.0f * ImGuiHelpers.GlobalScale);
|
||||||
|
|
||||||
ImGuiUtil.IconButton(FontAwesomeIcon.Calendar, id.ToString());
|
ImGuiUtil.IconButton(FontAwesomeIcon.Calendar, id.ToString());
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip(Language.DatePicker_Tooltip);
|
||||||
|
|
||||||
if (DatePicker(label, ref date, closeWhenMouseLeavesIt))
|
if (DatePicker(label, ref date, closeWhenMouseLeavesIt))
|
||||||
dateString = date.ToString(format);
|
dateString = date.ToString(format);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ internal static class ImGuiUtil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DrawArrows(ref int selected, int min, int max, float spacing, int id = 0)
|
public static void DrawArrows(ref int selected, int min, int max, float spacing, int id = 0, string? tooltipLeft = null, string? tooltipRight = null)
|
||||||
{
|
{
|
||||||
// Prevents changing values from triggering EndDisable
|
// Prevents changing values from triggering EndDisable
|
||||||
var isMin = selected == min;
|
var isMin = selected == min;
|
||||||
@@ -404,12 +404,19 @@ internal static class ImGuiUtil
|
|||||||
selected--;
|
selected--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooltipLeft != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||||
|
ImGui.SetTooltip(tooltipLeft);
|
||||||
|
|
||||||
ImGui.SameLine(0, spacing);
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
using (ImRaii.Disabled(isMax))
|
using (ImRaii.Disabled(isMax))
|
||||||
{
|
{
|
||||||
if (IconButton(FontAwesomeIcon.ArrowRight, id+1.ToString()))
|
if (IconButton(FontAwesomeIcon.ArrowRight, id+1.ToString()))
|
||||||
selected++;
|
selected++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooltipRight != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||||
|
ImGui.SetTooltip(tooltipRight);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void WrappedTextWithColor(Vector4 color, string text)
|
public static void WrappedTextWithColor(Vector4 color, string text)
|
||||||
@@ -544,6 +551,8 @@ internal static class ImGuiUtil
|
|||||||
|
|
||||||
public static void ChannelSelector(string headerText, Dictionary<ChatType, (ChatSource Source, ChatSource Target)> chatCodes)
|
public static void ChannelSelector(string headerText, Dictionary<ChatType, (ChatSource Source, ChatSource Target)> chatCodes)
|
||||||
{
|
{
|
||||||
|
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
|
||||||
|
|
||||||
using var channelNode = ImRaii.TreeNode(headerText);
|
using var channelNode = ImRaii.TreeNode(headerText);
|
||||||
if (!channelNode.Success)
|
if (!channelNode.Success)
|
||||||
return;
|
return;
|
||||||
@@ -561,7 +570,7 @@ internal static class ImGuiUtil
|
|||||||
if (ImGui.IsItemHovered())
|
if (ImGui.IsItemHovered())
|
||||||
ImGui.SetTooltip(Language.ChannelSelector_Select);
|
ImGui.SetTooltip(Language.ChannelSelector_Select);
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
||||||
{
|
{
|
||||||
@@ -572,7 +581,7 @@ internal static class ImGuiUtil
|
|||||||
if (ImGui.IsItemHovered())
|
if (ImGui.IsItemHovered())
|
||||||
ImGui.SetTooltip(Language.ChannelSelector_Unselect);
|
ImGui.SetTooltip(Language.ChannelSelector_Unselect);
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine(0, spacing);
|
||||||
|
|
||||||
using var headerNode = ImRaii.TreeNode(header);
|
using var headerNode = ImRaii.TreeNode(header);
|
||||||
if (!headerNode.Success)
|
if (!headerNode.Success)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 481 KiB After Width: | Height: | Size: 53 KiB |
@@ -54,26 +54,6 @@
|
|||||||
"resolved": "3.1.12",
|
"resolved": "3.1.12",
|
||||||
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
|
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
|
||||||
},
|
},
|
||||||
"Watson.Lite": {
|
|
||||||
"type": "Direct",
|
|
||||||
"requested": "[6.3.9, )",
|
|
||||||
"resolved": "6.3.9",
|
|
||||||
"contentHash": "sDigTY8D8V7W38lfzJGiigf7xZEfp3Kw7XE7VJyeNO9mxOkv+w8HcmCsmORMDhsipDqGU0gMEsPOqORmZzRaWg==",
|
|
||||||
"dependencies": {
|
|
||||||
"CavemanTcp": "2.0.9",
|
|
||||||
"Watson.Core": "6.3.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"CavemanTcp": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "2.0.9",
|
|
||||||
"contentHash": "KgIwYhPhGkBTm+wwVAmWonkKPw4xYVnutzzlIeqOLcX1fti+8d+MEGTvbern1smf3S/UpjFjihkf6XRziTddzQ=="
|
|
||||||
},
|
|
||||||
"IpMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.5",
|
|
||||||
"contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
|
|
||||||
},
|
|
||||||
"MessagePack.Annotations": {
|
"MessagePack.Annotations": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "3.1.4",
|
"resolved": "3.1.4",
|
||||||
@@ -97,11 +77,6 @@
|
|||||||
"resolved": "17.11.4",
|
"resolved": "17.11.4",
|
||||||
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
|
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
|
||||||
},
|
},
|
||||||
"RegexMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.9",
|
|
||||||
"contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
|
|
||||||
},
|
|
||||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.1.10",
|
"resolved": "2.1.10",
|
||||||
@@ -128,27 +103,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"SQLitePCLRaw.core": "2.1.10"
|
"SQLitePCLRaw.core": "2.1.10"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Timestamps": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.11",
|
|
||||||
"contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
|
|
||||||
},
|
|
||||||
"UrlMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "3.0.1",
|
|
||||||
"contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
|
|
||||||
},
|
|
||||||
"Watson.Core": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "6.3.9",
|
|
||||||
"contentHash": "hGoadE4SLbko8yxhx5+nxGV8lEVgEquNli87lN6/eOTQEJNpK/Cs+OF0etTgFKZ4p0u5ivetoDxl82Lg6oHZEg==",
|
|
||||||
"dependencies": {
|
|
||||||
"IpMatcher": "1.0.5",
|
|
||||||
"RegexMatcher": "1.0.9",
|
|
||||||
"Timestamps": "1.0.11",
|
|
||||||
"UrlMatcher": "3.0.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,110 +1,287 @@
|
|||||||
# Hellion Chat
|
# Hellion Chat
|
||||||
|
|
||||||
A GDPR-compliant, Linux-aware fork of [Chat 2](https://github.com/Infiziert90/ChatTwo)
|
**Version 0.2.0** — DSGVO-bewusste Erweiterung von [Chat 2](https://github.com/Infiziert90/ChatTwo) für FINAL FANTASY XIV / Dalamud.
|
||||||
for FINAL FANTASY XIV / Dalamud.
|
|
||||||
|
|
||||||
Same chat replacement you know from upstream, with extra controls for what
|
Hellion Chat baut auf Chat 2 auf und ergänzt es um Datenschutz- und Daten-Handling-Kontrollen, die mit den Datenschutz-Regeln in der EU, den USA und Japan im Einklang sind. Alle Chat-2-Funktionen, Befehle und Tastenkürzel funktionieren unverändert. Eigenständiger Plugin-Slot, eigene Konfiguration, eigene Datenbank.
|
||||||
actually gets stored:
|
|
||||||
|
|
||||||
- **Channel whitelist** for database persistence (GDPR Art. 25 — privacy by
|
Privates Repository, EUPL-1.2-lizenziert. Distribution über Custom-Repo während der Bootstrap-Phase.
|
||||||
default). Out-of-the-box only your own conversations are kept: Tells, Party,
|
|
||||||
Free Company, Linkshells, Cross-World Linkshells, Alliance, ExtraChat. Public
|
|
||||||
chat from strangers (Say/Shout/Yell), Novice Network, NPC dialogue, system
|
|
||||||
spam and battle messages stay outside the database unless you opt them in.
|
|
||||||
- **Per-channel retention** with a 24-hour idempotent background sweep. Tells
|
|
||||||
default to 365 days, own-conversation channels to 90, the global default to
|
|
||||||
30. Off until you switch it on — the plugin never deletes history without
|
|
||||||
your explicit consent.
|
|
||||||
- **Retroactive cleanup** with a Ctrl+Shift confirm. Apply the current
|
|
||||||
whitelist to an existing 800-MB+ database, watch it shrink to MBs, all on a
|
|
||||||
background thread.
|
|
||||||
- **Export** to Markdown, JSON or CSV (GDPR Art. 15 right of access). Filter
|
|
||||||
by channel, date range or sender substring; written via Dalamud's file
|
|
||||||
dialog without blocking the UI.
|
|
||||||
- **First-run wizard** with three profiles (Privacy-First / Casual / Full
|
|
||||||
History) that maps to concrete configuration sets. Reopenable from the
|
|
||||||
Privacy tab any time.
|
|
||||||
- **Independent plugin state.** Config and database live under
|
|
||||||
`pluginConfigs/HellionChat/`, completely separate from upstream Chat 2 — you
|
|
||||||
can run both side by side, or migrate from Chat 2 once and keep going.
|
|
||||||
- **Migration recovery.** Heals databases left in a half-applied Migrate3
|
|
||||||
state (columns added, `user_version` never bumped) without needing the
|
|
||||||
backup file upstream creates.
|
|
||||||
- **Localized UI (EN + DE).** All Hellion-specific surfaces follow Dalamud's
|
|
||||||
language override and switch live. Translations live in
|
|
||||||
`Resources/HellionStrings.<lang>.resx`.
|
|
||||||
|
|
||||||
## Status
|
---
|
||||||
|
|
||||||
Bootstrap (v0.1.x). Used in production on a single user's setup. Not (yet)
|
## Tech Stack
|
||||||
submitted to the official Dalamud plugin repository — distributed as a
|
|
||||||
custom-repo / dev-plugin while the architecture stabilises.
|
|
||||||
|
|
||||||
## Install (testers)
|
| Kategorie | Technologie |
|
||||||
|
| --------------- | ---------------------------------------------------- |
|
||||||
|
| Plattform | Dalamud Plugin (API Level 15) |
|
||||||
|
| Sprache | C# 13 / .NET 10 (`net10.0-windows`) |
|
||||||
|
| Build | Dalamud.NET.Sdk 15.0.0, DalamudPackager 15.0.0 |
|
||||||
|
| UI | Dear ImGui (Dalamud-Bindings) |
|
||||||
|
| Datenbank | SQLite (Microsoft.Data.Sqlite, MessagePack-Storage) |
|
||||||
|
| Lokalisierung | ResX (HellionStrings.resx, .de.resx) + Crowdin-Sync |
|
||||||
|
| Schriftart | Exo 2 (SIL Open Font License 1.1, gebündelt) |
|
||||||
|
| Toolchain | dotnet 10 SDK, VS Code mit C# Dev Kit |
|
||||||
|
| Deployment | GitHub Releases + Custom-Repo (`repo.json`) |
|
||||||
|
|
||||||
Hellion Chat is shipped via a Dalamud **custom repository** during the
|
---
|
||||||
bootstrap phase. To install:
|
|
||||||
|
|
||||||
1. Open Dalamud settings (`/xlsettings`) → **Experimental**.
|
## Features
|
||||||
2. Add a new entry under **Custom Plugin Repositories**:
|
|
||||||
|
### Privacy / Compliance
|
||||||
|
|
||||||
|
- **Channel-Whitelist** für die Datenbank-Persistenz mit Privacy-First-Default. Out-of-the-box werden nur eigene Konversationen gespeichert (Tells, Gruppe, FC, Linkshells, Cross-World-Linkshells, Allianz, ExtraChat). Öffentlicher Chat, NPC-Dialoge, System-Spam und Battle-Logs werden auf der Storage-Ebene verworfen.
|
||||||
|
- **Aufbewahrungsdauer pro Kanal** mit täglicher Background-Bereinigung. Tells 365 Tage, eigene Konversations-Kanäle 90 Tage, globaler Default 30 Tage. Standard ist AUS, das Plugin löscht ohne ausdrückliche Zustimmung nichts.
|
||||||
|
- **Retroaktive Säuberung** mit Vorschau und Strg+Umschalt-Bestätigung. Wendet die aktuelle Whitelist auf eine bestehende Datenbank an, läuft im Hintergrund, ruft danach VACUUM auf.
|
||||||
|
- **Export** nach Markdown, JSON oder CSV via Dalamud-Datei-Dialog (DSGVO Art. 15 Auskunftsrecht). Filter nach Kanal, Datums-Bereich oder Sender-Substring.
|
||||||
|
|
||||||
|
### Onboarding
|
||||||
|
|
||||||
|
- **First-Run-Wizard** mit drei Profilen (Privacy-First, Locker, Volle Historie) und DSGVO-Hinweis bei der "Volle Historie"-Option.
|
||||||
|
- **Konfigurations-Migration v6→v7** seedet Privacy-Defaults bei Bestand-Usern und zeigt eine Benachrichtigung beim Ersten Plugin-Start nach Update.
|
||||||
|
- **Layout-Migration aus Chat 2** verschiebt Konfiguration und Datenbank in `pluginConfigs/HellionChat/` ohne Datenverlust. Robust gegen blockierte Dateien (Warnung beim User wenn Chat 2 noch geladen ist).
|
||||||
|
- **Migrate3-Recovery** heilt halb-migrierte Datenbanken aus alten Chat-2-Installationen.
|
||||||
|
|
||||||
|
### Look & Feel
|
||||||
|
|
||||||
|
- **Bilinguale UI** (Englisch + Deutsch) mit Live-Sprachwechsel. Hellion-spezifische Strings in `HellionStrings.<lang>.resx`.
|
||||||
|
- **Hellion-HUD-Theme** mit Cyan-Teal-Akzenten, Slate-Violet-Tabs, Bernstein-Highlights für aktive Zustände.
|
||||||
|
- **Fenster-Deckkraft-Slider** für Kampf-freundliche Transparenz.
|
||||||
|
- **Mitgelieferte Hellion-Schrift** (Exo 2, OFL-1.1) als optionaler Default statt System-Font.
|
||||||
|
- **Hellion-Logo** im Plugin-Bundle und in der Dalamud-Plugin-Liste.
|
||||||
|
|
||||||
|
### Stability
|
||||||
|
|
||||||
|
- BetterTTV-Cache-Crash-Fix (Null-Key-Handling).
|
||||||
|
- Font-Atlas-Build-Fallback bei nicht-installierten System-Fonts.
|
||||||
|
- Defensive Wrapping aller Migrations-Operationen.
|
||||||
|
|
||||||
|
### Was gegenüber Chat 2 fehlt
|
||||||
|
|
||||||
|
- **Webinterface** wurde in Hellion Chat 0.2.0 entfernt. Der eingebaute HTTP-Server hat unter dem Privacy-Versprechen nicht abgesichert werden können (5-stelliger numerischer Auth-Code aus `System.Random`, Bind auf alle Interfaces per Default, Cookies ohne Security-Flags und ein Server-Sent-Events-Stream der den Privacy-Filter umgangen hat). Wer den Funktionsumfang von Chat 2 vollständig braucht, sollte beim Upstream-Plugin bleiben; Hellion Chat fokussiert auf DSGVO-konforme Persistenz und verzichtet bewusst auf Remote-Zugriffs-Features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatTwo/
|
||||||
|
├── Privacy/
|
||||||
|
│ └── PrivacyDefaults.cs # Whitelist-Sets, Spec-Retention-Tabelle
|
||||||
|
├── Export/
|
||||||
|
│ └── MessageExporter.cs # Markdown / JSON / CSV Serializer
|
||||||
|
├── Resources/
|
||||||
|
│ ├── HellionStrings.resx # Hellion-eigene UI-Strings (EN)
|
||||||
|
│ ├── HellionStrings.de.resx # Deutsche Übersetzung
|
||||||
|
│ ├── HellionStrings.Designer.cs # Hand-maintained Accessor
|
||||||
|
│ ├── HellionFont.ttf # Exo 2 Variable Font
|
||||||
|
│ ├── HellionFont-OFL.txt # OFL-1.1 Lizenztext (mit Font gebundelt)
|
||||||
|
│ └── Language*.resx # Upstream-Lokalisierung (Crowdin)
|
||||||
|
├── Ui/
|
||||||
|
│ ├── FirstRunWizard.cs # Drei-Profile-Onboarding
|
||||||
|
│ ├── HellionStyle.cs # ImGui-Theme-Push (lokal + global)
|
||||||
|
│ └── SettingsTabs/
|
||||||
|
│ └── Privacy.cs # Datenschutz-Tab (Filter, Retention, Cleanup, Export)
|
||||||
|
├── images/
|
||||||
|
│ └── icon.png # Hellion-Logo (256×256)
|
||||||
|
├── DalamudPackager.targets # Override für ImagesPath / HandleImages
|
||||||
|
└── HellionChat.yaml # Plugin-Manifest (DalamudPackager-Source)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regeln
|
||||||
|
|
||||||
|
- **Code-Namespace bleibt `ChatTwo.*`** — Cherry-Picks von Upstream-Bugfixes bleiben damit konfliktarm.
|
||||||
|
- **AssemblyName ist `HellionChat`** — eigener Slot in `pluginConfigs/`, eigene Datei-Manifest, kein Shared State mit Chat 2.
|
||||||
|
- **Hellion-eigene Strings nur in `HellionStrings.*.resx`** — die Upstream-`Language.*.resx` bleiben unverändert für sauberen Crowdin-Sync.
|
||||||
|
- **Kein Direkt-Eingriff in `Plugin.Interface.UiBuilder.FontAtlas`** außerhalb von `FontManager` — Font-Fallback und Hellion-Font laufen zentral.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank
|
||||||
|
|
||||||
|
SQLite, Schema von Upstream Chat 2 übernommen (Migration-Stand v3). Hellion-Erweiterungen sind in `Configuration` als Felder, nicht im DB-Schema:
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
| ---------------- | -------- | --------------------------------------------- |
|
||||||
|
| Id | BLOB | Guid |
|
||||||
|
| Receiver | INTEGER | Empfänger-ContentId |
|
||||||
|
| ContentId | INTEGER | Sender-ContentId |
|
||||||
|
| Date | INTEGER | Unix-Timestamp (ms) |
|
||||||
|
| ChatType | INTEGER | XivChatType / LogKind |
|
||||||
|
| SourceKind | INTEGER | Player / NPC / Server / etc. |
|
||||||
|
| TargetKind | INTEGER | Player / NPC / Server / etc. |
|
||||||
|
| Sender | BLOB | MessagePack `List<Chunk>` |
|
||||||
|
| Content | BLOB | MessagePack `List<Chunk>` |
|
||||||
|
| SenderSource | BLOB | MessagePack `SeString` |
|
||||||
|
| ContentSource | BLOB | MessagePack `SeString` |
|
||||||
|
| ExtraChatChannel | BLOB | Guid |
|
||||||
|
| Deleted | BOOLEAN | Soft-Delete-Marker |
|
||||||
|
|
||||||
|
Pfad: `pluginConfigs/HellionChat/chat-sqlite.db`. WAL-Modus, Synchronous=NORMAL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation (Tester)
|
||||||
|
|
||||||
|
Hellion Chat wird während der Bootstrap-Phase über ein Dalamud-**Custom-Repository** verteilt.
|
||||||
|
|
||||||
|
### Frische Installation (kein Chat 2 vorher)
|
||||||
|
|
||||||
|
1. Dalamud-Settings (`/xlsettings`) → **Experimental** öffnen.
|
||||||
|
2. Neuen Eintrag unter **Custom Plugin Repositories** anlegen:
|
||||||
```
|
```
|
||||||
https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/repo.json
|
https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/repo.json
|
||||||
```
|
```
|
||||||
3. Click **Save**, then back in `/xlplugins` hit **All Plugins** and refresh.
|
3. **Save**, dann in `/xlplugins` → **All Plugins** → Refresh.
|
||||||
4. **Hellion Chat** now appears in the list — install it from there.
|
4. **Hellion Chat** taucht in der Liste auf — installieren.
|
||||||
5. If you previously had **Chat 2** installed, disable it first. The two
|
|
||||||
share their database and config dir until Hellion Chat's first launch
|
|
||||||
migrates everything into `pluginConfigs/HellionChat/`.
|
|
||||||
|
|
||||||
Updates land in the same plugin list once the maintainer pushes a new
|
### Migration aus Chat 2 (mit bestehendem Verlauf)
|
||||||
`v0.1.x` tag.
|
|
||||||
|
|
||||||
## Why a fork
|
Chat 2 und Hellion Chat teilen sich die Datenbank-Datei, bis Hellion Chat sie beim ersten Start in den eigenen Pfad verschiebt. Die Reihenfolge ist wichtig:
|
||||||
|
|
||||||
The upstream maintainer has left filtering-related issues open since 2024
|
1. **Chat 2 deaktivieren** in `/xlplugins` (nicht deinstallieren, nur deaktivieren).
|
||||||
([#84](https://github.com/Infiziert90/ChatTwo/issues/84),
|
2. **FFXIV komplett schließen**, damit SQLite die Datei-Sperre freigibt. Plugin-Reload allein reicht nicht.
|
||||||
[#173](https://github.com/Infiziert90/ChatTwo/issues/173),
|
3. Spiel neu starten.
|
||||||
[#174](https://github.com/Infiziert90/ChatTwo/issues/174)). The original
|
4. Custom-Repo wie oben hinzufügen.
|
||||||
design treats the database as an unlimited searchable archive of *everything*
|
5. Hellion Chat installieren. Beim ersten Start wandert die Konfigurations-Datei und das gesamte Datenbank-Verzeichnis in das HellionChat-Layout.
|
||||||
the chat window sees, which is fine in the US-/JP-shaped privacy mindset but
|
6. **Verifizieren** unter Einstellungen → Datenschutz → Vorschau aktualisieren, dass die Nachrichten-Anzahl plausibel ist.
|
||||||
hard to reconcile with EU GDPR data minimization rules when the archive
|
|
||||||
contains messages from third parties.
|
|
||||||
|
|
||||||
Forking under EUPL-1.2 is explicitly permitted, the upstream stays
|
### Troubleshooting
|
||||||
authoritative for the chat-replacement engine, and we cherry-pick relevant
|
|
||||||
upstream bugfixes from `Infiziert90/ChatTwo` periodically.
|
|
||||||
|
|
||||||
## Build
|
**Hellion Chat zeigt 0 Nachrichten, obwohl Chat 2 vorher aktiv war:**
|
||||||
|
|
||||||
|
Migration wurde durch eine gesperrte Datei blockiert. Spiel schließen und manuell verschieben:
|
||||||
|
|
||||||
|
Linux / XIVLauncher Core:
|
||||||
|
```bash
|
||||||
|
mv ~/.xlcore/pluginConfigs/ChatTwo/chat-sqlite.db \
|
||||||
|
~/.xlcore/pluginConfigs/HellionChat/chat-sqlite.db
|
||||||
|
[ -d ~/.xlcore/pluginConfigs/ChatTwo/EmoteCacheV1 ] && \
|
||||||
|
mv ~/.xlcore/pluginConfigs/ChatTwo/EmoteCacheV1 \
|
||||||
|
~/.xlcore/pluginConfigs/HellionChat/
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows / XIVLauncher:
|
||||||
|
```powershell
|
||||||
|
Move-Item "$env:AppData\XIVLauncher\pluginConfigs\ChatTwo\chat-sqlite.db" `
|
||||||
|
"$env:AppData\XIVLauncher\pluginConfigs\HellionChat\chat-sqlite.db" -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
Spiel starten, Hellion Chat aktivieren, Verlauf ist zurück.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
Updates erscheinen automatisch in der Plugin-Liste, sobald ein neuer `v0.1.x`-Tag mit GitHub-Release publiziert ist. Keine Neu-Installation nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- .NET 10 SDK (`10.0.104+`) und .NET 9 SDK (`9.0.115+` parallel)
|
||||||
|
- Dalamud-Hooks im XIVLauncher-`addon`-Verzeichnis
|
||||||
|
- VS Code mit C# Dev Kit (oder Rider, JetBrains)
|
||||||
|
- Linux: WireGuard-Mount für Test-Spiel-Setup falls Remote-DB
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux with XIVLauncher Core
|
git clone --recurse-submodules https://github.com/JonKazama-Hellion/HellionChat.git
|
||||||
|
cd HellionChat
|
||||||
|
git remote add upstream https://github.com/Infiziert90/ChatTwo.git
|
||||||
|
|
||||||
|
# Linux: DALAMUD_HOME exportieren falls Hooks nicht im Standardpfad
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# adjust DALAMUD_HOME if your hooks live somewhere else
|
|
||||||
set -a; source .env; set +a
|
set -a; source .env; set +a
|
||||||
|
|
||||||
dotnet build ChatTwo/ChatTwo.csproj
|
dotnet build ChatTwo/ChatTwo.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
The output assembly is `ChatTwo/bin/Debug/HellionChat.dll`. Add the parent
|
Output: `ChatTwo/bin/Debug/HellionChat.dll`. Den Ordner `ChatTwo/bin/Debug` in Dalamud unter Experimental → Dev Plugin Locations eintragen.
|
||||||
directory as a Dev Plugin Location in Dalamud's experimental settings.
|
|
||||||
|
|
||||||
## Branding assets
|
### Build-Konfigurationen
|
||||||
|
|
||||||
`ChatTwo/images/icon.png` is the upstream Chat 2 icon and stays in place
|
| Configuration | Output | Zweck |
|
||||||
until a hand-drawn Hellion logo replaces it. **No AI-generated artwork —
|
| ------------- | ----------------------------------------------------- | -------------------------------- |
|
||||||
ever.**
|
| Debug | `bin/Debug/HellionChat.dll` | Dev-Plugin-Loading |
|
||||||
|
| Release | `bin/Release/HellionChat/latest.zip` + Manifest | Custom-Repo / GitHub Release |
|
||||||
|
|
||||||
## License
|
### Upstream-Sync
|
||||||
|
|
||||||
EUPL-1.2 (same as upstream Chat 2). See `LICENCE`.
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git log --oneline HEAD..upstream/main # Welche Commits gibt es?
|
||||||
|
git cherry-pick -x <commit> # Selektiv übernehmen
|
||||||
|
```
|
||||||
|
|
||||||
## Acknowledgments
|
Konflikte in Upstream-Sprach-Ressourcen (`Language.<lang>.resx`) kommen häufig vor weil Crowdin sie regelmäßig anfasst. Pragmatisch mit `git checkout --theirs` auflösen, da wir sie selbst nicht editieren.
|
||||||
|
|
||||||
- **Infi & Anna (ascclemens)** — original Chat 2 engine, filtering, IPC, all
|
---
|
||||||
the heavy lifting before this fork existed.
|
|
||||||
- **Dalamud team** — the plugin framework underneath everything.
|
|
||||||
- **JonKazama-Hellion** — fork maintenance, privacy/retention/export
|
|
||||||
features, German localization.
|
|
||||||
|
|
||||||
## AI assistance disclosure
|
## Distribution
|
||||||
|
|
||||||
See `AI_DISCLOSURE.md`.
|
| Phase | Version | Distribution |
|
||||||
|
| --------------- | ------------- | -------------------------------------------------- |
|
||||||
|
| Bootstrap | v0.1.x | Eigenes Custom-Repo (`repo.json` im Repo-Root) |
|
||||||
|
| Stable | v1.0 | Eigenes Custom-Repo |
|
||||||
|
| Optional | v1.1+ | Submission ans Dalamud-Main-Plugin-Repo (zusätzlich) |
|
||||||
|
|
||||||
|
`repo.json` wird beim Versions-Bump per Hand aus dem generierten `HellionChat.json` plus den GitHub-Release-Download-Links zusammengebaut. Skript-Automatisierung via GitHub Actions ist geplant aber noch nicht eingerichtet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstatus
|
||||||
|
|
||||||
|
**Version 0.2.0** | Stand: Mai 2026
|
||||||
|
|
||||||
|
Alle Bootstrap-Phasen abgeschlossen:
|
||||||
|
|
||||||
|
- [x] Privacy-Filter (Whitelist + Retention + Cleanup + Export)
|
||||||
|
- [x] First-Run-Wizard mit drei Profilen
|
||||||
|
- [x] Plugin-Identity (eigener Slot, Layout-Migration, Recovery)
|
||||||
|
- [x] Bilinguale UI (EN + DE) mit Live-Sprachwechsel
|
||||||
|
- [x] Hellion-Theme + Hellion-Logo + gebündelter Exo-2-Font
|
||||||
|
- [x] Custom-Repo-Pipeline mit GitHub-Release-Distribution
|
||||||
|
- [x] About-Tab im Hellion-Branding mit License + Disclaimer
|
||||||
|
- [x] AI-Disclosure dokumentiert (Pair-Klassifikation)
|
||||||
|
- [x] Webinterface entfernt (Phase 1.5, Audit-Konsequenz aus 2026-05-02)
|
||||||
|
|
||||||
|
Phase 2 (offen, kein festes Datum):
|
||||||
|
|
||||||
|
- [ ] EmoteCache Path-Traversal-Hardening (`Path.GetFullPath` + StartsWith-Check)
|
||||||
|
- [ ] Race-Hardening für `RetentionLastRunAt` (CompareExchange / Lock)
|
||||||
|
- [ ] MySQL/MariaDB-Backend mit Drei-Stufen-Bestätigung
|
||||||
|
- [ ] PostgreSQL-Backend
|
||||||
|
- [ ] Encryption für sensible Channels (AES-256, lokaler Key)
|
||||||
|
- [ ] WireGuard-Network-Detection (optionaler Filter)
|
||||||
|
- [ ] libnotify-Integration (native Linux-Toasts)
|
||||||
|
- [ ] XDG-Compliance (komplex unter Wine)
|
||||||
|
- [ ] Hand-gezeichnetes Hellion-Logo (Platzhalter aus Hellion-Online-Media-Brand-Repo)
|
||||||
|
- [ ] GitHub-Actions für reproduzierbaren Build und automatischen `repo.json`-Sync
|
||||||
|
- [ ] Submission ans Dalamud-Main-Plugin-Repo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
EUPL-1.2 (gleiche Lizenz wie Upstream Chat 2). Siehe `LICENCE`.
|
||||||
|
|
||||||
|
© 2023–2026 die Chat-2-Autoren (Infi, Anna und die Upstream-Contributors) für die Engine, IPC und Storage-Schicht.
|
||||||
|
© 2026 Hellion Online Media für die Hellion-Chat-Erweiterungen.
|
||||||
|
|
||||||
|
### Acknowledgments
|
||||||
|
|
||||||
|
- **Infi & Anna (ascclemens)** — die Chat-2-Engine, ohne die dieser Fork nicht existieren würde.
|
||||||
|
- **Dalamud-Team** — das Plugin-Framework.
|
||||||
|
- **Chat-2-Crowdin-Community** — Übersetzungen der Upstream-Strings (siehe Settings → Info → "Chat 2 community translators").
|
||||||
|
|
||||||
|
### FFXIV-Disclaimer
|
||||||
|
|
||||||
|
FINAL FANTASY XIV © SQUARE ENIX CO., LTD. Alle Rechte vorbehalten. Hellion Chat ist ein inoffizielles, von Fans erstelltes Plugin und ist weder mit Square Enix verbunden noch von ihnen unterstützt, gesponsert oder genehmigt.
|
||||||
|
|
||||||
|
### KI-Unterstützung
|
||||||
|
|
||||||
|
Siehe [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md) für die Pair-Level-Disclosure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hellion Online Media** | Bad Harzburg | [hellion-media.de](https://hellion-media.de)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
"Author": "JonKazama-Hellion",
|
"Author": "JonKazama-Hellion",
|
||||||
"Name": "Hellion Chat",
|
"Name": "Hellion Chat",
|
||||||
"InternalName": "HellionChat",
|
"InternalName": "HellionChat",
|
||||||
"AssemblyVersion": "0.1.0.0",
|
"AssemblyVersion": "0.2.0.0",
|
||||||
"Description": "Hellion Chat is a privacy-focused, Linux-aware fork of Chat 2.\n\nSame chat replacement you know from upstream, with extra controls\nfor what actually gets stored:\n\n- Channel whitelist for database persistence (GDPR Art. 25)\n- Privacy-First defaults: only your own conversations are kept\n- Failsafe for unknown ChatTypes (default OFF)\n- Independent plugin state (own config + database directory)\n\nBased on Chat 2 by Infi and Anna, licensed under EUPL-1.2.",
|
"Description": "Hellion Chat is built on top of Chat 2 with one removal and a stack\nof privacy controls on top. The /chat2 command, tabs, channel\nfilters, RGB colours, emotes, screenshot mode, IPC integration and\nthe chat replacement window itself work the same. The optional\nwebinterface that Chat 2 ships is intentionally not part of this\nfork because it could not be hardened to the privacy guarantees\nHellion Chat makes by default.\n\nOn top of that, Hellion Chat adds privacy and data-handling controls\ndesigned to align with the modern data protection rules that apply\nacross the EU, the United States and Japan. By default only your own\nconversations are stored; messages from strangers, NPCs and system\nspam stay out of the database. Retention windows are configurable per\nchannel, history can be wiped retroactively, and stored data can be\nexported on demand.\n\nKey additions on top of Chat 2:\n\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three preset profiles (Privacy-First, Casual,\n Full History)\n- Bilingual UI (English and German) with live language switching\n- Independent plugin state — own config file and database directory,\n so Hellion Chat does not share state with the upstream plugin\n\nBased on Chat 2 by Infi and Anna, licensed under EUPL-1.2.",
|
||||||
"ApplicableVersion": "any",
|
"ApplicableVersion": "any",
|
||||||
"RepoUrl": "https://github.com/JonKazama-Hellion/HellionChat",
|
"RepoUrl": "https://github.com/JonKazama-Hellion/HellionChat",
|
||||||
"Tags": [
|
"Tags": [
|
||||||
@@ -19,13 +19,13 @@
|
|||||||
"LoadSync": false,
|
"LoadSync": false,
|
||||||
"CanUnloadAsync": false,
|
"CanUnloadAsync": false,
|
||||||
"LoadPriority": 0,
|
"LoadPriority": 0,
|
||||||
"Punchline": "GDPR-compliant, Linux-aware fork of Chat 2",
|
"Punchline": "Chat 2 with privacy controls aligned to EU, US and JP rules",
|
||||||
"Changelog": "**Hellion Chat 0.1.0 — Initial fork release**\n\nPrivacy\n- Channel whitelist filter in MessageStore.UpsertMessage with a\n Privacy-First default (own conversations only)\n- Per-channel retention with a 24-hour idempotent background sweep\n (default OFF; spec defaults of 365 / 90 / 30 days)\n- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM\n- Export to Markdown / JSON / CSV via Dalamud's file dialog\n (GDPR Art. 15 right of access)\n\nOnboarding\n- First-run wizard with three profiles: Privacy-First / Casual /\n Full History\n- Configuration v6 → v7 migration that seeds defaults and shows a\n notification once on update\n- One-shot migration from upstream Chat 2's pluginConfigs layout\n so the fork uses pluginConfigs/HellionChat without losing state\n- Migrate3 idempotency recovery for half-migrated databases\n\nLook & feel\n- Localized UI (English and German) with live language switching\n- Hellion industrial HUD theme with cyan-teal action accents,\n slate-violet tabs, amber active highlights and a window-opacity\n slider for combat-friendly transparency\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).",
|
"Changelog": "**Hellion Chat 0.2.0 — Webinterface removed**\n\nFollowing an internal security and consistency audit the upstream\nwebinterface has been removed in its entirety. Hardening it to the\nprivacy guarantees Hellion Chat makes by default would have meant\nrewriting the auth flow (the upstream code uses a five-digit\nnumeric code from System.Random), changing the default bind address\n(currently every interface), reworking cookie handling and adding\nthe privacy filter to the live message stream that the webinterface\nwas broadcasting around it. The cumulative cost did not match the\nniche use case for a fork that wants less network surface, not more.\n\nWhat changed in this release:\n\n- Settings tab \"Webinterface\" is gone, the corresponding\n Configuration fields (WebinterfaceEnabled / AutoStart / Password /\n Port / AuthStore / MaxLinesToSend) are dropped and stale entries\n fall out of the JSON on the next save automatically\n- The whole ChatTwo/Http tree, the bundled Svelte frontend in\n websiteBuild.zip and the WebinterfaceUtil helper are deleted\n- Watson.Lite (the HTTP server) and Newtonsoft.Json (only used by\n the webinterface JSON wire format) are removed from the\n package references\n- DbViewer's \"Chat2 JSON Export\" button is dropped because it\n serialised the database into the webinterface message protocol;\n the Privacy tab's MessageExporter (Markdown, JSON, CSV with\n channel and date filters) covers the same ground without the\n proprietary shape\n- About tab notes the absence so users coming from Chat 2 do not\n look for it\n- Configuration version bumps from 7 to 8 with a one-shot\n notification (EN + DE)\n\nNo changes to the privacy filter, retention sweep, first-run wizard\nor export pipeline. Existing chat history is preserved.\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n**Hellion Chat 0.1.2 — About tab rebrand, DBViewer polish**\n\n- About tab now shows Hellion-specific maintainer, license, EU/US/JP\n disclaimer and SQUARE ENIX disclaimer instead of the inherited\n Chat 2 contact info; original ChatTwo translator credits stay\n visible under a clearly labelled upstream tree node\n- Localization clarified: Hellion-specific German strings are\n maintained by the fork maintainer, the Crowdin contributor list\n only covers the inherited upstream strings\n- Cherry-picked DBViewer UI improvements from upstream Chat 2\n (auto-scroll-reset on page change, tooltips on date reset,\n folder export, page arrows, localized export-running messages)\n- README rewritten in the Hellion project style with a tech-stack\n table, architecture tree, database column list, install guide,\n upstream-sync workflow notes and project-status checklist\n\n**Hellion Chat 0.1.1 — Packaging and migration fixes**\n\n- Plugin icon now ships inside the bundle, so the Hellion logo\n renders locally in the Dalamud plugin list once installed (the\n previous release relied only on the remote IconUrl)\n- Plugin icon downsampled from 1024×1024 to 256×256 to match the\n rendered size; loads faster and caches better\n- Migration from upstream Chat 2 is more robust: each file move is\n wrapped individually, a locked SQLite database no longer aborts\n the rest of the migration, and a warning notification fires when\n any file is held open (with a hint to disable Chat 2 and restart\n the game)\n- README ships a step-by-step migration guide (fresh install versus\n coming from Chat 2) and a troubleshooting section with manual\n recovery commands for Linux and Windows\n\n**Hellion Chat 0.1.0 — Initial fork release**\n\nPrivacy\n- Channel whitelist filter in MessageStore.UpsertMessage with a\n Privacy-First default (own conversations only)\n- Per-channel retention with a 24-hour idempotent background sweep\n- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM\n- Export to Markdown / JSON / CSV via Dalamud's file dialog\n\nOnboarding\n- First-run wizard with three profiles: Privacy-First / Casual /\n Full History\n- Configuration migration that seeds defaults on update\n- One-shot migration from upstream Chat 2's pluginConfigs layout\n- Migrate3 idempotency recovery for half-migrated databases\n\nLook & feel\n- Localized UI (English and German) with live language switching\n- Industrial HUD theme with cyan-teal action accents, slate-violet\n tabs, amber active highlights and a window-opacity slider\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).",
|
||||||
"AcceptsFeedback": true,
|
"AcceptsFeedback": true,
|
||||||
"DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.1.0/latest.zip",
|
"DownloadLinkInstall": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.2.0/latest.zip",
|
||||||
"DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.1.0/latest.zip",
|
"DownloadLinkUpdate": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.2.0/latest.zip",
|
||||||
"DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.1.0/latest.zip",
|
"DownloadLinkTesting": "https://github.com/JonKazama-Hellion/HellionChat/releases/download/v0.2.0/latest.zip",
|
||||||
"TestingAssemblyVersion": "0.1.0.0",
|
"TestingAssemblyVersion": "0.2.0.0",
|
||||||
"IconUrl": "https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/ChatTwo/images/icon.png",
|
"IconUrl": "https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/ChatTwo/images/icon.png",
|
||||||
"ImageUrls": [],
|
"ImageUrls": [],
|
||||||
"DownloadCount": 0,
|
"DownloadCount": 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user