Compare commits
384 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3fdcdf43d | |||
| f4ea460644 | |||
| d5735d8dcc | |||
| 80b48ac3ad | |||
| cddd29a986 | |||
| 799fdb67cc | |||
| 69fa0fecbd | |||
| fd5f970a8b | |||
| fee2459e73 | |||
| 63cad62c89 | |||
| dca5de4085 | |||
| 8edc3c70d3 | |||
| 3c33acf6d7 | |||
| c8ba8c1cd0 | |||
| 94e4828aeb | |||
| 1d88cb4c42 | |||
| c5fe69f0d3 | |||
| b46d3ad0a8 | |||
| e33cf0dcb9 | |||
| 0d016aaa5d | |||
| 5b972238bb | |||
| 7ac1eb3fd4 | |||
| db48f27842 | |||
| f8b5c14509 | |||
| 28e4b30cd6 | |||
| 4510c1e404 | |||
| 6b44f549b4 | |||
| ae1436b103 | |||
| 2684c31f10 | |||
| bdd64cad07 | |||
| 28ea2fa553 | |||
| dd597fca44 | |||
| b9d3ff8f26 | |||
| df3d5d78d6 | |||
| 2e057ce6c4 | |||
| e5dbc333fa | |||
| d0ec94c3e6 | |||
| cafb6faa39 | |||
| b8d289a847 | |||
| f16d8f5c78 | |||
| eabb39ba86 | |||
| b489ac946c | |||
| 8d9151c74a | |||
| 4ecbaf2a4b | |||
| 3e4601a0c8 | |||
| 61d5a33683 | |||
| 7ed689587b | |||
| 612bf8814f | |||
| be17472cd5 | |||
| 8bf50151d5 | |||
| 57da455700 | |||
| 0982b68a4a | |||
| 0fc88e480a | |||
| 7eb50e2c8d | |||
| 58e754c169 | |||
| 83064cd40b | |||
| 5ca3b73b7f | |||
| 570a6f071c | |||
| 11ad5db127 | |||
| 5c550e8587 | |||
| eb2a04c56b | |||
| 3f714d6f38 | |||
| 747e0e1574 | |||
| debfdcd278 | |||
| f85daf3dbe | |||
| 3b24b2adc4 | |||
| c493340104 | |||
| 3a7f9b3adb | |||
| b1b6402827 | |||
| 7d73def53d | |||
| c4c85cf4b8 | |||
| a37882893e | |||
| 702e4ca160 | |||
| 1ebc7b820f | |||
| 3152312890 | |||
| 4000bbd199 | |||
| 3cabdf3e15 | |||
| 05c28f7e92 | |||
| 699d4ede1d | |||
| 31673fdff6 | |||
| 07337108bc | |||
| fd82033666 | |||
| cd01fa63a1 | |||
| b81c50b433 | |||
| 355a57089b | |||
| cf7ab6226c | |||
| 03da6d58a4 | |||
| 90a4544ab2 | |||
| 9b4557f197 | |||
| e594258cf3 | |||
| bb863c5b32 | |||
| 0797d1a517 | |||
| 8dc8b87580 | |||
| baeec369e6 | |||
| a1f2b22b19 | |||
| 5931f2f301 | |||
| 0b25df0ea7 | |||
| b75c7b177a | |||
| ccc5a4e17a | |||
| daa800c8b1 | |||
| a531973c0d | |||
| 4c8b0da3da | |||
| 9a8a014795 | |||
| 9640d336a6 | |||
| 12ce015d83 | |||
| f455bf4736 | |||
| 9bc66c7cf3 | |||
| e9022de150 | |||
| cb327b8073 | |||
| 1c354d18bb | |||
| 0ed88691c2 | |||
| c64fcfd4d1 | |||
| 6689cdb968 | |||
| 345aa3ea2a | |||
| 1ffc41f97d | |||
| 36b92f0520 | |||
| cb612044ea | |||
| 71081d8344 | |||
| 54bfeb0f6f | |||
| 5f83c70292 | |||
| 3d7883ee01 | |||
| e4ee7aaafa | |||
| aff2528a6f | |||
| 0d2ee63420 | |||
| de9d1ac60b | |||
| 19f7099af0 | |||
| f8a734d93f | |||
| 3f7e86b32e | |||
| e5bf375b42 | |||
| 93329087a9 | |||
| 72d568e5b3 | |||
| c9dfd024b2 | |||
| 8c624a0032 | |||
| 079e280226 | |||
| 3bdf45c29c | |||
| d257a41660 | |||
| 36f2bbd8d1 | |||
| da291b7fca | |||
| c8485233d5 | |||
| 2d768e4edb | |||
| e58376bf50 | |||
| dceb028184 | |||
| 33a4d94c44 | |||
| b2f158f893 | |||
| da6da32651 | |||
| 477591e2fa | |||
| ddb293399e | |||
| 7494b001a2 | |||
| 9f0a40bedc | |||
| 8da05c3080 | |||
| 5b5f52f86e | |||
| af3caa9b96 | |||
| 206b25b8d6 | |||
| 00deef01a4 | |||
| 74e2c655f0 | |||
| fa91c4e847 | |||
| 1125caabca | |||
| eead8d813c | |||
| 28b20ad6d3 | |||
| a88ec1714d | |||
| 0110295e7d | |||
| 9752206996 | |||
| 2f4e4c33ca | |||
| b30b6b135c | |||
| df0844b737 | |||
| 21d703bf0b | |||
| 4048f0b8d0 | |||
| 2d0e9ae70c | |||
| eaf11dcebe | |||
| 9bd8262191 | |||
| ddb00a0836 | |||
| aec8ba15f2 | |||
| c84eae199b | |||
| 9ead8098f5 | |||
| b190456005 | |||
| ebc0999a8e | |||
| c0b3edb20c | |||
| 64cadcf87b | |||
| 0165cba966 | |||
| 3da550c2fc | |||
| 4b43fdb0ad | |||
| 56621669b2 | |||
| ed2a0f7374 | |||
| 59e86cd8dd | |||
| a74e3da030 | |||
| b8ed2a1ce5 | |||
| e6c6c02780 | |||
| ab9ebedeee | |||
| 11af4ce4c4 | |||
| 8a78390a15 | |||
| 23e47e06c0 | |||
| ff60576f3c | |||
| 5b5bacfc41 | |||
| eb8b7be2f5 | |||
| eb05e04f79 | |||
| 2f0affcdbb | |||
| dfa7c47887 | |||
| acf799440e | |||
| 3e98b9103f | |||
| 4a613f7acb | |||
| af5f4d380a | |||
| ecf1e93a1b | |||
| e404a2e0d9 | |||
| d485f5ea1f | |||
| b48684ce5a | |||
| a11c8bc6e9 | |||
| 985a284e7d | |||
| e629518550 | |||
| c28c972ae3 | |||
| bc0f44712f | |||
| f663cb3c14 | |||
| 5a9c2018b0 | |||
| a1cdae05d0 | |||
| c17f5ae516 | |||
| a2db8cb639 | |||
| 507efc8cda | |||
| 6f3cf2f3ce | |||
| c979a05d6c | |||
| c53e453341 | |||
| 2519b413f8 | |||
| e5ac4faf7b | |||
| 0c26d1aa67 | |||
| 8b13ba1fdc | |||
| 52da5d5e23 | |||
| 916640fb60 | |||
| feeb1df4eb | |||
| f2086865ce | |||
| 15a89dd6e7 | |||
| 53952717c0 | |||
| fcbbd174b6 | |||
| d41cea0031 | |||
| c943a2cff3 | |||
| abcd0847ef | |||
| 2f52cbb7d4 | |||
| 9103bbb892 | |||
| 8f9c01d322 | |||
| af4651b37e | |||
| 485dc4e1b4 | |||
| c878d24d11 | |||
| cb5c940a84 | |||
| dd3a0ea069 | |||
| 4bf6c3ef1f | |||
| 2378ce6bf2 | |||
| b85db24601 | |||
| cae7d76206 | |||
| 4c6d52e652 | |||
| cbfdfe35be | |||
| 537b96c79f | |||
| d3d28924e6 | |||
| 48f1fb5ba1 | |||
| 0b13efd0b5 | |||
| 289fe2eb78 | |||
| fe9e66b0ff | |||
| 990edd8300 | |||
| db95ec7dff | |||
| 7e036c1d00 | |||
| 1c511a147d | |||
| f093d93761 | |||
| e7c8667497 | |||
| 497197eb2c | |||
| 08b2ffc600 | |||
| 8db3eca46c | |||
| 4d54eabdac | |||
| 698eb01bbe | |||
| a3fbaab173 | |||
| 57291e925d | |||
| 8e9332ac8c | |||
| fcb72e2b78 | |||
| 7012e8c0d8 | |||
| 176474ec2a | |||
| 9fc8749d15 | |||
| 09634b416d | |||
| 393ef175bf | |||
| d63c710836 | |||
| fa9baa3929 | |||
| 4c18b9a62b | |||
| 26c12c3410 | |||
| 76a4de1192 | |||
| 55aeaea5b9 | |||
| e6d25f3e38 | |||
| 740c7cf1bb | |||
| 71f0b63079 | |||
| 8ee54bb8df | |||
| e3ce41306e | |||
| af7c757e63 | |||
| a10c115b9b | |||
| 6d49dbad3e | |||
| a651b3b9ad | |||
| 3f2e56be67 | |||
| feb6e262e4 | |||
| 1d557f1b0e | |||
| fea4965889 | |||
| 91663832f0 | |||
| 9cf1b19801 | |||
| 1f7f0945c5 | |||
| cd6afb32cb | |||
| 7d5496e959 | |||
| ed426556e1 | |||
| 96c445356b | |||
| 1c2d361b77 | |||
| 581aae1735 | |||
| 70109e1896 | |||
| 0df5819a88 | |||
| 3fbbe8543f | |||
| 03dfb8e3da | |||
| a987e97610 | |||
| ecd46ed630 | |||
| f2f7599f81 | |||
| ac158907ea | |||
| 9506af49db | |||
| c882eac1ca | |||
| 7a6b44048a | |||
| 0d39d59a04 | |||
| f0e0db55e3 | |||
| f207239d56 | |||
| ccf2ec9f12 | |||
| aff7a5e7ce | |||
| cd84ca2b3f | |||
| 7c645afa1d | |||
| 24c1e0e754 | |||
| 9f6a0807d1 | |||
| 15f83c8b0e | |||
| c7253bdf02 | |||
| cf10c566dd | |||
| acfe838bc6 | |||
| 9e1f559644 | |||
| 2c79a67dae | |||
| 1687271bfd | |||
| cb5457ba2e | |||
| a701f6c103 | |||
| 8cad8651d2 | |||
| 61b547606c | |||
| 059cfa6e28 | |||
| 71d84e4486 | |||
| 92301869ed | |||
| c3d06a9c94 | |||
| 911c870e24 | |||
| 8cda19d993 | |||
| 62621ba855 | |||
| 497c259031 | |||
| 9ad9d2acd2 | |||
| 1b63765caa | |||
| 61764459ed | |||
| 1b7f2c40e6 | |||
| 93d52ae819 | |||
| 48b3d5c6b1 | |||
| e9a9d8a01c | |||
| a155a57f33 | |||
| 90b83a0690 | |||
| f10301c3e4 | |||
| 8571a936a4 | |||
| 3f6144836c | |||
| 53c432a635 | |||
| 340cadf3b9 | |||
| 8d6868aef6 | |||
| 6e8fcc8cc3 | |||
| 57670ffc76 | |||
| 2144eedd76 | |||
| 43daef83de | |||
| 4a9ad426e7 | |||
| 13beda3a8d | |||
| 18c05af4db | |||
| df6e1e1cbd | |||
| 01b1a14511 | |||
| b6af8d559c | |||
| 22dbfc2e24 | |||
| 2f3b01732c | |||
| 88803382dd | |||
| 74c51163c7 | |||
| 877ff4ba18 | |||
| ad2feb5a27 | |||
| 46b63ffdd1 | |||
| 4ba5004322 | |||
| 3584c94523 | |||
| 303729f3d3 | |||
| 12085ff1e2 | |||
| e4593a0fda | |||
| 3fc42963ae | |||
| 7c52e890e6 | |||
| 4d977d5118 | |||
| ddd72a878e | |||
| 66450dd518 | |||
| 7de28ef9b2 | |||
| da3c1f6832 |
+13
-2
@@ -7,12 +7,21 @@ insert_final_newline=false
|
||||
|
||||
# JetBrains Rider custom properties for code formatting styles
|
||||
resharper_csharp_brace_style=next_line
|
||||
# Allman für standard Tooling (VS Code)
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
|
||||
# Switch-Einrückung
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
|
||||
resharper_csharp_braces_for_foreach=not_required
|
||||
resharper_csharp_braces_for_for=not_required
|
||||
resharper_csharp_braces_for_while=not_required
|
||||
charset=utf-8
|
||||
end_of_line=crlf
|
||||
end_of_line=lf
|
||||
|
||||
# Microsoft .NET properties
|
||||
csharp_new_line_before_members_in_object_initializers=false
|
||||
@@ -142,7 +151,7 @@ resharper_web_config_module_not_resolved_highlighting=warning
|
||||
resharper_web_config_type_not_resolved_highlighting=warning
|
||||
resharper_web_config_wrong_module_highlighting=warning
|
||||
|
||||
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}]
|
||||
[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.prettierrc.json,.markdownlint.json,.yamllint.json,.stylelintrc,bowerrc,jest.config}]
|
||||
indent_style=space
|
||||
indent_size=2
|
||||
|
||||
@@ -154,3 +163,5 @@ indent_size=2
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
tab_width=4
|
||||
[*.md]
|
||||
trim_trailing_whitespace=false
|
||||
+7
-1
@@ -1,2 +1,8 @@
|
||||
# Generated files
|
||||
ChatTwo/Resources/Language.*.resx linguist-generated=true
|
||||
HellionChat/Resources/Language.*.resx linguist-generated=true
|
||||
* text=auto eol=lf
|
||||
*.cs text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Build
|
||||
|
||||
# Verifies that every push to main and every PR still builds against the
|
||||
# current Dalamud staging branch. Does not produce release artefacts; the
|
||||
# release workflow handles that on tag.
|
||||
#
|
||||
# Linux runner: gitea.com Cloud Actions provides ubuntu-latest. The plugin
|
||||
# csproj targets net10.0-windows, but `dotnet build` cross-compiles on
|
||||
# Linux as long as the Dalamud staging assemblies are present at the
|
||||
# expected lookup path ($(HOME)/.xlcore/dalamud/Hooks/dev/, which the
|
||||
# Dalamud SDK 15 uses on Linux).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Minimum permissions for a build-only workflow: read the repo, nothing
|
||||
# else. Closes the CodeQL "Workflow does not contain permissions" alert
|
||||
# and matches the principle-of-least-privilege the security guide
|
||||
# recommends for workflows that don't push or create releases.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (Release)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Download Dalamud staging
|
||||
run: |
|
||||
hooks="$HOME/.xlcore/dalamud/Hooks/dev"
|
||||
mkdir -p "$hooks"
|
||||
curl -fsSL https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -o dalamud.zip
|
||||
unzip -oq dalamud.zip -d "$hooks"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore HellionChat/HellionChat.csproj
|
||||
|
||||
- name: Build (Release)
|
||||
run: dotnet build HellionChat/HellionChat.csproj --configuration Release --no-restore
|
||||
@@ -0,0 +1,250 @@
|
||||
name: Forge Announce
|
||||
|
||||
# Triggered when a vX.Y.Z tag is pushed. Reads .github/forge-posts/<tag>.md
|
||||
# (Frontmatter + DE bullet body) and the matching English block from
|
||||
# HellionChat/HellionChat.yaml, builds a Discord-Webhook embed and posts
|
||||
# it to the Hellion Forge #changelog channel.
|
||||
#
|
||||
# Decoupled from release.yml: a fail here does not block the GitHub
|
||||
# release, and a fail there does not block the announce. Spec lives in
|
||||
# the Vault under "Hellion Chat Forge-Auto-Announce Spec".
|
||||
#
|
||||
# Security: the only user-controlled inputs that enter run-steps are the
|
||||
# tag name and the frontmatter values from a repo-internal markdown file.
|
||||
# Tag name is read via env: (TAG_NAME, $env:TAG_NAME) and validated against
|
||||
# ^v\d+\.\d+\.\d+$ before any string interpolation. Frontmatter values are
|
||||
# parsed by regex with explicit length caps. No webhook event payload data
|
||||
# (issue titles, PR bodies, commit messages, etc.) flows into run-steps.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to (re)post, e.g. v1.1.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
name: Post changelog to Hellion Forge
|
||||
runs-on: ubuntu-latest
|
||||
# The DISCORD_FORGE_WEBHOOK secret is set as a repo-level Actions Secret
|
||||
# on Gitea (Settings → Actions → Secrets). Repo-level secrets are in
|
||||
# scope for every job by default, no environment: declaration needed.
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
# On push:tags github.ref points at the tag commit; on workflow_dispatch
|
||||
# the user supplies the tag explicitly. Always check out that tag so
|
||||
# the yaml + forge-posts file are read from the tagged tree, not main.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
# Build embed-payload as a JSON file on disk. PowerShell-Core (pwsh)
|
||||
# ships pre-installed on ubuntu-latest so we get the same scripting
|
||||
# patterns release.yml uses on windows-latest. Tag is read via env: to
|
||||
# treat it as a string variable rather than inline shell text, and
|
||||
# validated against the semver regex before any interpolation.
|
||||
- name: Build embed payload
|
||||
id: build
|
||||
shell: pwsh
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
$tag = $env:TAG_NAME
|
||||
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
|
||||
throw "V1: Refusing to announce non-semver tag: $tag"
|
||||
}
|
||||
$version = $tag.Substring(1)
|
||||
|
||||
# ---------- Forge-Post-Datei lesen ----------
|
||||
$forgePath = ".github/forge-posts/$tag.md"
|
||||
if (-not (Test-Path $forgePath)) {
|
||||
throw "V2: Forge-Post-Datei für $tag fehlt unter .github/forge-posts/. Datei vor dem Tag anlegen, dann Tag re-pushen oder workflow_dispatch."
|
||||
}
|
||||
$forgeRaw = Get-Content -Path $forgePath -Raw
|
||||
|
||||
# Frontmatter (--- … ---) am Datei-Anfang
|
||||
if ($forgeRaw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)\z') {
|
||||
throw "V3: Frontmatter (---) fehlt oder ist defekt in $forgePath"
|
||||
}
|
||||
$fmText = $matches[1]
|
||||
$deBody = $matches[2].Trim()
|
||||
|
||||
$subtitle = $null
|
||||
$versionsnatur = $null
|
||||
foreach ($line in ($fmText -split "`r?`n")) {
|
||||
if ($line -match '^subtitle:\s*"?([^"]*)"?\s*$') { $subtitle = $matches[1] }
|
||||
if ($line -match '^versionsnatur:\s*"?([^"]*)"?\s*$') { $versionsnatur = $matches[1] }
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($subtitle)) { throw "V3: Frontmatter-Feld 'subtitle' fehlt in $forgePath" }
|
||||
if ([string]::IsNullOrWhiteSpace($versionsnatur)) { throw "V3: Frontmatter-Feld 'versionsnatur' fehlt in $forgePath" }
|
||||
if ($subtitle.Length -gt 60) { throw "V4: Frontmatter-Feld 'subtitle' überschreitet Limit ($($subtitle.Length) Char, max 60)" }
|
||||
if ($versionsnatur.Length -gt 40) { throw "V4: Frontmatter-Feld 'versionsnatur' überschreitet Limit ($($versionsnatur.Length) Char, max 40)" }
|
||||
if ([string]::IsNullOrWhiteSpace($deBody)) { throw "V3: DE-Body fehlt in $forgePath" }
|
||||
|
||||
# ---------- EN-Block aus HellionChat.yaml ziehen ----------
|
||||
# 1:1 Pattern aus release.yml — gleicher Header-Marker, gleiches
|
||||
# Trailer-Verhalten. Bei Drift die zwei Workflows synchron halten.
|
||||
$yamlPath = "HellionChat/HellionChat.yaml"
|
||||
$raw = Get-Content -Path $yamlPath -Raw
|
||||
$marker = "changelog: |-"
|
||||
$idx = $raw.IndexOf($marker)
|
||||
if ($idx -lt 0) { throw "V5: changelog-Block nicht gefunden in $yamlPath" }
|
||||
$afterMarker = $raw.Substring($idx + $marker.Length)
|
||||
$changelogBody = (($afterMarker -split "`r?`n") | ForEach-Object {
|
||||
if ($_ -match '^ ') { $_.Substring(4) } else { $_ }
|
||||
}) -join "`n"
|
||||
|
||||
$header = "**v$version "
|
||||
$start = $changelogBody.IndexOf($header)
|
||||
if ($start -lt 0) {
|
||||
throw "V5: No changelog entry for version $version found in $yamlPath. Update the changelog block before tagging."
|
||||
}
|
||||
$rest = $changelogBody.Substring($start)
|
||||
$nextHdr = $rest.IndexOf("`n`n**v", 1)
|
||||
$trailer = $rest.IndexOf("`n`n---")
|
||||
if ($nextHdr -ge 0 -and ($trailer -lt 0 -or $nextHdr -lt $trailer)) {
|
||||
$enBlock = $rest.Substring(0, $nextHdr).TrimEnd()
|
||||
} elseif ($trailer -ge 0) {
|
||||
$enBlock = $rest.Substring(0, $trailer).TrimEnd()
|
||||
} else {
|
||||
$enBlock = $rest.TrimEnd()
|
||||
}
|
||||
|
||||
# ---------- Embed-Felder + Per-Field-Caps (Discord-Hard-Limits) ----------
|
||||
# Discord enforces per-embed-field limits separately from the
|
||||
# combined-total limit. We split the DE and EN blocks into two
|
||||
# embeds that share the same release URL so Discord stitches
|
||||
# them into one visual card. Hard caps per Discord docs:
|
||||
# description: 4096 per embed
|
||||
# title: 256 per embed
|
||||
# footer.text: 2048 per embed
|
||||
# combined sum across all embeds: 6000
|
||||
$title = "Hellion Chat $version — $subtitle"
|
||||
$deDesc = "**Deutsch**`n`n$deBody"
|
||||
$enDesc = "**English**`n`n$enBlock"
|
||||
$footerText = "Hellion Forge · $versionsnatur"
|
||||
$releaseUrl = "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/tag/$tag"
|
||||
|
||||
if ($deDesc.Length -gt 4096) {
|
||||
throw "V6a: DE-Body too long for one embed ($($deDesc.Length) chars, max 4096). Trim .github/forge-posts/$tag.md or post the announcement manually (see forge style §8)."
|
||||
}
|
||||
if ($enDesc.Length -gt 4096) {
|
||||
throw "V6b: EN-Block too long for one embed ($($enDesc.Length) chars, max 4096). Trim the changelog entry in HellionChat/HellionChat.yaml or post manually."
|
||||
}
|
||||
$totalChars = $title.Length + $deDesc.Length + $enDesc.Length + $footerText.Length
|
||||
if ($totalChars -gt 6000) {
|
||||
throw "V6c: Combined embed chars $totalChars exceed Discord's 6000-total limit. Major-Release detected — post manually via Bot/Multi-Embed (see forge style §8)."
|
||||
}
|
||||
Write-Host "Embed-Caps OK: de=$($deDesc.Length)/4096, en=$($enDesc.Length)/4096, total=$totalChars/6000"
|
||||
|
||||
# ---------- Embed-Payload bauen (zwei Embeds, gleiche url) ----------
|
||||
# Sharing the same `url` tells Discord to render both embeds as a
|
||||
# single contiguous card block. The title sits on the first embed,
|
||||
# the footer + timestamp on the last so it reads as one post.
|
||||
$payload = [ordered]@{
|
||||
username = "Forge Herald"
|
||||
avatar_url = "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png"
|
||||
content = "<@&1500489631555260446>"
|
||||
allowed_mentions = [ordered]@{
|
||||
parse = @()
|
||||
roles = @("1500489631555260446")
|
||||
}
|
||||
embeds = @(
|
||||
[ordered]@{
|
||||
title = $title
|
||||
url = $releaseUrl
|
||||
color = 12730636
|
||||
description = $deDesc
|
||||
},
|
||||
[ordered]@{
|
||||
url = $releaseUrl
|
||||
color = 12730636
|
||||
description = $enDesc
|
||||
footer = [ordered]@{ text = $footerText }
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
$payloadJson = $payload | ConvertTo-Json -Depth 8 -Compress
|
||||
# Ausgabe-Datei ohne trailing newline für sauberes curl --data-binary @-
|
||||
[System.IO.File]::WriteAllText("$PWD/embed-payload.json", $payloadJson, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-Host "Payload size: $($payloadJson.Length) chars"
|
||||
Write-Host "Embed title: $title"
|
||||
Write-Host "Embed footer: $footerText"
|
||||
|
||||
# POST to the Hellion Forge changelog webhook. curl from PowerShell-Core
|
||||
# so we can pipe the payload via stdin (--data-binary @-) and keep
|
||||
# secrets out of process arg lists. One retry on 5xx, hard fail on 4xx.
|
||||
- name: POST to Hellion Forge webhook
|
||||
shell: pwsh
|
||||
env:
|
||||
DISCORD_FORGE_WEBHOOK: ${{ secrets.DISCORD_FORGE_WEBHOOK }}
|
||||
run: |
|
||||
if ([string]::IsNullOrEmpty($env:DISCORD_FORGE_WEBHOOK)) {
|
||||
throw "V7: DISCORD_FORGE_WEBHOOK secret is empty. Check Settings → Environments → Webhook."
|
||||
}
|
||||
|
||||
$payloadFile = "$PWD/embed-payload.json"
|
||||
if (-not (Test-Path $payloadFile)) {
|
||||
throw "Embed payload file missing — previous step did not produce embed-payload.json"
|
||||
}
|
||||
|
||||
$maxAttempts = 2
|
||||
$attempt = 0
|
||||
while ($attempt -lt $maxAttempts) {
|
||||
$attempt++
|
||||
Write-Host "POST attempt $attempt of $maxAttempts"
|
||||
$tmpResp = "$PWD/.webhook-response"
|
||||
$tmpHeaders = "$PWD/.webhook-headers"
|
||||
# --silent suppresses progress; --show-error prints errors so
|
||||
# the workflow log shows what happened. -w prints HTTP status
|
||||
# to stdout for inspection. -o captures body for diagnosis,
|
||||
# -D captures headers.
|
||||
$rawStatus = Get-Content $payloadFile -Raw |
|
||||
curl --silent --show-error `
|
||||
--header 'Content-Type: application/json' `
|
||||
--data-binary '@-' `
|
||||
-D $tmpHeaders `
|
||||
-o $tmpResp `
|
||||
-w '%{http_code}' `
|
||||
"$env:DISCORD_FORGE_WEBHOOK"
|
||||
$status = [int]$rawStatus
|
||||
Write-Host "HTTP status: $status"
|
||||
|
||||
if ($status -ge 200 -and $status -lt 300) {
|
||||
Write-Host "Forge announce POST succeeded."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$bodySnippet = ""
|
||||
if (Test-Path $tmpResp) {
|
||||
$bodySnippet = (Get-Content $tmpResp -Raw -ErrorAction SilentlyContinue)
|
||||
if ($bodySnippet.Length -gt 500) { $bodySnippet = $bodySnippet.Substring(0, 500) + " …" }
|
||||
}
|
||||
|
||||
if ($status -ge 400 -and $status -lt 500) {
|
||||
# E2: 4xx is permanent — webhook revoked, channel deleted,
|
||||
# payload malformed. No retry.
|
||||
throw "E2: Discord-Webhook returned permanent $status. Body: $bodySnippet"
|
||||
}
|
||||
|
||||
# E1: 5xx (or transport-level fail with status 0) — wait + retry once
|
||||
if ($attempt -lt $maxAttempts) {
|
||||
Write-Host "Transient $status — sleeping 30s before retry."
|
||||
Start-Sleep -Seconds 30
|
||||
} else {
|
||||
throw "E1: Discord-Webhook returned transient $status after $maxAttempts attempts. Body: $bodySnippet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
name: Release
|
||||
|
||||
# Triggered when a vX.Y.Z tag is pushed. Builds the plugin against the
|
||||
# current Dalamud staging branch, locates the latest.zip produced by
|
||||
# DalamudPackager and attaches it to the matching Gitea Release.
|
||||
#
|
||||
# User-controlled inputs touched by this workflow:
|
||||
# - the tag name (filtered by on.tags = v*, validated again at runtime
|
||||
# against ^v\d+\.\d+\.\d+$ before being used in any string)
|
||||
# All other values are either repo-controlled (paths under
|
||||
# HellionChat/bin/Release derived from find / Get-ChildItem) or pinned
|
||||
# URLs to goatcorp / gitea. Nothing from a webhook event payload (issue/PR
|
||||
# titles, commit messages, etc.) flows into a run-step.
|
||||
#
|
||||
# Linux runner: gitea.com Cloud Actions only ships ubuntu-latest. The
|
||||
# plugin csproj targets net10.0-windows, `dotnet build` cross-compiles on
|
||||
# Linux when the Dalamud staging assemblies sit under $(HOME)/.xlcore/...
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# Manual recovery trigger. Use Gitea's "Run workflow" UI and select the
|
||||
# tag (e.g. v1.4.4) from the Ref dropdown - not main. The Validate tag
|
||||
# ref step below hard-fails if a non-tag ref is selected, because the
|
||||
# release-action reads GITHUB_REF directly and rejects anything that
|
||||
# does not start with refs/tags/.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and attach release ZIP
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
# release-action@main reads GITHUB_REF directly (its action.yml
|
||||
# does not declare a tag_name input). Validate up-front so manual
|
||||
# dispatches from a branch ref fail loud here instead of burning
|
||||
# a full build before the final step errors out with "ref X is
|
||||
# not a tag".
|
||||
- name: Validate tag ref
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
|
||||
echo "::error::Release workflow must run on a v*.X.Y tag ref, got ${GITHUB_REF}"
|
||||
echo "::error::Push a tag, or pick the tag (not main) in the workflow_dispatch Ref dropdown."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Download Dalamud staging
|
||||
run: |
|
||||
hooks="$HOME/.xlcore/dalamud/Hooks/dev"
|
||||
mkdir -p "$hooks"
|
||||
curl -fsSL https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -o dalamud.zip
|
||||
unzip -oq dalamud.zip -d "$hooks"
|
||||
|
||||
- name: Build (Release)
|
||||
run: dotnet build HellionChat/HellionChat.csproj --configuration Release
|
||||
|
||||
- name: Locate latest.zip
|
||||
id: locate
|
||||
run: |
|
||||
zip="$(find HellionChat/bin/Release -name latest.zip -print -quit)"
|
||||
if [ -z "$zip" ]; then
|
||||
echo "latest.zip not found under HellionChat/bin/Release" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found: $zip"
|
||||
echo "path=$zip" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build a release body from the matching changelog block in
|
||||
# HellionChat.yaml plus a static install / docs footer. Fails the
|
||||
# workflow if no block exists for the tagged version, which is the
|
||||
# automated counterpart to the "yaml + repo.json + release body
|
||||
# kept in sync" rule.
|
||||
#
|
||||
# GITHUB_REF_NAME is read via env: (not ${{ }} interpolation) so the
|
||||
# tag value is treated as a PowerShell variable, not as inline shell
|
||||
# text. The strict regex below rejects anything that is not a clean
|
||||
# semver tag before it is used to build a string.
|
||||
- name: Generate release body
|
||||
shell: pwsh
|
||||
env:
|
||||
# github.ref_name is the tag because Validate tag ref above
|
||||
# already enforced refs/tags/v*. Read via env: so the value
|
||||
# is a PowerShell variable, not inline shell text, and gets
|
||||
# re-validated against the semver regex below.
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
$tag = $env:TAG_NAME
|
||||
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
|
||||
throw "Refusing to generate release body for non-semver tag: $tag"
|
||||
}
|
||||
$version = $tag.Substring(1)
|
||||
|
||||
$yamlPath = "HellionChat/HellionChat.yaml"
|
||||
$raw = Get-Content -Path $yamlPath -Raw
|
||||
|
||||
$marker = "changelog: |-"
|
||||
$idx = $raw.IndexOf($marker)
|
||||
if ($idx -lt 0) { throw "changelog block not found in $yamlPath" }
|
||||
|
||||
# changelog: is the last top-level key in the manifest, so
|
||||
# everything after the marker is the literal block. Strip the
|
||||
# 4-space yaml indent (prettier convention) from each line.
|
||||
$afterMarker = $raw.Substring($idx + $marker.Length)
|
||||
$changelogBody = (($afterMarker -split "`r?`n") | ForEach-Object {
|
||||
if ($_ -match '^ ') { $_.Substring(4) } else { $_ }
|
||||
}) -join "`n"
|
||||
|
||||
# Subblock convention: "**vX.Y.Z — <subtitle> (<date>)**"
|
||||
# matches verify-changelog-sync.sh and slim-rule grep.
|
||||
$header = "**v$version "
|
||||
$start = $changelogBody.IndexOf($header)
|
||||
if ($start -lt 0) {
|
||||
throw "No changelog entry for version $version found in $yamlPath. Update the changelog block before tagging a release."
|
||||
}
|
||||
|
||||
$rest = $changelogBody.Substring($start)
|
||||
$nextHdr = $rest.IndexOf("`n`n**v", 1)
|
||||
$trailer = $rest.IndexOf("`n`n---")
|
||||
|
||||
if ($nextHdr -ge 0 -and ($trailer -lt 0 -or $nextHdr -lt $trailer)) {
|
||||
$currentBlock = $rest.Substring(0, $nextHdr).TrimEnd()
|
||||
} elseif ($trailer -ge 0) {
|
||||
$currentBlock = $rest.Substring(0, $trailer).TrimEnd()
|
||||
} else {
|
||||
$currentBlock = $rest.TrimEnd()
|
||||
}
|
||||
|
||||
# Static install / docs / licence footer is maintained as a
|
||||
# separate file so the workflow YAML stays clean (no embedded
|
||||
# heredoc that would have to be indented under the run-block).
|
||||
$footerPath = ".github/release-footer.md"
|
||||
if (-not (Test-Path $footerPath)) {
|
||||
throw "Release footer template not found: $footerPath"
|
||||
}
|
||||
$footer = Get-Content -Path $footerPath -Raw
|
||||
|
||||
$body = $currentBlock + "`n" + $footer
|
||||
$body | Out-File -FilePath release-body.md -Encoding utf8 -NoNewline
|
||||
|
||||
Write-Host "Generated release body for $tag :"
|
||||
Write-Host "----------------------------------------"
|
||||
Write-Host $body
|
||||
Write-Host "----------------------------------------"
|
||||
|
||||
# release-action@main only declares files/title/body/pre_release/
|
||||
# draft/api_key/insecure as inputs (see its action.yml). It silently
|
||||
# ignores anything else, including body_path and tag_name. The tag
|
||||
# itself comes from GITHUB_REF, the body must be passed inline via
|
||||
# body:, so we re-emit release-body.md as a step output first.
|
||||
- name: Expose release body for release-action
|
||||
id: body
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo 'content<<RELEASE_BODY_EOF'
|
||||
cat release-body.md
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Gitea-native release action. Creates the release if the tag has no
|
||||
# release yet, or updates the existing one with latest.zip attached
|
||||
# and the generated body. The auto-injected GITHUB_TOKEN on Gitea
|
||||
# Actions has Gitea-API scope and is sufficient for release write.
|
||||
- name: Attach to Gitea release
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
files: ${{ steps.locate.outputs.path }}
|
||||
body: ${{ steps.body.outputs.content }}
|
||||
api_key: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Security
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main
|
||||
with:
|
||||
# MessageStore.cs uses string-interpolation in CommandText for table
|
||||
# names and clause-joins that come from internal code constants, not
|
||||
# user input. Values are bound via SqlParameter, the SQL surface is
|
||||
# local-only inside a Dalamud plugin. Semgrep matches the pattern
|
||||
# without dataflow, so it flags those eight call sites; CodeQL
|
||||
# would not. Suppressed for this repo only.
|
||||
semgrep-exclude-rules: "csharp.lang.security.sqli.csharp-sqli.csharp-sqli"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# .githooks/pre-push — invokes preflight.sh (A/B/C/D=build).
|
||||
exec scripts/preflight.sh
|
||||
@@ -0,0 +1,13 @@
|
||||
# HellionChat is a hobby project and does not solicit funding.
|
||||
#
|
||||
# If you want to support the work that made HellionChat possible,
|
||||
# please consider supporting the upstream Chat 2 maintainers:
|
||||
#
|
||||
# Infiziert90 (Infi): https://ko-fi.com/infiii
|
||||
# Anna Clemens: https://ko-fi.com/lojewalo
|
||||
#
|
||||
# Both Ko-fi pages are also linked in the plugin's settings panel.
|
||||
|
||||
# No platforms enabled — keep this file present so GitHub recognises
|
||||
# the project as having considered funding without showing a Sponsor
|
||||
# button on the repository page.
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Bug report
|
||||
description: Something in HellionChat is broken or behaves wrong
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Please fill in the fields below so I can
|
||||
reproduce the issue. If this is a security issue, stop here and
|
||||
report it privately to [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=%5BHellionChat%20Security%5D)
|
||||
instead.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: HellionChat version
|
||||
description: From Settings → Information → Version
|
||||
placeholder: "0.5.4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
options:
|
||||
- Windows (XIVLauncher)
|
||||
- Linux (XIVLauncher Core)
|
||||
- macOS (XIVLauncher Core / wine)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Plain description, no log dumps yet
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What you expected
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to reproduce
|
||||
description: Step-by-step from "open settings" or "log in" through to the broken behaviour
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Relevant /xllog excerpt
|
||||
description: Filter for "HellionChat" if the log is huge
|
||||
render: text
|
||||
|
||||
- type: checkboxes
|
||||
id: confirm
|
||||
attributes:
|
||||
label: Pre-flight
|
||||
options:
|
||||
- label: I am running the latest version of HellionChat
|
||||
required: true
|
||||
- label: I have searched existing issues for duplicates
|
||||
required: true
|
||||
@@ -0,0 +1,15 @@
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: mailto:kontakt@hellion-media.de?subject=%5BHellionChat%20Security%5D
|
||||
about: Do not open a public issue for security problems. Report by e-mail instead.
|
||||
|
||||
- name: Upstream Chat 2 issue
|
||||
url: https://github.com/Infiziert90/ChatTwo/issues
|
||||
about:
|
||||
If the issue exists in upstream Chat 2 too, please report it there so the original maintainers see it as well.
|
||||
|
||||
- name: Discord
|
||||
url: https://discord.com/users/j.j_kazama
|
||||
about: Quick questions, casual feedback. Bug reports still go through the issue tracker for tracking.
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Feature request
|
||||
description: Suggest a feature or enhancement for HellionChat
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the suggestion. HellionChat focuses on privacy by
|
||||
default and a small, well-scoped feature set. Suggestions that
|
||||
align with that scope are easier to accept than ones that pull
|
||||
the plugin toward "do everything".
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem are you trying to solve
|
||||
description: The user-side problem, not the proposed solution yet
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: What you would like HellionChat to do
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you have considered
|
||||
description: Other plugins, manual workarounds, settings combinations
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope estimate from your side
|
||||
options:
|
||||
- "Small (one tab, one toggle, one filter)"
|
||||
- "Medium (a settings section, persistent state, one new file)"
|
||||
- "Large (architectural, touches the message pipeline or the database)"
|
||||
- "I don't know"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: confirm
|
||||
attributes:
|
||||
label: Pre-flight
|
||||
options:
|
||||
- label: I have searched existing issues for similar requests
|
||||
required: true
|
||||
- label:
|
||||
I understand HellionChat is a privacy-focused fork and not a feature parity tool with upstream Chat
|
||||
2
|
||||
required: true
|
||||
@@ -0,0 +1,65 @@
|
||||
<!--
|
||||
Thanks for contributing to HellionChat. Please fill in the sections
|
||||
below so the review goes quickly. Delete sections that genuinely do
|
||||
not apply, but do not delete the whole template.
|
||||
|
||||
If this is a security fix, stop here and report it privately by
|
||||
e-mail instead of opening a public PR:
|
||||
mailto:kontakt@hellion-media.de?subject=%5BHellionChat%20Security%5D
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences. What does this PR change and why. -->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Tick all that apply. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds behaviour)
|
||||
- [ ] Breaking change (config migration, removed feature, or behaviour change that user-visible defaults rely on)
|
||||
- [ ] Documentation only
|
||||
- [ ] Translation update
|
||||
- [ ] Build, CI or tooling change
|
||||
|
||||
## Linked issue
|
||||
|
||||
<!-- e.g. "Closes #42" or "Refs #42". For trivial typo fixes, "n/a". -->
|
||||
|
||||
## How I tested this
|
||||
|
||||
<!--
|
||||
- Built locally with `dotnet build -c Release`
|
||||
- Ran `dotnet test`
|
||||
- Loaded the plugin in-game on Windows / Linux / macOS via XIVLauncher
|
||||
- Specific scenarios I exercised in-game
|
||||
-->
|
||||
|
||||
## User-visible changes
|
||||
|
||||
<!--
|
||||
Anything the end user will notice. New settings, changed defaults,
|
||||
new commands, new translations, removed behaviour. If none, write
|
||||
"none".
|
||||
-->
|
||||
|
||||
## Compatibility notes
|
||||
|
||||
<!--
|
||||
- Does this require a configuration migration? If yes, which version
|
||||
bump and is it covered by the existing migration tests?
|
||||
- Does this change the schema in MessageStore?
|
||||
- Does this change the repo.json or HellionChat.yaml manifest fields?
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md).
|
||||
- [ ] My change matches the existing code style (`.editorconfig`).
|
||||
- [ ] I added or updated tests where the existing test infrastructure made that practical, or I have explained why tests
|
||||
are not applicable.
|
||||
- [ ] I updated the README, in-plugin strings or documentation if my change is user-visible.
|
||||
- [ ] I did not include any AI-generated code without disclosing it in the PR description (see
|
||||
[AI_DISCLOSURE.md](../docs/AI_DISCLOSURE.md)).
|
||||
- [ ] I confirm my contribution is released under the [EUPL-1.2](../LICENSE).
|
||||
@@ -0,0 +1,42 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# NuGet package updates for the plugin project. Weekly cadence keeps the
|
||||
# noise down while still catching transitive security advisories within
|
||||
# a few days of disclosure.
|
||||
- package-ecosystem: nuget
|
||||
directory: /HellionChat
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "07:00"
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- nuget
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
patches:
|
||||
update-types:
|
||||
- patch
|
||||
minor:
|
||||
update-types:
|
||||
- minor
|
||||
|
||||
# GitHub Actions versions in .github/workflows. Lower cadence because
|
||||
# Action releases ship less frequently and are usually safe to defer
|
||||
# for a month.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
time: "07:00"
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
commit-message:
|
||||
prefix: "chore(actions)"
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
subtitle: "Theme Foundation"
|
||||
versionsnatur: "Major-UI-Cycle"
|
||||
---
|
||||
|
||||
- Theme-Engine mit fünf Built-In-Themes: Hellion Arctic (Default), Chat 2 Klassik, Event Horizon, Moonlit Bloom, Mint
|
||||
Grove
|
||||
- Settings öffnet jetzt eine Card-Grid-Übersicht — Klick auf eine Card führt in den Detail-View, Breadcrumb und ESC
|
||||
zurück zur Übersicht
|
||||
- Themes-Tab mit Mini-Mockup pro Theme, Live-Switch beim Klick
|
||||
- Eigene Themes als JSON in `pluginConfigs/HellionChat/themes/` — Beispiel-Vorlage wird beim ersten Start automatisch
|
||||
abgelegt
|
||||
- Optional pro Theme eigene Chat-Channel-Farben mit Übernehmen/Behalten-Banner — niemals automatisch überschrieben
|
||||
- Plugin-Icon zum Hellion-Forge-Hammer gewechselt
|
||||
- Migration v13 → v14: alle User landen auf Hellion Arctic. Wer den Upstream-Look will, wählt Chat 2 Klassik in Settings
|
||||
→ Themes
|
||||
- Anleitung zum Schreiben eigener Themes: `docs/THEME-AUTHORING.md`
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
subtitle: "Layout Refresh"
|
||||
versionsnatur: "Major-UI-Cycle"
|
||||
---
|
||||
|
||||
- Sidebar im neuen Look: fix 44 px breit, nur Icons, Tab-Name als Tooltip beim Hover, vertikale Akzent-Pill markiert den
|
||||
aktiven Tab
|
||||
- Top-Tabs bekommen eine Akzent-Underline statt Background-Fill am aktiven Tab
|
||||
- Pro Tab eigenes Icon wählbar in Einstellungen → Tabs (FontAwesome-Pool)
|
||||
- Auto-Tell-Tabs sind jetzt visuell unterscheidbar: jeder Tell-Partner bekommt ein eigenes Icon
|
||||
(envelope/star/heart/bell/bookmark/flag/fire) plus eigene Farbe aus 12-Farb-Palette — 84 Kombinationen, gleicher
|
||||
Partner ergibt konsistent dieselbe
|
||||
- Pulsierender roter Dot oben rechts am Sidebar-Icon zeigt ungelesene Nachrichten an. Sanft, 2-Sekunden-Cycle,
|
||||
deaktivierbar über `Configuration.ReduceMotion` (UI-Toggle in v1.3.0)
|
||||
- Bottom-Status-Bar (22 px) mit fünf Live-Slots: aktiver Channel + Color-Dot, Privacy-Badge, Tab/Message-Counter,
|
||||
Auto-Tell-Counter, Plugin-Version. Update 1×/Sek
|
||||
- Card-Rows als Default-Message-Render: Sender-Header in Channel-Farbe, Body neue Zeile, dezenter Trenner.
|
||||
`Compact Density`-Toggle in Aussehen schaltet zurück auf den Einzeiler
|
||||
- Bug-Fix: Settings speichern löscht den Chat-Verlauf nicht mehr. Refilter läuft jetzt nur wenn Filter-relevante
|
||||
Settings geändert wurden — Cosmetic-Änderungen lassen den Chat unverändert. Persistente und Auto-Tell-Tabs überleben
|
||||
beide
|
||||
- Bug-Fix: Hellion-Schrift (Exo 2) blockt die Schriftgröße nicht mehr — 4K-User können hochskalieren
|
||||
- Migration v14 → v15: alte Theme-Felder entfernt, alle anderen Settings bleiben
|
||||
|
||||
Animation-Polish (Lerps, Theme-Crossfade, Quick-Picker) folgt in v1.3.0.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
subtitle: "Settings Cleanup"
|
||||
versionsnatur: "UX-Polish-Cycle"
|
||||
---
|
||||
|
||||
- Settings-Übersicht thematisch re-sortiert: zusammenhängende Optionen wohnen jetzt zusammen, jede Card hat einen kurzen
|
||||
Untertitel — kein Raten mehr wo eine Setting steckt
|
||||
- Drei neue Cards: **Theme & Layout** (Theme-Picker, Fenster-Style, Zeitstempel-Style), **Schriften & Farben**
|
||||
(Schriftart, Schriftgröße, Chat-Farben pro Channel), **Daten-Verwaltung** (Aufbewahrung, Cleanup, Export, DB-Viewer,
|
||||
Advanced-Tools — vorher zwischen Datenschutz und Datenbank verteilt)
|
||||
- Datenschutz fokussiert sich jetzt auf eine Aufgabe: den Privacy-Filter
|
||||
- Der Auto-Tell-Tabs-History-Preload-Slider ist von Datenschutz nach Chat → Auto-Tell-Tabs umgezogen
|
||||
- KeybindMode wohnt jetzt unter Allgemein → Eingabe statt unter Sprache
|
||||
- Vier tote Schema-Felder entfernt (alle obsolet seit der Theme-Engine in v1.1.0): `Stilüberschreiben`-Toggle,
|
||||
`Stilname`-Auswahl, alter `WindowAlpha`-Slider, ungenutztes `ShowThemeQuickPicker`
|
||||
- Migration v15 → v16: alter `WindowAlpha`-Wert wird automatisch nach
|
||||
`Theme & Layout → Fenster-Style → Fenster-Transparenz` gemappt (nur wenn der Slider noch auf Default 0.85 stand, sonst
|
||||
gewinnt der User-Wert). Backup der Pre-v16-Config liegt unter `pluginConfigs/HellionChat.json.pre-v16-backup`. User
|
||||
die `Stilüberschreiben` aktiv hatten sehen einen einmaligen Hinweis-Toast
|
||||
- UX-Default-Bumps für Bestand-User mit Default-Werten: Card-Rows-Layout zurück auf Single-Line, NG+ standardmäßig
|
||||
hidden, gleiche Zeitstempel werden zusammengefasst, MaxLinesToRender auf konservativere 2500
|
||||
- Frische Installs starten mit dem Hellion-Brand-Chat-Color-Preset out-of-the-box (der First-Run-Wizard hat keine
|
||||
Preset-Wahl)
|
||||
- Hinweis zum Window-Transparenz-Slider in der Beschreibung: Dalamud's per-Window-Hamburger-Menü (oben rechts in der
|
||||
Titelleiste) bietet eigene Overrides für Deckkraft, Hintergrund-Blur, Anpinnen und Durchklick — die haben Vorrang über
|
||||
unseren Slider für das jeweilige Fenster
|
||||
|
||||
Pure UX-Polish, keine neuen Features. Nächster Cycle (v1.3.0): Animation-Polish (Lerps, Theme-Crossfade, Quick-Picker)
|
||||
wie ursprünglich geplant.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
subtitle: "Theme Expansion"
|
||||
versionsnatur: "Theme-Pack-Patch"
|
||||
---
|
||||
|
||||
- Vier neue Built-in-Themes verlängern die Auswahl im Picker — keine Engine-Änderung, keine Settings angefasst, einfach
|
||||
mehr Farboptionen
|
||||
- **Night Blue** — Royal Blue auf tiefem Marineblau. Kühles Tech-Dashboard-Mood, bewusst neutral gehalten damit es sich
|
||||
nicht mit den Brand-Themes beißt
|
||||
- **Indigo Violet** — Royal Violet auf Deep Indigo mit Türkis-Mint-Counter für Aurora-Glitter-Stimmung. Schwester von
|
||||
Event Horizon, aber dunkler und dichter; der Türkis-Akzent hält die beiden klar auseinander
|
||||
- **Forge Merchantman** — Patina-Bronze auf Workshop-Slate mit warmem Bernstein-Counter. Hellion Forge bekommt ein
|
||||
eigenes Theme im Plugin selbst — Schwester von Hellion Arctic, aber grüner und wärmer statt kaltem Cyan
|
||||
- **Hellion Spectrum** — Farbenblind-sichere Channel-Farben (Deuteranopie/Protanopie) auf Basis der
|
||||
Wong/Okabe-Ito-Palette. Channel-Identität bleibt erhalten (Tell pink, Yell gelb, Shout orange, Party blau, FC grün);
|
||||
die Töne sind so gewählt dass jeder Channel auch unter Rot-Grün-Schwäche klar trennbar bleibt. Deckt rund 99 % aller
|
||||
CVD-Fälle ab
|
||||
- Kein Schema-Bump, keine Migration. Das Default-Theme bleibt **Hellion Arctic**, eigene Custom-Themes laufen
|
||||
unverändert weiter
|
||||
- Theme-Katalog wächst damit von fünf auf neun Built-ins
|
||||
|
||||
Reines Theme-Pack zwischen v1.2.1 und dem nächsten Polish-Cycle. Eine Tritan-Variante (Spectrum für Blau-Gelb-Schwäche)
|
||||
kann später nachgeliefert werden, falls Bedarf kommt.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
subtitle: "Plugin Integrations: Honorific"
|
||||
versionsnatur: "Plugin-Integration-Cycle 1"
|
||||
---
|
||||
|
||||
- Erste Plugin-Integration eingebaut, Cycle 1 von 6 auf der Roadmap
|
||||
- **Honorific-Custom-Titles im Chat-Header** — der Titel den du in Honorific gesetzt hast erscheint jetzt links über dem
|
||||
Message-Log mit der von dir gewählten Farbe, Auto-Hide wenn Honorific nicht installiert ist oder kein Custom-Titel
|
||||
aktiv ist
|
||||
- **Krone-Icon plus Tooltip** vor dem Titel-Text, damit klar ist woher der Slot kommt ohne dass der User raten muss
|
||||
- **Neuer Integrations-Settings-Tab** mit Status-Indikator (erkannt, nicht installiert, inkompatibel) und Toggle. Plus
|
||||
Vorschau-Block der die fünf weiteren geplanten Cycles ankündigt: Kontextmenü-Aktionen, Smart Notifications
|
||||
(NotificationMaster), RP-Status-Block (Moodles und LightlessClient), ExtraChat-Channels, Quick-DM-Button
|
||||
(XIVInstantMessenger)
|
||||
- **Maintainer-Attribution** im Tab als Höflichkeits-Geste, zwei Buttons zum Honorific-Repo und zum Caraxi-Profil. Plus
|
||||
Hellion-Forge-Discord-Button für Community-Vorschläge zu künftigen Integrationen
|
||||
- Keine Migration, keine Schema-Änderung. Wer Honorific eh schon nutzt sieht den Custom-Titel automatisch sobald
|
||||
HellionChat aktualisiert
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
subtitle: Critical Lifecycle Fixes
|
||||
versionsnatur: Stability-Hotfix
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.0 — Critical Lifecycle Fixes**
|
||||
|
||||
Erster Sub-Patch der v1.4.x Polish-Sweep-Serie. Sieben bekannte Lifecycle- und Race-Bugs aus den Audit-Pässen
|
||||
abgearbeitet, bevor Performance- und Architektur-Refactors draufkommen.
|
||||
|
||||
- **SQLite-Dispose** lehnt sich nicht mehr an GC-Druck zur Datei-Freigabe an, Pooling=false auf der Connection macht den
|
||||
manuellen GC.Collect überflüssig
|
||||
- **Worker-Threads** (PendingMessage, RetentionSweep) sind jetzt explizit IsBackground=true, das Plugin-Domain kann
|
||||
sauber unloaden bei XIVLauncher-Reload ohne darauf zu warten
|
||||
- **EmoteCache-Loader** von async-void auf async-Task mit shared Task-Tracker, drain-on-Dispose. Kein Schreib-Risiko
|
||||
mehr auf disposed EmoteImages-Einträge nach Plugin-Reload
|
||||
- **DisposeAsync-Timeout** (10s) warnt jetzt laut statt silent zu failen
|
||||
- **Plugin-Dispose** flushed pending DeferredSave bevor Services abgebaut werden, Settings-Änderungen aus den letzten
|
||||
Frames vor Disable überleben jetzt zuverlässig
|
||||
- **v13→v14 Config-Migration** liest pre-v13-Backup und überträgt HellionThemeWindowOpacity in das neue
|
||||
WindowOpacity-Feld statt auf 0.85 zurückzufallen
|
||||
|
||||
Keine Schema-Bumps, keine User-sichtbaren Funktions-Änderungen außer dass Reload und Shutdown spürbar sauberer laufen.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
subtitle: Theme Engine Performance
|
||||
versionsnatur: Performance-Patch
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.1 — Theme Engine Performance**
|
||||
|
||||
Zweiter Sub-Patch der v1.4.x Polish-Sweep-Serie. Heap-Pressure aus dem Theme-Engine-Render-Pfad eliminiert,
|
||||
Custom-Theme- Hot-Reload überlebt transiente File-Locks beim Editor-Save. Plus zehnter Built-In und überarbeitete
|
||||
Author-Credits.
|
||||
|
||||
- **ABGR-Cache auf den Theme-Records.** Beim Theme-Register (Built-In oder Custom) werden alle Color-Slots einmalig in
|
||||
ABGR-Pack-Form vor-konvertiert. HellionStyle.PushGlobal liest aus dem Cache statt pro Slot pro Frame durch
|
||||
ColourUtil.RgbaToAbgr zu jagen. Real gemessene Frame-Time-Recovery: **~13 %** in typischer Render-Szene
|
||||
(Plan-Erwartung war 2-6 % konservativ, real ~10-15 %)
|
||||
- **Custom-Theme File-Lock-Härtung.** Wenn der User ein Theme-JSON gerade speichert während HellionChat reloaden will,
|
||||
fängt der Loader jetzt explizit Sharing-Violation und Lock-Violation ab. Last-Known-Good-Snapshot bleibt im Picker,
|
||||
beim nächsten Tick wird automatisch retry'd — vorher fiel das Theme aus der Liste bis zum Plugin-Reload
|
||||
- **Defensive Cache-Refresh beim Theme-Switch.** Falls ein Theme auf einem alten Pfad ohne Cache-Fill in den Speicher
|
||||
gekommen ist, holt Switch() das beim Anwenden nach
|
||||
- **Synthwave Sunset als zehnter Built-In.** Hot Magenta + Cyan auf Mitternachts-Violett, 80s-Neon-Grid-Vibes für
|
||||
Late-Night-Raids
|
||||
- **Author-Credits konsolidiert.** Brand-Themes laufen jetzt unter „Hellion Forge". Mint Grove und Forge Merchantman
|
||||
werden Carla Beleandis als Community-Geste zugeschrieben.
|
||||
|
||||
Keine Schema-Bumps, keine User-sichtbaren Funktions- Änderungen außer dass die Frames in Theme-getrieben rendernden
|
||||
Szenen merklich glatter laufen und ein neues Theme im Picker steht.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
subtitle: ChatLog Frame-Hot-Path
|
||||
versionsnatur: Performance-Patch
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.2 — ChatLog Frame-Hot-Path**
|
||||
|
||||
Dritter Sub-Patch der v1.4.x Polish-Sweep-Serie. Drei Per-Frame-Allokations-Quellen aus dem ChatLogWindow-Render- Pfad
|
||||
und der Settings-StatusBar eliminiert.
|
||||
|
||||
- **Card-Mode-Border-Loop entlastet.** DrawMessages hebt Theme, DrawList, Window-Left, Window-Right und die ABGR-
|
||||
Border-Color einmalig vor den Per-Message-Loop. Bei 100 sichtbaren Messages sind das gut 500 redundante P/Invokes und
|
||||
Property-Reads, die der Hoist eliminiert. Pop-Out- Heavy-Setups (mehrere parallele Chat-Windows) profitieren
|
||||
proportional, weil der Hoist pro DrawMessages-Call greift, also pro Window
|
||||
- **Auto-Tell Tab-Tint und Icon gecached.** Die Hash-Color- Berechnung für Auto-Tell-Tabs lief pro Tab pro Frame, mit
|
||||
zwei String-Allokationen pro Tab (eine für Tint-Hash, eine für Icon-Hash). Der neue TabTintCache liest pre-computed
|
||||
Werte aus dem Tab und rechnet nur neu wenn das Tell-Target drifted. Beide Caches haben separate Validation-Keys, also
|
||||
keine Cross-Invalidation zwischen Tint- und Icon-Pfad. AutoTellTabTint selbst bleibt pure Hash-Helper, weiterhin ohne
|
||||
Tab-Awareness
|
||||
- **StatusBar-Aggregation hinter Cache-Gate.** Die Status- Leiste am unteren Window-Rand summiert die Tab-Message-
|
||||
Counts und zählt die Auto-Tell-Tabs pro Frame. Der Cache- Gate (1 Sekunde) lag bisher hinter den LINQ-Pfaden, also
|
||||
liefen Sum und Count trotzdem pro Frame. Jetzt vor dem Gate, plus die LINQ-Pfade durch eine Single-Pass-Foreach
|
||||
ersetzt. Die Aggregation läuft auf etwa 1 % der Frames
|
||||
|
||||
Realistische Frame-Time-Recovery: 2-5 % in typischen Szenen, Pop-Out-Heavy-Setups potenziell mehr durch die Card-Border-
|
||||
Multiplikation pro Window.
|
||||
|
||||
Keine Schema-Bumps, keine User-sichtbaren Funktions- Änderungen außer dass die Frames im Chat-Log und in der
|
||||
Settings-Statusleiste merklich glatter laufen.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
subtitle: Async-Lifecycle + Gitea-Cutover
|
||||
versionsnatur: Architecture-Refactor
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.3 — Plugin-Load Async-Init + Repo-Cutover**
|
||||
|
||||
Vierter Sub-Patch der v1.4.x Polish-Sweep-Serie. Plugin- Lifecycle auf Dalamud's `IAsyncDalamudPlugin`-API migriert und
|
||||
das Custom-Repo zieht von GitHub auf Gitea um.
|
||||
|
||||
- **Async-Plugin-Architektur.** Konstruktor übernimmt nur noch die Bootstrap-Essentials (Config-Load, Language-Init,
|
||||
Conflict-Detection). Migrationen, Service-Allokationen, Window-Konstruktion und Hook-Subscription wandern in
|
||||
LoadAsync, sodass Dalamud die UI während der schweren Arbeit responsive halten kann. Per-Line-CaptureFailure in
|
||||
DisposeAsync mirrort LightlessSync's Pattern, plus Idempotency-Guard gegen Reload-Races
|
||||
- **Custom-Repo-URL umgezogen auf Gitea.** Bestehende Tester müssen einmalig in XIVLauncher die Custom-Repo-URL auf
|
||||
`https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/repo.json` umstellen, dann
|
||||
XIVLauncher neu starten. Das alte GitHub-Repo bleibt als eingefrorener v1.4.2-Snapshot stehen und wird nicht mehr
|
||||
aktualisiert
|
||||
- **Schema-Gate statt Migrations-Kette.** Die v9 → v16 Migrationen sind raus, ersetzt durch einen harten Schema-Check in
|
||||
Phase 1. Configs auf Schema v16+ laden direkt; ältere Configs (vor v1.2.1) bekommen jetzt eine klare „install v1.4.2
|
||||
first"-Fehlermeldung statt eines impliziten Migrations-Pfads
|
||||
- **AutoTranslate-Cache läuft im Hintergrund.** Der Cache füllt sich jetzt fire-and-forget statt blockierend im
|
||||
Plugin-Load. Trade-off: die erste Auto-Translate-Nutzung einer Session kann einen kurzen Hitch haben, dafür kein
|
||||
300-ms-Block beim Plugin-Start
|
||||
- **Plugin-Load-Zeit ehrlich.** Median 3,7 s über fünf Reloads, vergleichbar mit v1.4.2. Der Async-Refactor ist
|
||||
Foundation für künftige Lazy-Init-Optimierungen (v1.4.4) und Code-Architektur-Hygiene, kein direkter User-spürbarer
|
||||
Speed-Win in dieser Release
|
||||
|
||||
Keine User-sichtbaren Funktions-Änderungen außer dem Repo-URL-Update. Settings, Themes und Tabs bleiben unangetastet.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
subtitle: Threading- und IPC-Sicherheits-Politur
|
||||
versionsnatur: Wartung und Robustheit
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.4 — Threading- und IPC-Sicherheits-Politur**
|
||||
|
||||
Fünfter Sub-Patch der v1.4.x Polish-Sweep-Serie. Threading-Annahmen werden explizit pro Methode dokumentiert, ein
|
||||
Hot-Path-Lock im Auto-Tell-Tab-Counter fällt weg, IPC-Cleanup wird sichtbar wenn er fehlschlägt und der Privacy-Filter
|
||||
spricht jetzt bei unbekannten ChatTypes.
|
||||
|
||||
- **AutoTellTabsService Hot-Path-Lock entfernt.** `ActiveTempTabCount` hat bisher pro Render-Frame ein LINQ-Count unter
|
||||
einem Lock gemacht. Jetzt läuft das über einen Interlocked-Counter der parallel zur Tabs-Liste mitgeführt wird,
|
||||
inklusive Resync-Hook für den Snapshot-Restore-Pfad in `SaveConfig`. Plus Pure-Helper-Test-Mirror in der Build-Suite
|
||||
damit die Atomicity-Semantik nicht versehentlich wegrefactored wird
|
||||
- **HonorificService selbst-dokumentierende Threading-Banner.** Statt eines Block-Comments am Klassen-Ende hat jede
|
||||
IPC-Callback-Methode jetzt einen 1-Zeilen-Banner darüber, der den Thread-Kontext direkt am Call-Site benennt
|
||||
(framework only, framework scheduled, any). Mehr Hilfe für künftige Reviews als ein abstraktes Threading-Kapitel
|
||||
- **Unsubscribe-Failure ist jetzt sichtbar.** `TryUnsubscribe` hat ein Honorific-Unsubscribe-Failure bisher als Debug
|
||||
geloggt, was bei Standard-Loglevel verschluckt wurde. Eine geleakte Subscription kann den Service über Plugin-Reloads
|
||||
hinweg leben lassen, also läuft der Log jetzt auf Warning
|
||||
- **AutoTranslate-Warmup blockiert den Plugin-Unload nicht mehr.** Der Cache-Warmup-Thread war ohne `IsBackground=true`
|
||||
unterwegs, was den Unload um 100-300 ms verzögern konnte. Pattern-Match zu MessageManager und RetentionSweep (beide
|
||||
seit v1.4.0)
|
||||
- **Privacy-Filter loggt unbekannte ChatTypes.** Wenn FFXIV durch einen Patch einen neuen ChatType einführt der weder in
|
||||
der Whitelist noch in den Defaults steht, wird er bisher silent durch den Failsafe geleitet. Jetzt loggt der Filter
|
||||
einmalig pro Runtime eine Warning mit dem Type und dem Failsafe-Wert. Dedup über ein NonSerialized-HashSet, also kein
|
||||
Log-Spam
|
||||
- **Default-Flip für neue Installationen.** `PrivacyPersistUnknownChannels` startet bei neuen Configs jetzt auf `true`,
|
||||
damit ein Patch-bedingt neuer ChatType nicht stillschweigend gedroppt wird bevor der User entscheiden kann. Bestehende
|
||||
Configs behalten ihre Wahl, weil der Deserializer den Initializer überschreibt. Keine Migration, kein Schema-Bump
|
||||
|
||||
Keine User-sichtbaren Funktions-Änderungen außer dem Default-Flip für neue Installationen. Settings, Themes, Tabs und
|
||||
das Privacy-Verhalten für Bestand bleiben unangetastet.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
subtitle: UX und Robustheit
|
||||
versionsnatur: UX-Polish-Cycle
|
||||
---
|
||||
|
||||
**Hellion Chat 1.4.5 — UX und Robustheit**
|
||||
|
||||
Sechster Sub-Patch der v1.4.x Polish-Sweep-Serie. Render-Fehler im Chat-Fenster werden jetzt sichtbar, der
|
||||
First-Run-Wizard hat eine explizite Cancel-Schaltfläche, der Eingabe-Verlauf bleibt nicht mehr über Plugin-Reloads
|
||||
hinweg liegen, und die Statusleiste klippt in schmalen Fenstern nicht mehr.
|
||||
|
||||
- **Fehler-Benachrichtigung im Chat-Fenster.** Wenn ein Render-Fehler in `DrawChatLog` auftritt, zeigt das Plugin jetzt
|
||||
eine einmalige Warning-Notification mit Verweis aufs `/xllog`, statt das Fenster stillschweigend leer zu lassen. Der
|
||||
Stack-Trace selbst geht weiter via `Plugin.Log.Error` ins Logfile. De-Dup über Per-Session-Bool, damit ein
|
||||
wiederkehrender Fehler die Notification-Stack nicht pro Frame neu vollkippt
|
||||
- **First-Run-Wizard trennt Accept und Close.** `OnClose` setzt nicht mehr stillschweigend `FirstRunCompleted=true`,
|
||||
also lässt das X den Wizard schwebend zurück und er kommt beim nächsten Plugin-Reload wieder. Eine neue „Später —
|
||||
Defaults behalten"-Schaltfläche im Footer ist der explizite Weg, ohne Profil-Auswahl rauszukommen. Strings bilingual
|
||||
EN+DE plus Tooltip
|
||||
- **Eingabe-Verlauf wird beim Plugin-Reload geleert.** `InputHistoryService.Reset` hängt jetzt in `Plugin.DisposeAsync`
|
||||
neben den anderen Pure-Memory-Cleanups, damit der statische Zustand aus der vorigen Session den nächsten Load nicht
|
||||
mehr erbt
|
||||
- **Statusleiste klippt nicht mehr.** Der rechtsbündige Versions-Slot wird ausgeblendet wenn die Chat-Window-Breite
|
||||
abzüglich Versions-Text unter 200 px fällt — vorher überlappte er die vier linken Slots. Ab ausreichender Breite
|
||||
taucht der Slot wieder auf
|
||||
- **Intern:** `FontManager` fällt auf System-Font zurück wenn die eingebettete Hellion-Font-Resource fehlt
|
||||
(Broken-csproj-Pfad, nie ein Produktions-Build), plus expliziter Session-Only-Invariant-Kommentar für Auto-Tell-Tabs
|
||||
in `Plugin.cs:167-168` mit einem TempTabCounter-Init-Pin in der Build-Suite. Kein Schema-Bump, keine Migration
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
subtitle: Code Hygiene and Refactor
|
||||
versionsnatur: Maintenance-Cycle
|
||||
---
|
||||
|
||||
Wartungs-Patch ohne User-sichtbare Änderungen. Saubere Code-Basis als Vorbereitung auf das v1.4.7-Backlog-Cleanup, plus
|
||||
zwei geerbte Bugfixes aus dem ChatTwo-Upstream `f35b7d3`.
|
||||
|
||||
- **preflight.sh härter**: csharpier-Reflow-Check (Block E) und markdownlint (Block F) laufen jetzt im Pre-Push-Gate,
|
||||
statt erst beim Pre-Merge-Review aufzufallen.
|
||||
- **FontManager-Fallback robuster**: Atlas-Toolkit-Throws aus kaputten Font-Configs (IO, InvalidOperation,
|
||||
ArgumentException) fallen jetzt zuverlässig auf NotoSansCjkRegular, statt den Atlas-Build mitzureißen. Der
|
||||
Exception-Typ wird im Log mitgegeben für die Diagnose.
|
||||
- **URL-Validation beim Plugin-Load**: BrandingLinks (5 URLs) und IntegrationLinks (2 URLs) werden via
|
||||
`[ModuleInitializer]` geprüft. Ein Tippfehler bei einer künftigen URL-Rotation wirft jetzt sofort beim Plugin-Load,
|
||||
statt still beim Klick zu scheitern.
|
||||
- **Cherry-Pick aus ChatTwo `f35b7d3`** — Memory-Leak in `Chat.SetChannel`: der native `Utf8String` wird jetzt auch dann
|
||||
freigegeben, wenn der Linkshell-Check den Channel ablehnt (vorher gefangen im early-return).
|
||||
- **Cherry-Pick aus ChatTwo `f35b7d3`** — `Tab.Clone()` Deep-cloned jetzt `UsedChannel` und `TellTarget`. Vorher
|
||||
Reference-Share-Bug: PopOut- und Temp-Tabs mutierten sich gegenseitig.
|
||||
- **Aktive-Tab-Underline pixel-perfect bei DPI-Scaling**: Die Underline-Pill skaliert jetzt mit
|
||||
`ImGuiHelpers.GlobalScale` und rundet die DrawList-Koordinaten auf physische Pixel. Kein Sub-Pixel-Blur mehr auf
|
||||
125/150%-Setups.
|
||||
- **IconButton-Width-Fix**: der manuelle `width - 2 * CellPadding.X`-Subtract verlor den HUD-Scale (Padding skaliert,
|
||||
der raw int nicht). Gemessene Breite läuft jetzt unverändert durch.
|
||||
- **Test-Isolation für MessageStore**: `Dalamud.Utility.Util`-Surface (IsWine, OpenLink) läuft jetzt durch eine
|
||||
`IPlatformUtil`-Indirektion. MessageStores `IsWine`-Probe ist isoliert testbar in der Build-Suite. Plus:
|
||||
HellionStyle-ChildBgAlpha als Pure-Helper extrahiert, Plugin.SaveConfig kopiert nur Session-Tabs statt der ganzen
|
||||
Tab-Liste, SettingsOverview cached den DrawList einmal pro Frame.
|
||||
- **Built-in-Theme-Roster**: Crystal Nocturne (Royal Sapphire + Electric Magenta auf Obsidian, von CRYSTALLITE) ersetzt
|
||||
Moonlit Bloom. User mit Moonlit Bloom als aktivem Theme fallen beim ersten Plugin-Load auf Hellion Arctic zurück.
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
subtitle: Backlog Cleanup and Mid-Features
|
||||
versionsnatur: Mid-Feature-Patch
|
||||
---
|
||||
|
||||
Achter Sub-Patch der v1.4.x Polish-Sweep-Serie. Erstes User-sichtbares Feature-Bundle seit v1.4.5 — angepinnte Tell-Tabs
|
||||
die Relog überleben, opt-in Honorific-Glow, plus eine konfigurierbare Sidebar.
|
||||
|
||||
- **TempTell anpinnen**: Rechtsklick auf einen TempTell-Tab in der Sidebar → „Tab anpinnen". Angepinnte Tabs überleben
|
||||
Plugin-Reload und Char-Logout, behalten ihre Konversations-Historie (wird beim Rehydrate aus dem MessageStore
|
||||
nachgeladen) und bleiben an die gleiche /tell-Person gebunden. Hard-Cap 5 angepinnte Tabs in einem separaten Pool —
|
||||
die normalen Auto-Tell-Tabs (15er Cap) sind davon entkoppelt, Gesamt-Decke 20. Die Sidebar gruppiert angepinnte Tabs
|
||||
in einer eigenen „Angepinnt"-Sektion mit eigenem Trenner.
|
||||
- **Honorific Glow-Outline**: rendert jetzt eine 8-Richtungs-DrawList-Outline wenn der Honorific-Titel eine Glow-Farbe
|
||||
trägt. Opt-in via **Settings → Integrationen → Glow-Outline rendern (Honorific)** (Default OFF). Gradient (Color3 /
|
||||
GradientColourSet / Wave / Pulse) wird geparst und im DTO weitergereicht, rendert aktuell aber statisch als
|
||||
Primärfarbe — der volle Gradient-Port (Animations-Algorithmus + Pride-Palette) kommt als eigener Cycle nach.
|
||||
- **Sidebar-Breite konfigurierbar**: in **Theme & Layout** ein Slider 44–160 px. Default bleibt 44 px (icon-only), aber
|
||||
breiter machen damit Sektion-Header wie „Aktive Tells (3)" oder „Angepinnt (2)" nicht abgeschnitten werden.
|
||||
- **Settings-Save Channel-Fix**: ein Save mit aktivem Party- oder Linkshell-Tab konnte den Chat-Input zurück auf
|
||||
`/tell <angepinnte Person>` springen lassen. `Configuration.UpdateFrom` bewahrt jetzt den Runtime-`CurrentChannel`
|
||||
über den persistent-Tab-Merge hinweg, und `TabSwitched` deep-cloned den Seed-Channel statt sich den `UsedChannel` mit
|
||||
dem vorigen Tab zu teilen.
|
||||
- **Internal**: `IPluginLogProxy`-Indirektion vor Dalamud's `IPluginLog` über alle ~91 `Plugin.Log`-Call-Sites. Damit
|
||||
läuft `MessageStore.Migrate0` voll-isoliert in xUnit (F12.1-Lücke aus v1.4.6 geschlossen). Plus: TempTab-Counter als
|
||||
abgeleitete Property statt gecachtes Interlocked-Feld — die neuen Pin/Unpin-Übergänge sind Cold-Path, kein
|
||||
Lock-Free-Vorteil mehr. Migration v16 → v17 ist rein additiv (neues `Tab.IsPinned`-Bool, Default false).
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
|
||||
## How to install
|
||||
|
||||
This release is distributed via the HellionChat custom repository, not the Dalamud main plugin repo. To install:
|
||||
|
||||
1. In XIVLauncher: **Settings → Experimental → Custom Plugin Repositories**
|
||||
2. Add the URL: `https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/repo.json`
|
||||
3. Enable, save, then `/xlplugins` → search **Hellion Chat** → install
|
||||
|
||||
## Project documents
|
||||
|
||||
- [README](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/README.md) — features,
|
||||
architecture, build
|
||||
- [Privacy notice](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/PRIVACY.md) — what
|
||||
the plugin stores and sends
|
||||
- [Third-party notices](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/docs/THIRD_PARTY_NOTICES.md)
|
||||
— dependencies and licences
|
||||
- [Security policy](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/SECURITY.md) —
|
||||
vulnerability reporting
|
||||
- [Support](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/SUPPORT.md) — bug reports,
|
||||
questions, contact paths
|
||||
|
||||
## Licence
|
||||
|
||||
[EUPL-1.2](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/src/branch/main/LICENSE). Based on
|
||||
[Chat 2](https://github.com/Infiziert90/ChatTwo) by Infi and Anna, also EUPL-1.2.
|
||||
+12
-1
@@ -9,7 +9,11 @@
|
||||
.envrc
|
||||
!.env.example
|
||||
.vscode/
|
||||
scripts/
|
||||
scripts/setup-dev-env.sh
|
||||
|
||||
# Local test project (stays out of the published plugin repo;
|
||||
# pure-function safety net for refactor cycles)
|
||||
HellionChat.Tests/
|
||||
|
||||
# Packaging
|
||||
pack/
|
||||
@@ -374,6 +378,13 @@ FodyWeavers.xsd
|
||||
|
||||
#Specs und Plan datein
|
||||
/.superpowers/
|
||||
|
||||
#Test Datein
|
||||
ChatTwo.Tests
|
||||
TestResults
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Claude Code projekt-spezifisches Setup (lokal, nicht committed)
|
||||
/.claude/
|
||||
/CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"MD007": { "indent": 4 },
|
||||
"MD013": false,
|
||||
"MD024": { "siblings_only": true },
|
||||
"MD029": false,
|
||||
"MD033": false,
|
||||
"MD036": false,
|
||||
"MD041": false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
node_modules/
|
||||
*.Designer.cs
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always",
|
||||
"singleQuote": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: default
|
||||
rules:
|
||||
line-length: disable
|
||||
document-start: disable
|
||||
truthy:
|
||||
allowed-values: ["true", "false", "on"]
|
||||
empty-lines:
|
||||
max: 1
|
||||
@@ -1,77 +0,0 @@
|
||||
# AI assistance disclosure
|
||||
|
||||
This fork uses AI assistance per the [Dalamud Plugin AI Usage Policy](https://github.com/goatcorp/DalamudPluginsD17/)
|
||||
at the **Pair** level.
|
||||
|
||||
A note up front: Hellion Chat is currently in a rebuild and adjustment
|
||||
phase, and there are no plans to submit it to the Dalamud team for review
|
||||
while it stays standalone. If the plugin stays out of the official repo I
|
||||
technically wouldn't need to disclose any of this, but I'd rather be
|
||||
upfront about how it's built.
|
||||
|
||||
Hellion Chat is my entry point into game modding and plugin development. I
|
||||
have never written a plugin for a game before. I work alone, so I get help
|
||||
where I need it. That's not something I want to hide.
|
||||
|
||||
## How I actually work
|
||||
|
||||
I plan the architecture, decide what gets built, and own every design
|
||||
decision. For each change I:
|
||||
|
||||
- Read the code Claude drafts before I integrate it
|
||||
- Test with my own tooling and in the running game
|
||||
- Read the Dalamud log output to verify behaviour
|
||||
- Run security and privacy audits on anything that touches user data
|
||||
|
||||
One of the main reasons I use AI is consistency. I want the Hellion code to
|
||||
match the style of the upstream Chat 2 codebase and stay readable for
|
||||
anyone who opens the repo, not just for me. Claude helps me catch when I'm
|
||||
drifting from upstream conventions or writing something that only makes
|
||||
sense in my own head.
|
||||
|
||||
The balance is shifting toward more hand-written work as I get more
|
||||
comfortable with Dalamud and plugin development in general.
|
||||
|
||||
## What AI is used for
|
||||
|
||||
- API explanations (Dalamud, ImGui, .NET specifics I haven't worked with before)
|
||||
- Code drafts that I read, edit, and integrate
|
||||
- Pattern suggestions and code review
|
||||
- Keeping the style aligned with the upstream Chat 2 codebase
|
||||
|
||||
## What AI isn't used for
|
||||
|
||||
- **Visual assets.** Logos, icons, banners, and screenshots are human-drawn
|
||||
or taken from the running game.
|
||||
- **German translations.** Written by me as a native speaker.
|
||||
|
||||
## What's where
|
||||
|
||||
Upstream Chat 2 (by Infi & Anna, EUPL-1.2) is the foundation and was not
|
||||
produced with AI assistance. Hellion-specific code lives in
|
||||
`ChatTwo/Privacy/`, `ChatTwo/Export/`, `Resources/HellionStrings*`,
|
||||
`Ui/SettingsTabs/Privacy.cs`, `Ui/FirstRunWizard.cs`, `Ui/HellionStyle.cs`,
|
||||
plus the Migrate3 recovery and plugin layout migration in `MessageStore.cs`
|
||||
and `Plugin.cs`. These were developed with Pair-level assistance as
|
||||
described above.
|
||||
|
||||
## If AI-assisted development is a dealbreaker for you
|
||||
|
||||
Fair enough. There are solid alternatives that don't rely on AI in their
|
||||
development:
|
||||
|
||||
- [Chat 2](https://github.com/Infiziert90/ChatTwo), the original upstream
|
||||
this fork is based on
|
||||
- [XIV Instant Messenger](https://github.com/NightmareXIV/XIVInstantMessenger),
|
||||
a different approach to chat in FFXIV
|
||||
|
||||
Both are good projects. Use what fits you best.
|
||||
|
||||
## Tooling
|
||||
|
||||
- Claude (Anthropic) via Claude Code CLI
|
||||
- Context7 / Microsoft Learn for current Dalamud and .NET documentation
|
||||
|
||||
## Contact
|
||||
|
||||
Questions about this disclosure: <https://github.com/JonKazama-Hellion/HellionChat/issues>.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Code of Conduct
|
||||
|
||||
## A Note on This Project
|
||||
|
||||
HellionChat is a one-person side project developed under Hellion Forge. I maintain this in my spare time, which means
|
||||
replies can take a few days. Please do not escalate just because a thread is quiet.
|
||||
|
||||
When in doubt, assume good intent. Contributors come from different backgrounds, time zones and skill levels. A
|
||||
clarifying question is almost always a better first move than an accusation.
|
||||
|
||||
Please also keep discussions on topic. This project is about a Dalamud chat plugin. Off-topic arguments belong
|
||||
elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
|
||||
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
|
||||
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
|
||||
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
|
||||
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
|
||||
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
|
||||
background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
|
||||
values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
|
||||
violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
|
||||
clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
|
||||
people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone's personality or behavior on the basis of immutable
|
||||
identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
|
||||
purpose of the community.
|
||||
5. **Violating confidentiality.** Sharing or acting on someone's personal or private information without their
|
||||
permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
|
||||
enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials.** Sharing marketing or other commercial content in a way that is outside the norms of the
|
||||
community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links to, or describes any
|
||||
other restricted behaviors.
|
||||
|
||||
## Reporting
|
||||
|
||||
If something here is being broken, contact me directly. Do not open a public issue.
|
||||
|
||||
| Channel | Address |
|
||||
| ---------- | -------------------------- |
|
||||
| Email | `kontakt@hellion-media.de` |
|
||||
| Discord DM | `@j.j_kazama` |
|
||||
|
||||
Reports stay private. I will acknowledge within a few weekdays (European business hours) and tell you what I plan to do.
|
||||
|
||||
## Enforcement
|
||||
|
||||
I am the sole maintainer, so enforcement is a single-person process. I will pick the lightest measure that actually
|
||||
resolves the situation:
|
||||
|
||||
1. Private note asking the behaviour to stop.
|
||||
2. Public correction in the affected thread.
|
||||
3. Edit or removal of the offending content.
|
||||
4. Private written warning with a cooldown period.
|
||||
5. Temporary block from the repository or related spaces.
|
||||
6. Permanent block.
|
||||
|
||||
Severe cases skip the lower steps. I will not negotiate over harassment or threats.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies to all spaces the project owns or that I run on its behalf: the GitHub repository, GitHub
|
||||
Discussions, project-related Discord conversations, and the maintainer contact listed in [`SECURITY.md`](SECURITY.md).
|
||||
It also applies when someone is identifiably representing HellionChat elsewhere, for example when posting as a
|
||||
HellionChat maintainer in the Dalamud Discord.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, available at
|
||||
[https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
|
||||
of this license, visit
|
||||
[https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Contributing to HellionChat
|
||||
|
||||
Thanks for taking a look. HellionChat is a one-person side project developed under Hellion Forge. It started as a fork
|
||||
of [Chat 2](https://github.com/Infiziert90/ChatTwo) and has since become a standalone plugin under its own namespace,
|
||||
IPC channels and source tree (standalone-cut completed in v1.0.0). Forking HellionChat itself is explicitly permitted
|
||||
under the EUPL-1.2.
|
||||
|
||||
This document explains what I am looking for, what I am not, and how to make a contribution land smoothly.
|
||||
|
||||
## Before You Open Anything
|
||||
|
||||
- Read the [README](README.md) so you understand the scope: a privacy-focused, EUPL-1.2-licensed Dalamud plugin that
|
||||
intentionally removes the upstream webinterface and ships privacy-first defaults.
|
||||
- Read [`docs/UPSTREAM_SYNC.md`](docs/UPSTREAM_SYNC.md). Active cherry-picking from upstream Chat 2 has ended in the
|
||||
v1.4.x cycle; HellionChat continues as an independent codebase. Existing upstream-derived code keeps its attribution.
|
||||
New contributions stand on their own and do not need to be cherry-pick-compatible.
|
||||
- Read [`SECURITY.md`](SECURITY.md). Anything security-sensitive goes through a private advisory, never a public issue
|
||||
or PR.
|
||||
- Read the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## What I Will Accept
|
||||
|
||||
- Bug fixes for behaviour documented in the README, the in-plugin settings or the changelog.
|
||||
- Translation contributions for Hellion-specific strings via direct pull requests against
|
||||
`HellionChat/Resources/HellionStrings.*.resx`. Translations for upstream Chat 2 strings (`Language.*.resx`) are not
|
||||
handled here; those go to the upstream Chat 2 project.
|
||||
- Documentation improvements (README, comments, this file).
|
||||
- Performance fixes with a measurable before/after.
|
||||
- New features that fit the privacy-first scope and do not duplicate what an existing Dalamud plugin already does well.
|
||||
|
||||
## What I Will Probably Decline
|
||||
|
||||
- Re-introducing the webinterface or any remote-access feature. It was removed in v0.2.0 on purpose. See the README
|
||||
section "Was gegenüber Chat 2 fehlt".
|
||||
- Features that bypass the privacy filter or weaken the default retention behaviour without an explicit, documented
|
||||
opt-in.
|
||||
- Sweeping refactors that touch large parts of the codebase. The maintenance cost outweighs the benefit for a one-person
|
||||
project. (This used to be doubly important because of the upstream cherry-pick path; that path is closed now, but the
|
||||
rule still holds on its own merits.)
|
||||
- AI-generated code dropped in without disclosure or human review. See [`docs/AI_DISCLOSURE.md`](docs/AI_DISCLOSURE.md)
|
||||
for how I handle AI assistance on my side; I expect comparable transparency from contributors.
|
||||
|
||||
If you are unsure whether an idea fits, open a feature-request issue first and ask before writing code. I would rather
|
||||
say "no" to a proposal than to a finished pull request.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Open an issue (bug or feature request) using the templates under `.github/ISSUE_TEMPLATE/`. Skip this for trivial
|
||||
typos.
|
||||
2. Fork the repository and branch off `main`. Branch naming is informal; something like `fix/auto-tell-history-empty` or
|
||||
`feat/theme-export` is fine.
|
||||
3. Match the existing code style. The repository ships an `.editorconfig` that VS Code and Rider pick up automatically.
|
||||
4. Keep commits focused. Several small commits with clear messages are easier to review than one large one.
|
||||
Squash-on-merge happens at the PR level if needed.
|
||||
5. If your change touches user-visible behaviour, update the README and/or the changelog block in
|
||||
`HellionChat/HellionChat.yaml` and `repo.json`. I bump the version number myself at release time.
|
||||
6. Open the pull request against `main`. The PR template will ask you to summarise the change, the testing you did and
|
||||
any compatibility notes.
|
||||
|
||||
## Build and Test
|
||||
|
||||
The project targets `net10.0-windows` against Dalamud SDK 15. To build locally you need:
|
||||
|
||||
- .NET 10 SDK
|
||||
- A working Dalamud dev environment with `DALAMUD_HOME` set (XIVLauncher installed and launched once is the simplest
|
||||
path)
|
||||
- VS Code with the C# Dev Kit, Rider, or Visual Studio
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build HellionChat.sln -c Release
|
||||
```
|
||||
|
||||
There are currently no tests in `HellionChat.sln`. If you add a test project, point it at the relevant subsystems
|
||||
(privacy filter, configuration migration, message store) and mention it in the PR.
|
||||
|
||||
For a smoke test in-game: build, copy the output into your Dalamud `devPlugins/HellionChat/` directory and load it via
|
||||
`/xlplugins`.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Every push and every pull request runs:
|
||||
|
||||
| Workflow | What it checks |
|
||||
| ------------ | -------------------------------- |
|
||||
| `build.yml` | `dotnet build` and `dotnet test` |
|
||||
| `codeql.yml` | CodeQL security analysis |
|
||||
|
||||
A pull request will not be merged while either of these is failing. CodeQL findings on changed code need to be
|
||||
addressed; pre-existing findings on untouched code are tracked separately.
|
||||
|
||||
## Translations
|
||||
|
||||
Hellion-specific strings live in `HellionChat/Resources/HellionStrings.resx` (English source) and
|
||||
`HellionStrings.<lang>.resx` (per-language). These are accepted as direct pull requests.
|
||||
|
||||
The upstream Chat 2 strings in `HellionChat/Resources/Language.*.resx` are **not** translated here. They are kept as-is
|
||||
from the last upstream sync and remain the work of the Chat 2 Crowdin community. Active cherry-picking from upstream
|
||||
ended in the v1.4.x cycle (see [`docs/UPSTREAM_SYNC.md`](docs/UPSTREAM_SYNC.md)), so future translation improvements to
|
||||
those upstream strings will not flow into HellionChat automatically anymore. If you have improvements for the original
|
||||
Chat 2 strings, please contribute them to [Infiziert90/ChatTwo](https://github.com/Infiziert90/ChatTwo) directly.
|
||||
|
||||
## Licensing
|
||||
|
||||
By submitting a pull request you confirm that:
|
||||
|
||||
- Your contribution is your own work, or you have the right to contribute it under the project licence.
|
||||
- You agree that your contribution will be released under the [EUPL-1.2](LICENSE), the same licence as the rest of the
|
||||
project.
|
||||
|
||||
There is no separate CLA. Forking HellionChat is explicitly permitted under the EUPL-1.2, as with any EUPL-licensed
|
||||
project.
|
||||
|
||||
## Response Times
|
||||
|
||||
| Channel | Address |
|
||||
| ------------- | --------------------------------------- |
|
||||
| GitHub Issues | Preferred for bugs and feature requests |
|
||||
| Discord DM | `@j.j_kazama` |
|
||||
| Email | `kontakt@hellion-media.de` |
|
||||
|
||||
I respond on weekdays during European business hours and take weekends and FFXIV patch days off. A pull request that
|
||||
sits for a few days has not been ignored. Pinging once after a week is fine; please do not ping daily.
|
||||
|
||||
## First-time setup
|
||||
|
||||
After cloning, run once:
|
||||
|
||||
```bash
|
||||
./scripts/setup-hooks.sh
|
||||
```
|
||||
|
||||
This wires `core.hooksPath` to `.githooks/`. The pre-push hook runs preflight (versions/manifest/changelog/build).
|
||||
|
||||
### Test suite
|
||||
|
||||
The plugin's test suite lives in a separate local repository and is not part of this codebase. If you need access for
|
||||
development, contact the maintainer.
|
||||
@@ -0,0 +1,51 @@
|
||||
HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
Source code
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Copyright (c) 2022-2026 **[Infiziert90 (Infi)](https://github.com/Infiziert90)** and **[Anna](https://github.com/anna-is-cute)**
|
||||
Original ChatTwo authors and copyright holders of the upstream
|
||||
plugin this fork is built on. Their work covers the message store,
|
||||
the channel filtering, the sidebar tab system, the FFXIV chat
|
||||
hooks, the localisation infrastructure and most of the
|
||||
architecture HellionChat still relies on.
|
||||
|
||||
Copyright (c) 2025-2026 Florian Wathling / Hellion Online Media
|
||||
HellionChat-specific modifications, including the privacy filter,
|
||||
per-channel retention sweep, export pipeline, Auto-Tell-Tabs,
|
||||
German localisation and the EUPL-1.2 fork maintenance.
|
||||
|
||||
Source code is licensed under the European Union Public Licence
|
||||
(EUPL), Version 1.2 only. The full Licence text lives in the LICENSE
|
||||
file at the root of this repository. The official Licence website is
|
||||
at: <https://eupl.eu/1.2/en/>
|
||||
|
||||
This Work is provided "AS IS" without warranties of any kind. See
|
||||
Article 7 (Disclaimer of Warranty) and Article 8 (Disclaimer of
|
||||
Liability) of the Licence for the legally binding wording.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
Visual assets
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
Copyright (c) 2026 Florian Eck
|
||||
|
||||
Designer of the Hellion Forge logo and Hellion Online Media logo
|
||||
(variants located in docs/images and HellionChat/images).
|
||||
Exclusive usage and marketing rights licensed to Hellion Online
|
||||
Media. These assets are NOT covered by the EUPL-1.2 source code
|
||||
licence above and may not be reused, modified, or redistributed
|
||||
without separate permission from the copyright holder.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
Bundled assets
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Exo 2 font (HellionChat/Resources/HellionFont.ttf)
|
||||
SIL Open Font License 1.1, full text in HellionFont-OFL.txt.
|
||||
Bundled with permission per the OFL terms.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Acknowledgements directed at the upstream ChatTwo authors live in
|
||||
NOTICE.md. The manual upstream-sync workflow lives in UPSTREAM_SYNC.md.
|
||||
@@ -1,167 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ChatTwo.Tests;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs history-preload coverage.
|
||||
//
|
||||
// These tests exercise MessageStore.GetTellHistoryWithSender, the query the
|
||||
// AutoTellTabsService uses to populate a freshly spawned temp tab with the
|
||||
// last conversations with that player.
|
||||
//
|
||||
// NOTE: like the rest of ChatTwo.Tests today, these will fail at runtime
|
||||
// until the project's Dalamud.dll runtime dependency is sorted out (see
|
||||
// Phase-2 backlog item "Test-Projekt fixen"). Compile-time the suite builds
|
||||
// fine via DALAMUD_HOME, so the tests guard against API drift even before
|
||||
// they can be executed locally.
|
||||
[TestClass]
|
||||
[TestSubject(typeof(MessageStore))]
|
||||
public class AutoTellTabsHistoryTest
|
||||
{
|
||||
public TestContext TestContext { get; set; }
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void GetTellHistoryWithSender_FiltersByNameAndWorld()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
const ulong receiver = 99001;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Two tells with the target sender, one with a different sender on
|
||||
// the same world, one with the same name on a different world. Only
|
||||
// the first two should make it into the result.
|
||||
var asukaLichIn = TellMessage("Asuka", 76, receiver, now.AddMinutes(-30), ChatType.TellIncoming);
|
||||
var asukaLichOut = TellMessage("Asuka", 76, receiver, now.AddMinutes(-20), ChatType.TellOutgoing);
|
||||
var broboLich = TellMessage("Brobo", 76, receiver, now.AddMinutes(-10), ChatType.TellIncoming);
|
||||
var asukaOmega = TellMessage("Asuka", 90, receiver, now.AddMinutes(-5), ChatType.TellIncoming);
|
||||
|
||||
store.UpsertMessage(asukaLichIn);
|
||||
store.UpsertMessage(asukaLichOut);
|
||||
store.UpsertMessage(broboLich);
|
||||
store.UpsertMessage(asukaOmega);
|
||||
|
||||
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 50);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
// Result is oldest-first so a tab can append messages chronologically.
|
||||
Assert.AreEqual(asukaLichIn.Id, result[0].Id);
|
||||
Assert.AreEqual(asukaLichOut.Id, result[1].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void GetTellHistoryWithSender_RespectsLimit()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
const ulong receiver = 99002;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var msg = TellMessage("Asuka", 76, receiver, now.AddMinutes(-i - 1), ChatType.TellIncoming);
|
||||
store.UpsertMessage(msg);
|
||||
}
|
||||
|
||||
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 5);
|
||||
|
||||
Assert.AreEqual(5, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void GetTellHistoryWithSender_ZeroLimitReturnsEmpty()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
const ulong receiver = 99003;
|
||||
|
||||
var msg = TellMessage("Asuka", 76, receiver, DateTimeOffset.UtcNow, ChatType.TellIncoming);
|
||||
store.UpsertMessage(msg);
|
||||
|
||||
var result = store.GetTellHistoryWithSender(receiver, "Asuka", 76, limit: 0);
|
||||
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void GetTellHistoryWithSender_IgnoresOtherReceivers()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
const ulong ourReceiver = 99004;
|
||||
const ulong otherReceiver = 99005;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Tell on the local player's account.
|
||||
var ours = TellMessage("Asuka", 76, ourReceiver, now.AddMinutes(-1), ChatType.TellIncoming);
|
||||
// Same sender, but logged against a different local character —
|
||||
// common when the user has alts. Must not surface.
|
||||
var foreign = TellMessage("Asuka", 76, otherReceiver, now, ChatType.TellIncoming);
|
||||
|
||||
store.UpsertMessage(ours);
|
||||
store.UpsertMessage(foreign);
|
||||
|
||||
var result = store.GetTellHistoryWithSender(ourReceiver, "Asuka", 76, limit: 50);
|
||||
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(ours.Id, result[0].Id);
|
||||
}
|
||||
|
||||
private static Message TellMessage(
|
||||
string senderName,
|
||||
uint senderWorld,
|
||||
ulong receiver,
|
||||
DateTimeOffset dateTime,
|
||||
ChatType chatType)
|
||||
{
|
||||
var senderSeString = new SeStringBuilder()
|
||||
.Add(new PlayerPayload(senderName, senderWorld))
|
||||
.AddText(senderName)
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.Build();
|
||||
|
||||
var contentSeString = new SeStringBuilder()
|
||||
.AddText("test message")
|
||||
.Build();
|
||||
|
||||
var senderChunks = ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, chatType).ToList();
|
||||
var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, chatType).ToList();
|
||||
|
||||
var chatCode = new ChatCode((XivChatType)chatType, XivChatRelationKind.LocalPlayer, XivChatRelationKind.LocalPlayer);
|
||||
return new Message(
|
||||
Guid.NewGuid(),
|
||||
receiver,
|
||||
0,
|
||||
dateTime,
|
||||
chatCode,
|
||||
senderChunks,
|
||||
contentChunks,
|
||||
senderSeString,
|
||||
contentSeString,
|
||||
Guid.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-windows</TargetFrameworks>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2025.2.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="morelinq" Version="4.4.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.6.3" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.6.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ChatTwo\ChatTwo.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<DalamudLibPath>$(AppData)\XIVLauncher\addon\Hooks\dev</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
|
||||
<DalamudLibPath>$(DALAMUD_HOME)</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(IsCI)' == 'true'">
|
||||
<DalamudLibPath>$(HOME)/dalamud</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Dalamud">
|
||||
<HintPath>$(DalamudLibPath)\Dalamud.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="FFXIVClientStructs">
|
||||
<HintPath>$(DalamudLibPath)\FFXIVClientStructs.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Lumina">
|
||||
<HintPath>$(DalamudLibPath)\Lumina.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Lumina.Excel">
|
||||
<HintPath>$(DalamudLibPath)\Lumina.Excel.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -1,293 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Chat2PartyFinderPayload = ChatTwo.Util.PartyFinderPayload;
|
||||
|
||||
namespace ChatTwo.Tests;
|
||||
|
||||
[TestClass]
|
||||
[TestSubject(typeof(MessageStore))]
|
||||
public class MessageStoreTest {
|
||||
// From Message.cs
|
||||
private static readonly byte[] ExtraChatChannelPayloadBytes = [0, 0x27, 18, 0x20];
|
||||
|
||||
public TestContext TestContext { get; set; }
|
||||
|
||||
public static string GetImportPath() {
|
||||
string[] importPaths = [
|
||||
@".\TestData",
|
||||
@"..\TestData",
|
||||
@"..\..\TestData",
|
||||
@"..\..\..\TestData",
|
||||
];
|
||||
var importPath = importPaths.FirstOrDefault(Directory.Exists);
|
||||
if (string.IsNullOrEmpty(importPath)) {
|
||||
throw new DirectoryNotFoundException("Could not find the import path");
|
||||
}
|
||||
return importPath;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void StoreAndRetrieve() {
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
// Write the message.
|
||||
var input = BigMessage();
|
||||
store.UpsertMessage(input);
|
||||
|
||||
// Read the message back.
|
||||
using var messageEnumerator = store.GetMostRecentMessages();
|
||||
var messages = messageEnumerator.ToList();
|
||||
Assert.AreEqual(1, messages.Count);
|
||||
AssertMessagesEqual(input, messages.First());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public void RetrieveMultiple() {
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
// Insert 10 messages in the wrong order of date.
|
||||
var messages = new List<Message>();
|
||||
const uint receiver = 12345;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var message = BigMessage(true, receiver, now.AddSeconds(-i));
|
||||
TestContext.WriteLine($"Inserting message {i}: {message.Id}");
|
||||
store.UpsertMessage(message);
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
// Insert a message for a different receiver. This shouldn't be returned
|
||||
// because of the receiver filtering.
|
||||
var otherReceiverMsg = BigMessage(receiver: receiver + 1, dateTime: now.AddSeconds(1));
|
||||
TestContext.WriteLine($"Inserting other receiver message: {otherReceiverMsg.Id}");
|
||||
store.UpsertMessage(otherReceiverMsg);
|
||||
|
||||
// Query the most recent 5 messages. Should return the 4 newest messages
|
||||
// from the list, as well as the different receiver message because we
|
||||
// aren't filtering.
|
||||
using var unfilteredMessageEnumerator = store.GetMostRecentMessages(count: 5);
|
||||
var outputMessages = unfilteredMessageEnumerator.ToList();
|
||||
var gotIds = outputMessages.Select(m => m.Id).ToList();
|
||||
TestContext.WriteLine($"Query 1 got IDs: {string.Join(", ", gotIds)}");
|
||||
AssertGuidsEqual(new List<Guid> {
|
||||
messages[3].Id,
|
||||
messages[2].Id,
|
||||
messages[1].Id,
|
||||
messages[0].Id,
|
||||
otherReceiverMsg.Id
|
||||
}, gotIds);
|
||||
|
||||
// Query the most recent 5 messages but filter by receiver ID.
|
||||
using var filteredByReceiverMessageEnumerator = store.GetMostRecentMessages(receiver: receiver, count: 5);
|
||||
outputMessages = filteredByReceiverMessageEnumerator.ToList();
|
||||
gotIds = outputMessages.Select(m => m.Id).ToList();
|
||||
TestContext.WriteLine($"Query 2 got IDs: {string.Join(", ", gotIds)}");
|
||||
AssertGuidsEqual(new List<Guid> {
|
||||
messages[4].Id,
|
||||
messages[3].Id,
|
||||
messages[2].Id,
|
||||
messages[1].Id,
|
||||
messages[0].Id,
|
||||
}, gotIds);
|
||||
|
||||
// Query the most recent 5 messages but only since a specific date.
|
||||
using var filteredByReceiverAndDateMessageEnumerator = store.GetMostRecentMessages(receiver, since: messages[1].Date, count: 5);
|
||||
outputMessages = filteredByReceiverAndDateMessageEnumerator.ToList();
|
||||
gotIds = outputMessages.Select(m => m.Id).ToList();
|
||||
TestContext.WriteLine($"Query 3 got IDs: {string.Join(", ", gotIds)}");
|
||||
AssertGuidsEqual(new List<Guid> {
|
||||
messages[1].Id,
|
||||
messages[0].Id,
|
||||
}, gotIds);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
// This test guards against the data format changing in an incompatible way.
|
||||
public void RetrieveExisting() {
|
||||
var input = BigMessage(uniqId: false);
|
||||
|
||||
var dbPath = Path.Join(GetImportPath(), "existing.db");
|
||||
TestContext.WriteLine($"Using existing database: {dbPath}");
|
||||
Assert.IsTrue(File.Exists(dbPath));
|
||||
|
||||
// Uncomment this section to regenerate the existing database.
|
||||
/*
|
||||
File.Delete(dbPath);
|
||||
using (var newStore = new MessageStore(dbPath)) {
|
||||
newStore.UpsertMessage(input);
|
||||
}
|
||||
*/
|
||||
|
||||
using var store = new MessageStore(dbPath);
|
||||
using var existingMessageEnumerator = store.GetMostRecentMessages();
|
||||
var output = existingMessageEnumerator.ToList();
|
||||
Assert.AreEqual(1, output.Count);
|
||||
AssertMessagesEqual(input, output[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(30_000)]
|
||||
public void ProfileMany() {
|
||||
const int count = 20_000;
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
|
||||
var dbPath = Path.Join(tempDir.FullName, "test.db");
|
||||
TestContext.WriteLine("Using database path: " + dbPath);
|
||||
using var store = new MessageStore(dbPath);
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var message = BigMessage(uniqId: true);
|
||||
store.UpsertMessage(message);
|
||||
}
|
||||
|
||||
using var messageEnumerator = store.GetMostRecentMessages(count: count);
|
||||
var messages = messageEnumerator.ToList();
|
||||
Assert.AreEqual(count, messages.Count);
|
||||
foreach (var message in messages) {
|
||||
// Load the message because they are lazily parsed.
|
||||
Assert.IsTrue(message.Id != Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Message BigMessage(bool uniqId = true, uint receiver = 12345, DateTimeOffset? dateTime = null) {
|
||||
// NOTE: These values aren't valid in the game.
|
||||
// NOTE: we can't test UiForeground, UiGlow, or AutoTranslatePayload
|
||||
// because they load data from the game.
|
||||
var senderSeString = new SeStringBuilder()
|
||||
.AddText("<")
|
||||
.Add(new PlayerPayload("Player Name", 12345))
|
||||
.AddItalics("Player Name")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.AddText(">: ")
|
||||
.Build();
|
||||
var extraChatId = Guid.Parse("03d9e6d4-dc1a-4005-bbe7-66b8c3529277");
|
||||
var contentSeString = new SeStringBuilder()
|
||||
.Add(new RawPayload(ExtraChatChannelPayloadBytes.Concat(extraChatId.ToByteArray()).ToArray()))
|
||||
.AddIcon(BitmapFontIcon.IslandSanctuary)
|
||||
.AddMapLink(1, 2, 3, 4)
|
||||
.AddText("map")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.AddQuestLink(12345)
|
||||
.AddText("quest")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.Add(new DalamudLinkPayload())
|
||||
.AddText("dalamud")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.AddStatusLink(12345)
|
||||
.AddText("status")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.AddPartyFinderLink(12345)
|
||||
.AddText("party finder")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.Build();
|
||||
|
||||
// Add Chat 2 specific payloads (that can't be serialized into the
|
||||
// SeString).
|
||||
var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, ChatType.Say).ToList();
|
||||
contentChunks = contentChunks.Concat([
|
||||
new TextChunk(ChunkSource.Content, new Chat2PartyFinderPayload(12345), "chat 2 party finder"),
|
||||
new TextChunk(ChunkSource.Content, new AchievementPayload(12345), "chat 2 achievement"),
|
||||
new TextChunk(ChunkSource.Content, new UriPayload(new Uri("https://dalamud.dev")), "chat 2 uri"),
|
||||
]).ToList();
|
||||
|
||||
var chatCode = new ChatCode((XivChatType)46, XivChatRelationKind.LocalPlayer, XivChatRelationKind.EngagedEnemy);
|
||||
return new Message(
|
||||
uniqId ? Guid.NewGuid() : Guid.Parse("f011343e-6a21-49e5-a6f9-238f0f1f8c2c"),
|
||||
receiver,
|
||||
54321,
|
||||
dateTime ?? DateTimeOffset.FromUnixTimeMilliseconds(1713520182440),
|
||||
chatCode,
|
||||
ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, ChatType.Debug).ToList(),
|
||||
contentChunks,
|
||||
senderSeString,
|
||||
contentSeString,
|
||||
extraChatId
|
||||
);
|
||||
}
|
||||
|
||||
internal static void AssertMessagesEqual(Message input, Message output) {
|
||||
// Check basic fields.
|
||||
Assert.AreEqual(input.Id, output.Id);
|
||||
Assert.AreEqual(input.Receiver, output.Receiver);
|
||||
Assert.AreEqual(input.ContentId, output.ContentId);
|
||||
// Assert time is within 1 second
|
||||
var timeDifference = Math.Abs(input.Date.ToUniversalTime().Subtract(output.Date.ToUniversalTime()).TotalSeconds);
|
||||
Assert.IsTrue(timeDifference < 1);
|
||||
Assert.AreEqual(input.Code, output.Code);
|
||||
Assert.AreEqual($"{input.SenderSource.Encode():X}", $"{output.SenderSource.Encode():X}");
|
||||
Assert.AreEqual($"{input.ContentSource.Encode():X}", $"{output.ContentSource.Encode():X}");
|
||||
Assert.AreEqual(input.SortCodeV2, output.SortCodeV2);
|
||||
Assert.AreEqual(input.ExtraChatChannel, output.ExtraChatChannel);
|
||||
|
||||
// Check chunks.
|
||||
AssertChunksEqual(input.Sender, output.Sender);
|
||||
AssertChunksEqual(input.Content, output.Content);
|
||||
}
|
||||
|
||||
private static void AssertChunksEqual(IReadOnlyList<Chunk> inputChunks, IReadOnlyList<Chunk> outputChunks) {
|
||||
Assert.AreEqual(inputChunks.Count, outputChunks.Count);
|
||||
for (var i = 0; i < inputChunks.Count; i++) {
|
||||
var inputChunk = inputChunks[i];
|
||||
var outputChunk = outputChunks[i];
|
||||
Assert.AreEqual(inputChunk.Source, outputChunk.Source);
|
||||
switch (inputChunk.Link) {
|
||||
case AchievementPayload inputAchievementPayload:
|
||||
Assert.AreEqual(inputAchievementPayload.Id, ((AchievementPayload) outputChunk.Link)!.Id);
|
||||
break;
|
||||
case Chat2PartyFinderPayload inputPartyFinderPayload:
|
||||
Assert.AreEqual(inputPartyFinderPayload.Id, ((Chat2PartyFinderPayload) outputChunk.Link)!.Id);
|
||||
break;
|
||||
case UriPayload inputUriPayload:
|
||||
Assert.AreEqual(inputUriPayload.Uri, ((UriPayload) outputChunk.Link)!.Uri);
|
||||
break;
|
||||
case null:
|
||||
Assert.IsTrue(outputChunk.Link == null);
|
||||
break;
|
||||
default:
|
||||
Assert.AreEqual($"{inputChunk.Link.Encode():X}", $"{outputChunk.Link!.Encode():X}");
|
||||
break;
|
||||
}
|
||||
|
||||
switch (inputChunk) {
|
||||
case TextChunk inputTextChunk:
|
||||
var outputTextChunk = (TextChunk)outputChunk;
|
||||
Assert.AreEqual(inputTextChunk.FallbackColour, outputTextChunk.FallbackColour);
|
||||
Assert.AreEqual(inputTextChunk.Foreground, outputTextChunk.Foreground);
|
||||
Assert.AreEqual(inputTextChunk.Glow, outputTextChunk.Glow);
|
||||
Assert.AreEqual(inputTextChunk.Italic, outputTextChunk.Italic);
|
||||
Assert.AreEqual(inputTextChunk.Content, outputTextChunk.Content);
|
||||
break;
|
||||
case IconChunk inputIconChunk:
|
||||
Assert.AreEqual(inputIconChunk.Icon, ((IconChunk) outputChunk).Icon);
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Unknown chunk type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertGuidsEqual(IReadOnlyList<Guid> expected, IReadOnlyList<Guid> got) {
|
||||
Assert.AreEqual(expected.Count, got.Count);
|
||||
for (var i = 0; i < expected.Count; i++) {
|
||||
Assert.AreEqual(expected[i].ToString(), got[i].ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
-22
@@ -1,22 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatTwo", "ChatTwo\ChatTwo.csproj", "{739F75E6-B65F-41EF-9D90-F7BC519E4875}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatTwo.Tests", "ChatTwo.Tests\ChatTwo.Tests.csproj", "{A9FE423A-240C-4EDA-ACC6-21474B562128}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A9FE423A-240C-4EDA-ACC6-21474B562128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A9FE423A-240C-4EDA-ACC6-21474B562128}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A9FE423A-240C-4EDA-ACC6-21474B562128}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A9FE423A-240C-4EDA-ACC6-21474B562128}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,358 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.GameFunctions.Types;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
|
||||
namespace ChatTwo;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs.
|
||||
//
|
||||
// Spawns a session-only tab per /tell partner so a club greeter can track
|
||||
// multiple parallel conversations without losing context. Subscribes to
|
||||
// MessageManager.MessageProcessed for live tells and to ClientState.Logout
|
||||
// for the cleanup pass; everything else hangs off these two entry points.
|
||||
//
|
||||
// See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian vault).
|
||||
internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
private readonly MessageManager _messageManager;
|
||||
private readonly MessageStore _store;
|
||||
private readonly object _tempTabsLock = new();
|
||||
|
||||
private bool _initialized;
|
||||
|
||||
internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store)
|
||||
{
|
||||
_plugin = plugin;
|
||||
_messageManager = messageManager;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
internal int ActiveTempTabCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
return Plugin.Config.Tabs.Count(t => t.IsTempTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_messageManager.MessageProcessed += HandleTell;
|
||||
Plugin.ClientState.Logout += OnLogout;
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Plugin.ClientState.Logout -= OnLogout;
|
||||
_messageManager.MessageProcessed -= HandleTell;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
internal void HandleTell(Message message)
|
||||
{
|
||||
if (!Plugin.Config.EnableAutoTellTabs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.Code.Type != ChatType.TellIncoming && message.Code.Type != ChatType.TellOutgoing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var partner = ExtractTellPartner(message);
|
||||
if (partner == null)
|
||||
{
|
||||
// Real message without a player payload — e.g. GM tells, which
|
||||
// we deliberately skip. The diagnostics make future regressions
|
||||
// (FFXIV changing tell payload shape, new edge cases) findable
|
||||
// without having to crank up debug logging at the source.
|
||||
Plugin.Log.Warning(
|
||||
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " +
|
||||
$"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " +
|
||||
$"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, " +
|
||||
$"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||
if (existing != null)
|
||||
{
|
||||
// Tab already exists; Tab.Matches has already routed this
|
||||
// message via the MessageManager pipeline (see Task 2 sender
|
||||
// filter).
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
||||
{
|
||||
DropOldestTempTab();
|
||||
}
|
||||
|
||||
SpawnTempTab(partner.Value, message);
|
||||
}
|
||||
}
|
||||
|
||||
private (string Name, uint World)? ExtractTellPartner(Message message)
|
||||
{
|
||||
if (message.Code.Type == ChatType.TellIncoming)
|
||||
{
|
||||
// Incoming tell: the sender is the conversation partner. The
|
||||
// PlayerPayload normally rides on a chunk's Link slot, but for
|
||||
// some tell types FFXIV only puts it in the raw SeString —
|
||||
// fall back to that before giving up.
|
||||
var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
if (fromSender != null)
|
||||
{
|
||||
return (fromSender.PlayerName, fromSender.World.RowId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Outgoing tell: the local player is the sender, the partner shows
|
||||
// up either as a payload in the content (for tells typed via the
|
||||
// Chat 2 input bar) or as the channel's tracked tell target (set by
|
||||
// the SetContextTellTarget game hook). Same SeString fallback.
|
||||
var fromContent = ChunkUtil.TryGetPlayerPayload(message.Content)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
if (fromContent != null)
|
||||
{
|
||||
return (fromContent.PlayerName, fromContent.World.RowId);
|
||||
}
|
||||
|
||||
var current = _plugin.CurrentTab.CurrentChannel.TellTarget
|
||||
?? _plugin.CurrentTab.CurrentChannel.TempTellTarget;
|
||||
if (current != null && current.IsSet())
|
||||
{
|
||||
return (current.Name, current.World);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Tab? FindTempTab(string name, uint world)
|
||||
{
|
||||
return Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||
t.IsTempTab
|
||||
&& t.TellTarget != null
|
||||
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
|
||||
&& t.TellTarget.World == world);
|
||||
}
|
||||
|
||||
private void DropOldestTempTab()
|
||||
{
|
||||
// Greeted tabs are dropped before un-greeted ones (the user said
|
||||
// "I'm done with that conversation"), and within each bucket we
|
||||
// pick the oldest LastActivity. This protects active conversations
|
||||
// and unfinished greetings while still freeing up a slot.
|
||||
var victim = Plugin.Config.Tabs
|
||||
.Select((tab, idx) => (Tab: tab, Index: idx))
|
||||
.Where(t => t.Tab.IsTempTab)
|
||||
.OrderByDescending(t => t.Tab.IsGreeted)
|
||||
.ThenBy(t => t.Tab.LastActivity)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (victim.Tab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||
|
||||
// Re-anchor the active tab so the user does not silently end up on
|
||||
// a different conversation when their tab gets dropped or shifted.
|
||||
if (victim.Index <= _plugin.LastTab)
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||
{
|
||||
var tab = BuildTempTab(partner.Name, partner.World);
|
||||
|
||||
// Preload first so the tab opens with chronological history above
|
||||
// the current message — and so a slow DB query never causes a
|
||||
// visible "empty tab, then history pops in" effect on screen.
|
||||
PreloadHistory(tab, partner.Name, partner.World);
|
||||
|
||||
tab.AddMessage(currentMessage, unread: true);
|
||||
Plugin.Config.Tabs.Add(tab);
|
||||
}
|
||||
|
||||
private static Tab BuildTempTab(string playerName, uint worldRowId)
|
||||
{
|
||||
return new Tab
|
||||
{
|
||||
Name = FormatTabName(playerName, worldRowId),
|
||||
IsTempTab = true,
|
||||
AllSenderMessages = true,
|
||||
TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct),
|
||||
Channel = InputChannel.Tell,
|
||||
DisplayTimestamp = true,
|
||||
UnreadMode = UnreadMode.Unseen,
|
||||
HideWhenInactive = false,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTabName(string playerName, uint worldRowId)
|
||||
{
|
||||
if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow))
|
||||
{
|
||||
return $"{playerName}@{worldRow.Name}";
|
||||
}
|
||||
// World sheet lookup miss is rare (only for FFXIV worlds Dalamud has
|
||||
// not yet seen). Fall back to the raw RowId so the user still has a
|
||||
// unique, readable label.
|
||||
return $"{playerName}@World{worldRowId}";
|
||||
}
|
||||
|
||||
private void PreloadHistory(Tab tab, string senderName, uint senderWorld)
|
||||
{
|
||||
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
|
||||
if (preloadCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var history = _store.GetTellHistoryWithSender(
|
||||
_messageManager.CurrentContentId,
|
||||
senderName,
|
||||
senderWorld,
|
||||
preloadCount);
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
// No prior tells with this player — leave the tab to start
|
||||
// empty so the user does not see a "history loaded" marker
|
||||
// sitting alone above the very first message.
|
||||
return;
|
||||
}
|
||||
|
||||
// The history list is already oldest-first, so a plain AddPrune
|
||||
// loop produces the chronological order the user expects to see
|
||||
// when the tab opens.
|
||||
foreach (var message in history)
|
||||
{
|
||||
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
||||
}
|
||||
|
||||
// Visible separator between the loaded history and the live
|
||||
// tell that triggered this spawn. Goes in last so it sorts
|
||||
// after the historical messages but before the current one.
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
|
||||
MessageManager.MessageDisplayLimit);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: the tab still spawns, but the user gets a visible
|
||||
// notice instead of silently missing history. The error logs
|
||||
// once with full stack trace for diagnosis.
|
||||
Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed");
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
|
||||
MessageManager.MessageDisplayLimit);
|
||||
}
|
||||
}
|
||||
|
||||
private static Message MakeSystemMarker(string text)
|
||||
{
|
||||
var seString = new SeStringBuilder().AddText(text).Build();
|
||||
var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList();
|
||||
var code = new ChatCode((XivChatType)ChatType.System, 0, 0);
|
||||
return Message.FakeMessage(chunks, code);
|
||||
}
|
||||
|
||||
internal void MarkGreeted(Tab tab)
|
||||
{
|
||||
SetGreeted(tab, true);
|
||||
}
|
||||
|
||||
internal void UnmarkGreeted(Tab tab)
|
||||
{
|
||||
SetGreeted(tab, false);
|
||||
}
|
||||
|
||||
internal bool IsGreeted(Tab tab)
|
||||
{
|
||||
return tab.IsGreeted;
|
||||
}
|
||||
|
||||
private void SetGreeted(Tab tab, bool greeted)
|
||||
{
|
||||
if (tab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Frame-race guard (E5): the sidebar might still render a tab
|
||||
// that has already been removed by LRU drop or logout cleanup.
|
||||
// Silently skip the toggle so we don't mutate stale state.
|
||||
if (!Plugin.Config.Tabs.Contains(tab))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tab.IsGreeted = greeted;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLogout(int type, int code)
|
||||
{
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Snapshot whether the active tab is about to be removed, BEFORE
|
||||
// we mutate the list — index lookups would lie to us afterwards.
|
||||
var lastIndex = _plugin.LastTab;
|
||||
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab;
|
||||
|
||||
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
// Force a switch to tab 0 if the active tab was a temp tab OR
|
||||
// if drops before the active index pushed LastTab out of range.
|
||||
// Otherwise the user keeps their current persistent tab.
|
||||
var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
if (currentWasTempTab || !stillValid)
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||
<PropertyGroup>
|
||||
<!-- Hellion Chat versioning runs separately from upstream Chat 2.
|
||||
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
|
||||
derives from. -->
|
||||
<Version>0.5.0</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- HellionChat fork: assembly is renamed so Dalamud uses
|
||||
pluginConfigs/HellionChat instead of pluginConfigs/ChatTwo,
|
||||
keeping our state independent from the upstream plugin.
|
||||
Code namespace stays ChatTwo.* so upstream cherry-picks
|
||||
apply cleanly. -->
|
||||
<AssemblyName>HellionChat</AssemblyName>
|
||||
<RootNamespace>ChatTwo</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="morelinq" Version="4.4.0" />
|
||||
<PackageReference Include="Pidgin" Version="3.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Resources\Language.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Language.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Resources\Language.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Language.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- HellionChat — Hellion-specific resource bundle (HellionStrings.resx
|
||||
+ HellionStrings.<lang>.resx) is picked up automatically by the SDK
|
||||
default include. Designer.cs is hand-maintained, no auto-gen needed. -->
|
||||
|
||||
<!-- Bundled Hellion font (Exo 2, OFL-1.1). Embedded as a manifest
|
||||
resource with a fixed LogicalName so FontManager can pull the
|
||||
bytes back at runtime via AddFontFromMemory. The OFL license
|
||||
text travels with it inside the assembly to satisfy the
|
||||
"license must be distributed with the font" clause. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\HellionFont.ttf">
|
||||
<LogicalName>HellionFont.ttf</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\HellionFont-OFL.txt">
|
||||
<LogicalName>HellionFont-OFL.txt</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="images\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy images/icon.png next to the built DLL so Dalamud's local
|
||||
plugin loader finds it at <plugindir>/images/icon.png. The
|
||||
DalamudPackager.targets file in this directory then includes
|
||||
the same path inside the release ZIP — see that file for the
|
||||
full packaging override. -->
|
||||
<ItemGroup>
|
||||
<None Include="images\icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,42 +0,0 @@
|
||||
using Dalamud.Game.Text;
|
||||
|
||||
namespace ChatTwo.Code;
|
||||
|
||||
[Flags]
|
||||
public enum ChatSource : ushort
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>The player currently controlled by the local client.</summary>
|
||||
LocalPlayer = 1 << XivChatRelationKind.LocalPlayer,
|
||||
|
||||
/// <summary>A player in the same 4-man or 8-man party as the local player.</summary>
|
||||
PartyMember = 1 << XivChatRelationKind.PartyMember,
|
||||
|
||||
/// <summary>A player in the same alliance raid.</summary>
|
||||
AllianceMember = 1 << XivChatRelationKind.AllianceMember,
|
||||
|
||||
/// <summary>A player not in the local player's party or alliance.</summary>
|
||||
OtherPlayer = 1 << XivChatRelationKind.OtherPlayer,
|
||||
|
||||
/// <summary>An enemy entity that is currently in combat with the player or party.</summary>
|
||||
EngagedEnemy = 1 << XivChatRelationKind.EngagedEnemy,
|
||||
|
||||
/// <summary>An enemy entity that is not yet in combat or claimed.</summary>
|
||||
UnengagedEnemy = 1 << XivChatRelationKind.UnengagedEnemy,
|
||||
|
||||
/// <summary>An NPC that is friendly or neutral to the player (e.g., EventNPCs).</summary>
|
||||
FriendlyNpc = 1 << XivChatRelationKind.FriendlyNpc,
|
||||
|
||||
/// <summary>A pet (Summoner/Scholar) or companion (Chocobo) belonging to the local player.</summary>
|
||||
PetOrCompanion = 1 << XivChatRelationKind.PetOrCompanion,
|
||||
|
||||
/// <summary>A pet or companion belonging to a member of the local player's party.</summary>
|
||||
PetOrCompanionParty = 1 << XivChatRelationKind.PetOrCompanionParty,
|
||||
|
||||
/// <summary>A pet or companion belonging to a member of the alliance.</summary>
|
||||
PetOrCompanionAlliance = 1 << XivChatRelationKind.PetOrCompanionAlliance,
|
||||
|
||||
/// <summary>A pet or companion belonging to a player not in the party or alliance.</summary>
|
||||
PetOrCompanionOther = 1 << XivChatRelationKind.PetOrCompanionOther,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using ChatTwo.Resources;
|
||||
|
||||
namespace ChatTwo.Code;
|
||||
|
||||
internal static class ChatSourceExt
|
||||
{
|
||||
internal const ChatSource All =
|
||||
ChatSource.LocalPlayer | ChatSource.PartyMember | ChatSource.AllianceMember |
|
||||
ChatSource.OtherPlayer | ChatSource.EngagedEnemy | ChatSource.UnengagedEnemy |
|
||||
ChatSource.FriendlyNpc | ChatSource.PetOrCompanion | ChatSource.PetOrCompanionParty |
|
||||
ChatSource.PetOrCompanionAlliance | ChatSource.PetOrCompanionOther;
|
||||
|
||||
internal static string Name(this ChatSource source) => source switch
|
||||
{
|
||||
ChatSource.LocalPlayer => Language.ChatSource_Self,
|
||||
ChatSource.PartyMember => Language.ChatSource_PartyMember,
|
||||
ChatSource.AllianceMember => Language.ChatSource_AllianceMember,
|
||||
ChatSource.OtherPlayer => Language.ChatSource_Other,
|
||||
ChatSource.EngagedEnemy => Language.ChatSource_EngagedEnemy,
|
||||
ChatSource.UnengagedEnemy => Language.ChatSource_UnengagedEnemy,
|
||||
ChatSource.FriendlyNpc => Language.ChatSource_FriendlyNpc,
|
||||
ChatSource.PetOrCompanion => Language.ChatSource_SelfPet,
|
||||
ChatSource.PetOrCompanionParty => Language.ChatSource_PartyPet,
|
||||
ChatSource.PetOrCompanionAlliance => Language.ChatSource_AlliancePet,
|
||||
ChatSource.PetOrCompanionOther => Language.ChatSource_OtherPet,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), source, null),
|
||||
};
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Config;
|
||||
|
||||
namespace ChatTwo.Code;
|
||||
|
||||
internal static class ChatTypeExt
|
||||
{
|
||||
internal static IEnumerable<(string, ChatType[])> SortOrder =>
|
||||
[
|
||||
(Language.Options_Tabs_ChannelTypes_Special, [ChatType.Debug, ChatType.Urgent, ChatType.Notice]),
|
||||
|
||||
(Language.Options_Tabs_ChannelTypes_Chat,
|
||||
[
|
||||
ChatType.Say,
|
||||
ChatType.Yell,
|
||||
ChatType.Shout,
|
||||
ChatType.TellIncoming,
|
||||
ChatType.TellOutgoing,
|
||||
ChatType.Party,
|
||||
ChatType.CrossParty,
|
||||
ChatType.Alliance,
|
||||
ChatType.FreeCompany,
|
||||
ChatType.PvpTeam,
|
||||
ChatType.CrossLinkshell1,
|
||||
ChatType.CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8,
|
||||
ChatType.Linkshell1,
|
||||
ChatType.Linkshell2,
|
||||
ChatType.Linkshell3,
|
||||
ChatType.Linkshell4,
|
||||
ChatType.Linkshell5,
|
||||
ChatType.Linkshell6,
|
||||
ChatType.Linkshell7,
|
||||
ChatType.Linkshell8,
|
||||
ChatType.NoviceNetwork,
|
||||
ChatType.StandardEmote,
|
||||
ChatType.CustomEmote
|
||||
]),
|
||||
|
||||
(Language.Options_Tabs_ChannelTypes_Battle,
|
||||
[
|
||||
ChatType.Damage,
|
||||
ChatType.Miss,
|
||||
ChatType.Action,
|
||||
ChatType.Item,
|
||||
ChatType.Healing,
|
||||
ChatType.GainBuff,
|
||||
ChatType.LoseBuff,
|
||||
ChatType.GainDebuff,
|
||||
ChatType.LoseDebuff
|
||||
]),
|
||||
|
||||
(Language.Options_Tabs_ChannelTypes_Announcements,
|
||||
[
|
||||
ChatType.System,
|
||||
ChatType.BattleSystem,
|
||||
ChatType.GatheringSystem,
|
||||
ChatType.Error,
|
||||
ChatType.Echo,
|
||||
ChatType.NoviceNetworkSystem,
|
||||
ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.PvpTeamAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout,
|
||||
ChatType.PvpTeamLoginLogout,
|
||||
ChatType.RetainerSale,
|
||||
ChatType.NpcDialogue,
|
||||
ChatType.NpcAnnouncement,
|
||||
ChatType.LootNotice,
|
||||
ChatType.Progress,
|
||||
ChatType.LootRoll,
|
||||
ChatType.Crafting,
|
||||
ChatType.Gathering,
|
||||
ChatType.PeriodicRecruitmentNotification,
|
||||
ChatType.Sign,
|
||||
ChatType.RandomNumber,
|
||||
ChatType.Orchestrion,
|
||||
ChatType.MessageBook,
|
||||
ChatType.Alarm,
|
||||
ChatType.GlamourNotifications
|
||||
])
|
||||
// Note: ExtraChat linkshells are handled separately in the tab settings
|
||||
// UI.
|
||||
];
|
||||
|
||||
internal static string Name(this ChatType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ChatType.Debug => Language.ChatType_Debug,
|
||||
ChatType.Urgent => Language.ChatType_Urgent,
|
||||
ChatType.Notice => Language.ChatType_Notice,
|
||||
ChatType.Say => Language.ChatType_Say,
|
||||
ChatType.Shout => Language.ChatType_Shout,
|
||||
ChatType.TellOutgoing => Language.ChatType_TellOutgoing,
|
||||
ChatType.TellIncoming => Language.ChatType_TellIncoming,
|
||||
ChatType.Party => Language.ChatType_Party,
|
||||
ChatType.Alliance => Language.ChatType_Alliance,
|
||||
ChatType.Linkshell1 => Language.ChatType_Linkshell1,
|
||||
ChatType.Linkshell2 => Language.ChatType_Linkshell2,
|
||||
ChatType.Linkshell3 => Language.ChatType_Linkshell3,
|
||||
ChatType.Linkshell4 => Language.ChatType_Linkshell4,
|
||||
ChatType.Linkshell5 => Language.ChatType_Linkshell5,
|
||||
ChatType.Linkshell6 => Language.ChatType_Linkshell6,
|
||||
ChatType.Linkshell7 => Language.ChatType_Linkshell7,
|
||||
ChatType.Linkshell8 => Language.ChatType_Linkshell8,
|
||||
ChatType.FreeCompany => Language.ChatType_FreeCompany,
|
||||
ChatType.NoviceNetwork => Language.ChatType_NoviceNetwork,
|
||||
ChatType.CustomEmote => Language.ChatType_CustomEmotes,
|
||||
ChatType.StandardEmote => Language.ChatType_StandardEmotes,
|
||||
ChatType.Yell => Language.ChatType_Yell,
|
||||
ChatType.CrossParty => Language.ChatType_CrossWorldParty,
|
||||
ChatType.PvpTeam => Language.ChatType_PvpTeam,
|
||||
ChatType.CrossLinkshell1 => Language.ChatType_CrossLinkshell1,
|
||||
ChatType.Damage => Language.ChatType_Damage,
|
||||
ChatType.Miss => Language.ChatType_Miss,
|
||||
ChatType.Action => Language.ChatType_Action,
|
||||
ChatType.Item => Language.ChatType_Item,
|
||||
ChatType.Healing => Language.ChatType_Healing,
|
||||
ChatType.GainBuff => Language.ChatType_GainBuff,
|
||||
ChatType.GainDebuff => Language.ChatType_GainDebuff,
|
||||
ChatType.LoseBuff => Language.ChatType_LoseBuff,
|
||||
ChatType.LoseDebuff => Language.ChatType_LoseDebuff,
|
||||
ChatType.Alarm => Language.ChatType_Alarm,
|
||||
ChatType.GlamourNotifications => Language.ChatType_Glamour,
|
||||
ChatType.Echo => Language.ChatType_Echo,
|
||||
ChatType.System => Language.ChatType_System,
|
||||
ChatType.BattleSystem => Language.ChatType_BattleSystem,
|
||||
ChatType.GatheringSystem => Language.ChatType_GatheringSystem,
|
||||
ChatType.Error => Language.ChatType_Error,
|
||||
ChatType.NpcDialogue => Language.ChatType_NpcDialogue,
|
||||
ChatType.LootNotice => Language.ChatType_LootNotice,
|
||||
ChatType.Progress => Language.ChatType_Progress,
|
||||
ChatType.LootRoll => Language.ChatType_LootRoll,
|
||||
ChatType.Crafting => Language.ChatType_Crafting,
|
||||
ChatType.Gathering => Language.ChatType_Gathering,
|
||||
ChatType.NpcAnnouncement => Language.ChatType_NpcAnnouncement,
|
||||
ChatType.FreeCompanyAnnouncement => Language.ChatType_FreeCompanyAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout => Language.ChatType_FreeCompanyLoginLogout,
|
||||
ChatType.RetainerSale => Language.ChatType_RetainerSale,
|
||||
ChatType.PeriodicRecruitmentNotification => Language.ChatType_PeriodicRecruitmentNotification,
|
||||
ChatType.Sign => Language.ChatType_Sign,
|
||||
ChatType.RandomNumber => Language.ChatType_RandomNumber,
|
||||
ChatType.NoviceNetworkSystem => Language.ChatType_NoviceNetworkSystem,
|
||||
ChatType.Orchestrion => Language.ChatType_Orchestrion,
|
||||
ChatType.PvpTeamAnnouncement => Language.ChatType_PvpTeamAnnouncement,
|
||||
ChatType.PvpTeamLoginLogout => Language.ChatType_PvpTeamLoginLogout,
|
||||
ChatType.MessageBook => Language.ChatType_MessageBook,
|
||||
ChatType.GmTell => Language.ChatType_GmTell,
|
||||
ChatType.GmSay => Language.ChatType_GmSay,
|
||||
ChatType.GmShout => Language.ChatType_GmShout,
|
||||
ChatType.GmYell => Language.ChatType_GmYell,
|
||||
ChatType.GmParty => Language.ChatType_GmParty,
|
||||
ChatType.GmFreeCompany => Language.ChatType_GmFreeCompany,
|
||||
ChatType.GmLinkshell1 => Language.ChatType_GmLinkshell1,
|
||||
ChatType.GmLinkshell2 => Language.ChatType_GmLinkshell2,
|
||||
ChatType.GmLinkshell3 => Language.ChatType_GmLinkshell3,
|
||||
ChatType.GmLinkshell4 => Language.ChatType_GmLinkshell4,
|
||||
ChatType.GmLinkshell5 => Language.ChatType_GmLinkshell5,
|
||||
ChatType.GmLinkshell6 => Language.ChatType_GmLinkshell6,
|
||||
ChatType.GmLinkshell7 => Language.ChatType_GmLinkshell7,
|
||||
ChatType.GmLinkshell8 => Language.ChatType_GmLinkshell8,
|
||||
ChatType.GmNoviceNetwork => Language.ChatType_GmNoviceNetwork,
|
||||
ChatType.CrossLinkshell2 => Language.ChatType_CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3 => Language.ChatType_CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4 => Language.ChatType_CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5 => Language.ChatType_CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6 => Language.ChatType_CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7 => Language.ChatType_CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8 => Language.ChatType_CrossLinkshell8,
|
||||
ChatType.ExtraChatLinkshell1 => Language.ChatType_ExtraChatLinkshell1,
|
||||
ChatType.ExtraChatLinkshell2 => Language.ChatType_ExtraChatLinkshell2,
|
||||
ChatType.ExtraChatLinkshell3 => Language.ChatType_ExtraChatLinkshell3,
|
||||
ChatType.ExtraChatLinkshell4 => Language.ChatType_ExtraChatLinkshell4,
|
||||
ChatType.ExtraChatLinkshell5 => Language.ChatType_ExtraChatLinkshell5,
|
||||
ChatType.ExtraChatLinkshell6 => Language.ChatType_ExtraChatLinkshell6,
|
||||
ChatType.ExtraChatLinkshell7 => Language.ChatType_ExtraChatLinkshell7,
|
||||
ChatType.ExtraChatLinkshell8 => Language.ChatType_ExtraChatLinkshell8,
|
||||
_ => type.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
internal static uint? DefaultColor(this ChatType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ChatType.Debug:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.Urgent:
|
||||
return ColourUtil.ComponentsToRgba(255, 127, 127);
|
||||
case ChatType.Notice:
|
||||
return ColourUtil.ComponentsToRgba(179, 140, 255);
|
||||
|
||||
case ChatType.Say:
|
||||
case ChatType.GmSay:
|
||||
return ColourUtil.ComponentsToRgba(247, 247, 247);
|
||||
case ChatType.Shout:
|
||||
case ChatType.GmShout:
|
||||
return ColourUtil.ComponentsToRgba(255, 166, 102);
|
||||
case ChatType.TellIncoming:
|
||||
case ChatType.TellOutgoing:
|
||||
case ChatType.GmTell:
|
||||
return ColourUtil.ComponentsToRgba(255, 184, 222);
|
||||
case ChatType.Party:
|
||||
case ChatType.CrossParty:
|
||||
case ChatType.GmParty:
|
||||
return ColourUtil.ComponentsToRgba(102, 229, 255);
|
||||
case ChatType.Alliance:
|
||||
return ColourUtil.ComponentsToRgba(255, 127, 0);
|
||||
case ChatType.NoviceNetwork:
|
||||
case ChatType.NoviceNetworkSystem:
|
||||
case ChatType.GmNoviceNetwork:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.Linkshell1:
|
||||
case ChatType.Linkshell2:
|
||||
case ChatType.Linkshell3:
|
||||
case ChatType.Linkshell4:
|
||||
case ChatType.Linkshell5:
|
||||
case ChatType.Linkshell6:
|
||||
case ChatType.Linkshell7:
|
||||
case ChatType.Linkshell8:
|
||||
case ChatType.CrossLinkshell1:
|
||||
case ChatType.CrossLinkshell2:
|
||||
case ChatType.CrossLinkshell3:
|
||||
case ChatType.CrossLinkshell4:
|
||||
case ChatType.CrossLinkshell5:
|
||||
case ChatType.CrossLinkshell6:
|
||||
case ChatType.CrossLinkshell7:
|
||||
case ChatType.CrossLinkshell8:
|
||||
case ChatType.GmLinkshell1:
|
||||
case ChatType.GmLinkshell2:
|
||||
case ChatType.GmLinkshell3:
|
||||
case ChatType.GmLinkshell4:
|
||||
case ChatType.GmLinkshell5:
|
||||
case ChatType.GmLinkshell6:
|
||||
case ChatType.GmLinkshell7:
|
||||
case ChatType.GmLinkshell8:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.StandardEmote:
|
||||
return ColourUtil.ComponentsToRgba(186, 255, 240);
|
||||
case ChatType.CustomEmote:
|
||||
return ColourUtil.ComponentsToRgba(186, 255, 240);
|
||||
case ChatType.Yell:
|
||||
case ChatType.GmYell:
|
||||
return ColourUtil.ComponentsToRgba(255, 255, 0);
|
||||
case ChatType.Echo:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.System:
|
||||
case ChatType.GatheringSystem:
|
||||
case ChatType.PeriodicRecruitmentNotification:
|
||||
case ChatType.Orchestrion:
|
||||
case ChatType.Alarm:
|
||||
case ChatType.GlamourNotifications:
|
||||
case ChatType.RetainerSale:
|
||||
case ChatType.Sign:
|
||||
case ChatType.MessageBook:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.NpcAnnouncement:
|
||||
case ChatType.NpcDialogue:
|
||||
return ColourUtil.ComponentsToRgba(171, 214, 71);
|
||||
case ChatType.Error:
|
||||
return ColourUtil.ComponentsToRgba(255, 74, 74);
|
||||
case ChatType.FreeCompany:
|
||||
case ChatType.FreeCompanyAnnouncement:
|
||||
case ChatType.FreeCompanyLoginLogout:
|
||||
case ChatType.GmFreeCompany:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.PvpTeam:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.PvpTeamAnnouncement:
|
||||
case ChatType.PvpTeamLoginLogout:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.Action:
|
||||
case ChatType.Item:
|
||||
case ChatType.LootNotice:
|
||||
return ColourUtil.ComponentsToRgba(255, 255, 176);
|
||||
case ChatType.Progress:
|
||||
return ColourUtil.ComponentsToRgba(255, 222, 115);
|
||||
case ChatType.LootRoll:
|
||||
case ChatType.RandomNumber:
|
||||
return ColourUtil.ComponentsToRgba(199, 191, 158);
|
||||
case ChatType.Crafting:
|
||||
case ChatType.Gathering:
|
||||
return ColourUtil.ComponentsToRgba(222, 191, 247);
|
||||
case ChatType.Damage:
|
||||
return ColourUtil.ComponentsToRgba(255, 125, 125);
|
||||
case ChatType.Miss:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.Healing:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.GainBuff:
|
||||
case ChatType.LoseBuff:
|
||||
return ColourUtil.ComponentsToRgba(148, 191, 255);
|
||||
case ChatType.GainDebuff:
|
||||
case ChatType.LoseDebuff:
|
||||
return ColourUtil.ComponentsToRgba(255, 138, 196);
|
||||
case ChatType.BattleSystem:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static InputChannel? ToInputChannel(this ChatType type) => type switch
|
||||
{
|
||||
ChatType.TellOutgoing => InputChannel.Tell,
|
||||
ChatType.Say => InputChannel.Say,
|
||||
ChatType.Party => InputChannel.Party,
|
||||
ChatType.Alliance => InputChannel.Alliance,
|
||||
ChatType.Yell => InputChannel.Yell,
|
||||
ChatType.Shout => InputChannel.Shout,
|
||||
ChatType.FreeCompany => InputChannel.FreeCompany,
|
||||
ChatType.PvpTeam => InputChannel.PvpTeam,
|
||||
ChatType.NoviceNetwork => InputChannel.NoviceNetwork,
|
||||
ChatType.CrossLinkshell1 => InputChannel.CrossLinkshell1,
|
||||
ChatType.CrossLinkshell2 => InputChannel.CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3 => InputChannel.CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4 => InputChannel.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5 => InputChannel.CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6 => InputChannel.CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7 => InputChannel.CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8 => InputChannel.CrossLinkshell8,
|
||||
ChatType.Linkshell1 => InputChannel.Linkshell1,
|
||||
ChatType.Linkshell2 => InputChannel.Linkshell2,
|
||||
ChatType.Linkshell3 => InputChannel.Linkshell3,
|
||||
ChatType.Linkshell4 => InputChannel.Linkshell4,
|
||||
ChatType.Linkshell5 => InputChannel.Linkshell5,
|
||||
ChatType.Linkshell6 => InputChannel.Linkshell6,
|
||||
ChatType.Linkshell7 => InputChannel.Linkshell7,
|
||||
ChatType.Linkshell8 => InputChannel.Linkshell8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
internal static bool IsGm(this ChatType type) => type switch
|
||||
{
|
||||
ChatType.GmTell => true,
|
||||
ChatType.GmSay => true,
|
||||
ChatType.GmShout => true,
|
||||
ChatType.GmYell => true,
|
||||
ChatType.GmParty => true,
|
||||
ChatType.GmFreeCompany => true,
|
||||
ChatType.GmLinkshell1 => true,
|
||||
ChatType.GmLinkshell2 => true,
|
||||
ChatType.GmLinkshell3 => true,
|
||||
ChatType.GmLinkshell4 => true,
|
||||
ChatType.GmLinkshell5 => true,
|
||||
ChatType.GmLinkshell6 => true,
|
||||
ChatType.GmLinkshell7 => true,
|
||||
ChatType.GmLinkshell8 => true,
|
||||
ChatType.GmNoviceNetwork => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsExtraChatLinkshell(this ChatType type) => type switch
|
||||
{
|
||||
ChatType.ExtraChatLinkshell1 => true,
|
||||
ChatType.ExtraChatLinkshell2 => true,
|
||||
ChatType.ExtraChatLinkshell3 => true,
|
||||
ChatType.ExtraChatLinkshell4 => true,
|
||||
ChatType.ExtraChatLinkshell5 => true,
|
||||
ChatType.ExtraChatLinkshell6 => true,
|
||||
ChatType.ExtraChatLinkshell7 => true,
|
||||
ChatType.ExtraChatLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public static UiConfigOption ToConfigEntry(this ChatType type) => type switch
|
||||
{
|
||||
ChatType.Say => UiConfigOption.ColorSay,
|
||||
ChatType.Shout => UiConfigOption.ColorShout,
|
||||
ChatType.TellOutgoing => UiConfigOption.ColorTell,
|
||||
ChatType.Party => UiConfigOption.ColorParty,
|
||||
ChatType.Linkshell1 => UiConfigOption.ColorLS1,
|
||||
ChatType.Linkshell2 => UiConfigOption.ColorLS2,
|
||||
ChatType.Linkshell3 => UiConfigOption.ColorLS3,
|
||||
ChatType.Linkshell4 => UiConfigOption.ColorLS4,
|
||||
ChatType.Linkshell5 => UiConfigOption.ColorLS5,
|
||||
ChatType.Linkshell6 => UiConfigOption.ColorLS6,
|
||||
ChatType.Linkshell7 => UiConfigOption.ColorLS7,
|
||||
ChatType.Linkshell8 => UiConfigOption.ColorLS8,
|
||||
ChatType.FreeCompany => UiConfigOption.ColorFCompany,
|
||||
ChatType.NoviceNetwork => UiConfigOption.ColorBeginner,
|
||||
ChatType.CustomEmote => UiConfigOption.ColorEmoteUser,
|
||||
ChatType.StandardEmote => UiConfigOption.ColorEmote,
|
||||
ChatType.Yell => UiConfigOption.ColorYell,
|
||||
ChatType.GainBuff => UiConfigOption.ColorBuffGive,
|
||||
ChatType.GainDebuff => UiConfigOption.ColorDebuffGive,
|
||||
ChatType.System => UiConfigOption.ColorSysMsg,
|
||||
ChatType.NpcDialogue => UiConfigOption.ColorNpcSay,
|
||||
ChatType.LootRoll => UiConfigOption.ColorLoot,
|
||||
ChatType.FreeCompanyAnnouncement => UiConfigOption.ColorFCAnnounce,
|
||||
ChatType.PvpTeamAnnouncement => UiConfigOption.ColorPvPGroupAnnounce,
|
||||
_ => UiConfigOption.ColorSay,
|
||||
};
|
||||
|
||||
internal static bool HasSource(this ChatType type) => type switch
|
||||
{
|
||||
// Battle
|
||||
ChatType.Damage => true,
|
||||
ChatType.Miss => true,
|
||||
ChatType.Action => true,
|
||||
ChatType.Item => true,
|
||||
ChatType.Healing => true,
|
||||
ChatType.GainBuff => true,
|
||||
ChatType.LoseBuff => true,
|
||||
ChatType.GainDebuff => true,
|
||||
ChatType.LoseDebuff => true,
|
||||
|
||||
// Announcements
|
||||
ChatType.System => true,
|
||||
ChatType.BattleSystem => true,
|
||||
ChatType.Error => true,
|
||||
ChatType.LootNotice => true,
|
||||
ChatType.Progress => true,
|
||||
ChatType.LootRoll => true,
|
||||
ChatType.Crafting => true,
|
||||
ChatType.Gathering => true,
|
||||
ChatType.FreeCompanyLoginLogout => true,
|
||||
ChatType.PvpTeamLoginLogout => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static ChatType Parent(this ChatType type) => type switch
|
||||
{
|
||||
ChatType.Say => ChatType.Say,
|
||||
ChatType.GmSay => ChatType.Say,
|
||||
ChatType.Shout => ChatType.Shout,
|
||||
ChatType.GmShout => ChatType.Shout,
|
||||
ChatType.TellOutgoing => ChatType.TellOutgoing,
|
||||
ChatType.TellIncoming => ChatType.TellOutgoing,
|
||||
ChatType.GmTell => ChatType.TellOutgoing,
|
||||
ChatType.Party => ChatType.Party,
|
||||
ChatType.CrossParty => ChatType.Party,
|
||||
ChatType.GmParty => ChatType.Party,
|
||||
ChatType.Linkshell1 => ChatType.Linkshell1,
|
||||
ChatType.GmLinkshell1 => ChatType.Linkshell1,
|
||||
ChatType.Linkshell2 => ChatType.Linkshell2,
|
||||
ChatType.GmLinkshell2 => ChatType.Linkshell2,
|
||||
ChatType.Linkshell3 => ChatType.Linkshell3,
|
||||
ChatType.GmLinkshell3 => ChatType.Linkshell3,
|
||||
ChatType.Linkshell4 => ChatType.Linkshell4,
|
||||
ChatType.GmLinkshell4 => ChatType.Linkshell4,
|
||||
ChatType.Linkshell5 => ChatType.Linkshell5,
|
||||
ChatType.GmLinkshell5 => ChatType.Linkshell5,
|
||||
ChatType.Linkshell6 => ChatType.Linkshell6,
|
||||
ChatType.GmLinkshell6 => ChatType.Linkshell6,
|
||||
ChatType.Linkshell7 => ChatType.Linkshell7,
|
||||
ChatType.GmLinkshell7 => ChatType.Linkshell7,
|
||||
ChatType.Linkshell8 => ChatType.Linkshell8,
|
||||
ChatType.GmLinkshell8 => ChatType.Linkshell8,
|
||||
ChatType.FreeCompany => ChatType.FreeCompany,
|
||||
ChatType.GmFreeCompany => ChatType.FreeCompany,
|
||||
ChatType.NoviceNetwork => ChatType.NoviceNetwork,
|
||||
ChatType.GmNoviceNetwork => ChatType.NoviceNetwork,
|
||||
ChatType.CustomEmote => ChatType.CustomEmote,
|
||||
ChatType.StandardEmote => ChatType.StandardEmote,
|
||||
ChatType.Yell => ChatType.Yell,
|
||||
ChatType.GmYell => ChatType.Yell,
|
||||
ChatType.GainBuff => ChatType.GainBuff,
|
||||
ChatType.LoseBuff => ChatType.GainBuff,
|
||||
ChatType.GainDebuff => ChatType.GainDebuff,
|
||||
ChatType.LoseDebuff => ChatType.GainDebuff,
|
||||
ChatType.System => ChatType.System,
|
||||
ChatType.Alarm => ChatType.System,
|
||||
ChatType.GlamourNotifications => ChatType.System,
|
||||
ChatType.RetainerSale => ChatType.System,
|
||||
ChatType.PeriodicRecruitmentNotification => ChatType.System,
|
||||
ChatType.Sign => ChatType.System,
|
||||
ChatType.Orchestrion => ChatType.System,
|
||||
ChatType.MessageBook => ChatType.System,
|
||||
ChatType.NpcDialogue => ChatType.NpcDialogue,
|
||||
ChatType.NpcAnnouncement => ChatType.NpcDialogue,
|
||||
ChatType.LootRoll => ChatType.LootRoll,
|
||||
ChatType.RandomNumber => ChatType.LootRoll,
|
||||
ChatType.FreeCompanyAnnouncement => ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout => ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.PvpTeamAnnouncement => ChatType.PvpTeamAnnouncement,
|
||||
ChatType.PvpTeamLoginLogout => ChatType.PvpTeamAnnouncement,
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using Lumina.Excel.Sheets;
|
||||
|
||||
namespace ChatTwo.Code;
|
||||
|
||||
internal static class InputChannelExt
|
||||
{
|
||||
internal static ChatType ToChatType(this InputChannel input) => input switch
|
||||
{
|
||||
InputChannel.Tell => ChatType.TellOutgoing,
|
||||
InputChannel.Say => ChatType.Say,
|
||||
InputChannel.Party => ChatType.Party,
|
||||
InputChannel.Alliance => ChatType.Alliance,
|
||||
InputChannel.Yell => ChatType.Yell,
|
||||
InputChannel.Shout => ChatType.Shout,
|
||||
InputChannel.FreeCompany => ChatType.FreeCompany,
|
||||
InputChannel.PvpTeam => ChatType.PvpTeam,
|
||||
InputChannel.NoviceNetwork => ChatType.NoviceNetwork,
|
||||
InputChannel.CrossLinkshell1 => ChatType.CrossLinkshell1,
|
||||
InputChannel.CrossLinkshell2 => ChatType.CrossLinkshell2,
|
||||
InputChannel.CrossLinkshell3 => ChatType.CrossLinkshell3,
|
||||
InputChannel.CrossLinkshell4 => ChatType.CrossLinkshell4,
|
||||
InputChannel.CrossLinkshell5 => ChatType.CrossLinkshell5,
|
||||
InputChannel.CrossLinkshell6 => ChatType.CrossLinkshell6,
|
||||
InputChannel.CrossLinkshell7 => ChatType.CrossLinkshell7,
|
||||
InputChannel.CrossLinkshell8 => ChatType.CrossLinkshell8,
|
||||
InputChannel.Linkshell1 => ChatType.Linkshell1,
|
||||
InputChannel.Linkshell2 => ChatType.Linkshell2,
|
||||
InputChannel.Linkshell3 => ChatType.Linkshell3,
|
||||
InputChannel.Linkshell4 => ChatType.Linkshell4,
|
||||
InputChannel.Linkshell5 => ChatType.Linkshell5,
|
||||
InputChannel.Linkshell6 => ChatType.Linkshell6,
|
||||
InputChannel.Linkshell7 => ChatType.Linkshell7,
|
||||
InputChannel.Linkshell8 => ChatType.Linkshell8,
|
||||
InputChannel.ExtraChatLinkshell1 => ChatType.ExtraChatLinkshell1,
|
||||
InputChannel.ExtraChatLinkshell2 => ChatType.ExtraChatLinkshell2,
|
||||
InputChannel.ExtraChatLinkshell3 => ChatType.ExtraChatLinkshell3,
|
||||
InputChannel.ExtraChatLinkshell4 => ChatType.ExtraChatLinkshell4,
|
||||
InputChannel.ExtraChatLinkshell5 => ChatType.ExtraChatLinkshell5,
|
||||
InputChannel.ExtraChatLinkshell6 => ChatType.ExtraChatLinkshell6,
|
||||
InputChannel.ExtraChatLinkshell7 => ChatType.ExtraChatLinkshell7,
|
||||
InputChannel.ExtraChatLinkshell8 => ChatType.ExtraChatLinkshell8,
|
||||
InputChannel.Invalid => ChatType.Echo,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(input), input, null),
|
||||
};
|
||||
|
||||
public static uint LinkshellIndex(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.Linkshell1 => 0,
|
||||
InputChannel.Linkshell2 => 1,
|
||||
InputChannel.Linkshell3 => 2,
|
||||
InputChannel.Linkshell4 => 3,
|
||||
InputChannel.Linkshell5 => 4,
|
||||
InputChannel.Linkshell6 => 5,
|
||||
InputChannel.Linkshell7 => 6,
|
||||
InputChannel.Linkshell8 => 7,
|
||||
InputChannel.CrossLinkshell1 => 0,
|
||||
InputChannel.CrossLinkshell2 => 1,
|
||||
InputChannel.CrossLinkshell3 => 2,
|
||||
InputChannel.CrossLinkshell4 => 3,
|
||||
InputChannel.CrossLinkshell5 => 4,
|
||||
InputChannel.CrossLinkshell6 => 5,
|
||||
InputChannel.CrossLinkshell7 => 6,
|
||||
InputChannel.CrossLinkshell8 => 7,
|
||||
InputChannel.ExtraChatLinkshell1 => 0,
|
||||
InputChannel.ExtraChatLinkshell2 => 1,
|
||||
InputChannel.ExtraChatLinkshell3 => 2,
|
||||
InputChannel.ExtraChatLinkshell4 => 3,
|
||||
InputChannel.ExtraChatLinkshell5 => 4,
|
||||
InputChannel.ExtraChatLinkshell6 => 5,
|
||||
InputChannel.ExtraChatLinkshell7 => 6,
|
||||
InputChannel.ExtraChatLinkshell8 => 7,
|
||||
_ => uint.MaxValue,
|
||||
};
|
||||
|
||||
public static string Prefix(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.Tell => "/t",
|
||||
InputChannel.Say => "/s",
|
||||
InputChannel.Party => "/p",
|
||||
InputChannel.Alliance => "/a",
|
||||
InputChannel.Yell => "/y",
|
||||
InputChannel.Shout => "/sh",
|
||||
InputChannel.FreeCompany => "/fc",
|
||||
InputChannel.PvpTeam => "/pt",
|
||||
InputChannel.NoviceNetwork => "/b",
|
||||
InputChannel.CrossLinkshell1 => "/cwl1",
|
||||
InputChannel.CrossLinkshell2 => "/cwl2",
|
||||
InputChannel.CrossLinkshell3 => "/cwl3",
|
||||
InputChannel.CrossLinkshell4 => "/cwl4",
|
||||
InputChannel.CrossLinkshell5 => "/cwl5",
|
||||
InputChannel.CrossLinkshell6 => "/cwl6",
|
||||
InputChannel.CrossLinkshell7 => "/cwl7",
|
||||
InputChannel.CrossLinkshell8 => "/cwl8",
|
||||
InputChannel.Linkshell1 => "/l1",
|
||||
InputChannel.Linkshell2 => "/l2",
|
||||
InputChannel.Linkshell3 => "/l3",
|
||||
InputChannel.Linkshell4 => "/l4",
|
||||
InputChannel.Linkshell5 => "/l5",
|
||||
InputChannel.Linkshell6 => "/l6",
|
||||
InputChannel.Linkshell7 => "/l7",
|
||||
InputChannel.Linkshell8 => "/l8",
|
||||
InputChannel.ExtraChatLinkshell1 => "/ecl1",
|
||||
InputChannel.ExtraChatLinkshell2 => "/ecl2",
|
||||
InputChannel.ExtraChatLinkshell3 => "/ecl3",
|
||||
InputChannel.ExtraChatLinkshell4 => "/ecl4",
|
||||
InputChannel.ExtraChatLinkshell5 => "/ecl5",
|
||||
InputChannel.ExtraChatLinkshell6 => "/ecl6",
|
||||
InputChannel.ExtraChatLinkshell7 => "/ecl7",
|
||||
InputChannel.ExtraChatLinkshell8 => "/ecl8",
|
||||
_ => "/e",
|
||||
};
|
||||
|
||||
public static IEnumerable<TextCommand>? TextCommands(this InputChannel channel)
|
||||
{
|
||||
uint[] ids = channel switch
|
||||
{
|
||||
InputChannel.Tell => [104, 118],
|
||||
InputChannel.Say => [102],
|
||||
InputChannel.Party => [105],
|
||||
InputChannel.Alliance => [119],
|
||||
InputChannel.Yell => [117],
|
||||
InputChannel.Shout => [103],
|
||||
InputChannel.FreeCompany => [115],
|
||||
InputChannel.PvpTeam => [91],
|
||||
InputChannel.NoviceNetwork => [101],
|
||||
InputChannel.CrossLinkshell1 => [13],
|
||||
InputChannel.CrossLinkshell2 => [14],
|
||||
InputChannel.CrossLinkshell3 => [15],
|
||||
InputChannel.CrossLinkshell4 => [16],
|
||||
InputChannel.CrossLinkshell5 => [17],
|
||||
InputChannel.CrossLinkshell6 => [18],
|
||||
InputChannel.CrossLinkshell7 => [19],
|
||||
InputChannel.CrossLinkshell8 => [20],
|
||||
InputChannel.Linkshell1 => [107],
|
||||
InputChannel.Linkshell2 => [108],
|
||||
InputChannel.Linkshell3 => [109],
|
||||
InputChannel.Linkshell4 => [110],
|
||||
InputChannel.Linkshell5 => [111],
|
||||
InputChannel.Linkshell6 => [112],
|
||||
InputChannel.Linkshell7 => [113],
|
||||
InputChannel.Linkshell8 => [114],
|
||||
_ => [],
|
||||
};
|
||||
|
||||
if (ids.Length == 0)
|
||||
return null;
|
||||
|
||||
return ids.Where(id => Sheets.TextCommandSheet.HasRow(id)).Select(id => Sheets.TextCommandSheet.GetRow(id));
|
||||
}
|
||||
|
||||
internal static bool IsLinkshell(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.Linkshell1 => true,
|
||||
InputChannel.Linkshell2 => true,
|
||||
InputChannel.Linkshell3 => true,
|
||||
InputChannel.Linkshell4 => true,
|
||||
InputChannel.Linkshell5 => true,
|
||||
InputChannel.Linkshell6 => true,
|
||||
InputChannel.Linkshell7 => true,
|
||||
InputChannel.Linkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsCrossLinkshell(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.CrossLinkshell1 => true,
|
||||
InputChannel.CrossLinkshell2 => true,
|
||||
InputChannel.CrossLinkshell3 => true,
|
||||
InputChannel.CrossLinkshell4 => true,
|
||||
InputChannel.CrossLinkshell5 => true,
|
||||
InputChannel.CrossLinkshell6 => true,
|
||||
InputChannel.CrossLinkshell7 => true,
|
||||
InputChannel.CrossLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsExtraChatLinkshell(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.ExtraChatLinkshell1 => true,
|
||||
InputChannel.ExtraChatLinkshell2 => true,
|
||||
InputChannel.ExtraChatLinkshell3 => true,
|
||||
InputChannel.ExtraChatLinkshell4 => true,
|
||||
InputChannel.ExtraChatLinkshell5 => true,
|
||||
InputChannel.ExtraChatLinkshell6 => true,
|
||||
InputChannel.ExtraChatLinkshell7 => true,
|
||||
InputChannel.ExtraChatLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsValid(this InputChannel channel) => channel switch
|
||||
{
|
||||
InputChannel.Invalid => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,210 +0,0 @@
|
||||
using Dalamud;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.FontIdentifier;
|
||||
using Dalamud.Interface.GameFonts;
|
||||
using Dalamud.Interface.ManagedFontAtlas;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo;
|
||||
|
||||
public class FontManager
|
||||
{
|
||||
internal IFontHandle Axis = null!;
|
||||
internal IFontHandle AxisItalic = null!;
|
||||
|
||||
internal IFontHandle RegularFont = null!;
|
||||
internal IFontHandle? ItalicFont;
|
||||
|
||||
internal IFontHandle FontAwesome = null!;
|
||||
|
||||
internal readonly byte[] GameSymFont;
|
||||
|
||||
private ushort[] Ranges = [];
|
||||
private ushort[] JpRange = [];
|
||||
|
||||
public static readonly HashSet<float> AxisFontSizeList =
|
||||
[
|
||||
9.6f, 10f, 12f, 14f, 16f,
|
||||
18f, 18.4f, 20f, 23f, 34f,
|
||||
36f, 40f, 45f, 46f, 68f, 90f,
|
||||
];
|
||||
|
||||
public FontManager()
|
||||
{
|
||||
var filePath = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "FFXIV_Lodestone_SSF.ttf");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
GameSymFont = File.ReadAllBytes(filePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameSymFont = new HttpClient().GetAsync("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")
|
||||
.Result
|
||||
.Content
|
||||
.ReadAsByteArrayAsync()
|
||||
.Result;
|
||||
|
||||
Dalamud.Utility.FilesystemUtil.WriteAllBytesSafe(filePath, GameSymFont);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily
|
||||
/// extracted from the assembly's manifest resources on first use; the
|
||||
/// load happens inside the font atlas build callback so we keep the
|
||||
/// allocation off the plugin constructor's hot path.
|
||||
/// </summary>
|
||||
private static byte[]? HellionFontBytes;
|
||||
|
||||
private static byte[] GetHellionFontBytes()
|
||||
{
|
||||
if (HellionFontBytes is not null)
|
||||
return HellionFontBytes;
|
||||
|
||||
using var stream = typeof(FontManager).Assembly.GetManifestResourceStream("HellionFont.ttf")
|
||||
?? throw new FileNotFoundException("Hellion font resource not embedded in the assembly");
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
HellionFontBytes = ms.ToArray();
|
||||
return HellionFontBytes;
|
||||
}
|
||||
|
||||
private unsafe void SetUpRanges()
|
||||
{
|
||||
ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges)
|
||||
{
|
||||
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
|
||||
// text
|
||||
foreach (var range in ranges)
|
||||
builder.AddRanges((ushort*)range);
|
||||
|
||||
// chars
|
||||
if (chars != null)
|
||||
{
|
||||
for (var i = 0; i < chars.Count; i += 2)
|
||||
{
|
||||
if (chars[i] == 0)
|
||||
break;
|
||||
|
||||
for (var j = (uint) chars[i]; j <= chars[i + 1]; j++)
|
||||
builder.AddChar((ushort) j);
|
||||
}
|
||||
}
|
||||
|
||||
// Ingame supported ranges
|
||||
var reader = new FdtReader(Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data);
|
||||
foreach (var c in reader.Glyphs)
|
||||
builder.AddChar(c.Char);
|
||||
|
||||
// various symbols
|
||||
// French
|
||||
// Romanian
|
||||
// builder.AddText("←→↑↓《》■※☀★★☆♥♡ヅツッシ☀☁☂℃℉°♀♂♠♣♦♣♧®©™€$£♯♭♪✓√◎◆◇♦■□〇●△▽▼▲‹›≤≥<«“”─\~");
|
||||
builder.AddText("Œœ");
|
||||
builder.AddText("ĂăÂâÎîȘșȚț");
|
||||
|
||||
// "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460
|
||||
for (var i = 0x2460; i <= 0x24B5; i++)
|
||||
builder.AddChar((char) i);
|
||||
|
||||
builder.AddChar('⓪');
|
||||
return builder.BuildRangesToArray();
|
||||
}
|
||||
|
||||
var ranges = new List<nint> { (nint)ImGui.GetIO().Fonts.GetGlyphRangesDefault() };
|
||||
foreach (var extraRange in Enum.GetValues<ExtraGlyphRanges>())
|
||||
if (Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange))
|
||||
ranges.Add(extraRange.Range());
|
||||
|
||||
Ranges = BuildRange(null, ranges.ToArray());
|
||||
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
||||
}
|
||||
|
||||
public void BuildFonts()
|
||||
{
|
||||
SetUpRanges();
|
||||
|
||||
Axis = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)));
|
||||
AxisItalic = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2))
|
||||
{
|
||||
SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6
|
||||
});
|
||||
|
||||
FontAwesome = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
{
|
||||
e.OnPreBuild(tk => tk.AddFontAwesomeIconFont(new SafeFontConfig { SizePx = GetFontSize() }));
|
||||
e.OnPostBuild(tk => tk.FitRatio(tk.Font));
|
||||
});
|
||||
|
||||
RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(
|
||||
e => e.OnPreBuild(
|
||||
tk =>
|
||||
{
|
||||
var config = new SafeFontConfig {SizePt = Plugin.Config.GlobalFontV2.SizePt, GlyphRanges = Ranges};
|
||||
config.MergeFont = Plugin.Config.UseHellionFont
|
||||
? tk.AddFontFromMemory(GetHellionFontBytes(), config, "Hellion-Exo2")
|
||||
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global");
|
||||
|
||||
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
|
||||
config.GlyphRanges = JpRange;
|
||||
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
}
|
||||
));
|
||||
|
||||
if (Plugin.Config.ItalicEnabled)
|
||||
{
|
||||
ItalicFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(
|
||||
e => e.OnPreBuild(
|
||||
tk =>
|
||||
{
|
||||
var config = new SafeFontConfig {SizePt = Plugin.Config.ItalicFontV2.SizePt, GlyphRanges = Ranges};
|
||||
config.MergeFont = AddFontWithFallback(tk, Plugin.Config.ItalicFontV2.FontId, config, "italic");
|
||||
|
||||
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
|
||||
config.GlyphRanges = JpRange;
|
||||
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
}
|
||||
));
|
||||
}
|
||||
else
|
||||
{
|
||||
ItalicFont = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to add a user-configured font to the build toolkit, falling back to
|
||||
/// the bundled NotoSansCjkRegular asset if the configured font isn't
|
||||
/// available on the system. Without this guard a stale SystemFontId
|
||||
/// pointing at a font the user uninstalled or that never existed on
|
||||
/// Linux (e.g. "Crimson Text") tears down the entire font atlas build.
|
||||
/// </summary>
|
||||
private static ImFontPtr AddFontWithFallback(IFontAtlasBuildToolkitPreBuild tk, IFontId fontId, SafeFontConfig config, string slot)
|
||||
{
|
||||
try
|
||||
{
|
||||
return fontId.AddToBuildToolkit(tk, config);
|
||||
}
|
||||
catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException or IOException)
|
||||
{
|
||||
Plugin.Log.Warning(e, $"Configured {slot} font unavailable, falling back to NotoSansCjkRegular");
|
||||
var fallback = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular);
|
||||
return fallback.AddToBuildToolkit(tk, config);
|
||||
}
|
||||
}
|
||||
|
||||
public static float SizeInPt(float px) => (float) (px * 3.0 / 4.0);
|
||||
public static float SizeInPx(float pt) => (float) (pt * 4.0 / 3.0);
|
||||
public static float GetFontSize() => Plugin.Config.FontsEnabled ? Plugin.Config.GlobalFontV2.SizePx : SizeInPx(Plugin.Config.FontSizeV2);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
|
||||
namespace ChatTwo.GameFunctions.Types;
|
||||
|
||||
[Serializable]
|
||||
public class TellTarget
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public uint World { get; set; }
|
||||
public ulong ContentId { get; private set; }
|
||||
public TellReason Reason { get; private set; }
|
||||
|
||||
public TellTarget(string name, uint world, ulong contentId, TellReason reason)
|
||||
{
|
||||
Name = name;
|
||||
World = world;
|
||||
ContentId = contentId;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public bool IsSet()
|
||||
=> Name.Length > 0 && World > 0;
|
||||
|
||||
public string ToWorldString()
|
||||
=> Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty;
|
||||
|
||||
public string ToTargetString()
|
||||
=> $"{Name}@{ToWorldString()}";
|
||||
|
||||
public unsafe void FromTarget(IPlayerCharacter target)
|
||||
{
|
||||
Name = target.Name.TextValue;
|
||||
World = target.HomeWorld.RowId;
|
||||
ContentId = ((Character*)target.Address)->ContentId;
|
||||
}
|
||||
|
||||
public static TellTarget Empty() => new(string.Empty, 0, 0, TellReason.Direct);
|
||||
public static TellTarget From(TellTarget t) => new(t.Name, t.World, t.ContentId, t.Reason);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
name: Hellion Chat
|
||||
author: JonKazama-Hellion
|
||||
punchline: Chat 2 with privacy controls aligned to EU, US and JP rules
|
||||
description: |-
|
||||
Hellion Chat is built on top of Chat 2 with one removal and a stack
|
||||
of privacy controls on top. 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 serves a
|
||||
different use case from the smaller default footprint Hellion Chat
|
||||
is built around.
|
||||
|
||||
On top of that, Hellion Chat adds privacy and data-handling controls
|
||||
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.
|
||||
|
||||
Key additions on top of Chat 2:
|
||||
|
||||
- Channel whitelist with a Privacy-First default
|
||||
- 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.
|
||||
repo_url: https://github.com/JonKazama-Hellion/HellionChat
|
||||
accepts_feedback: true
|
||||
tags:
|
||||
- Social
|
||||
- UI
|
||||
- Chat
|
||||
- Replacement
|
||||
- Privacy
|
||||
changelog: |-
|
||||
**Hellion Chat 0.5.0 — Settings UX polish**
|
||||
|
||||
The settings window has been pulled apart and rebuilt around eight
|
||||
themed tabs instead of the twelve organic ones it grew into.
|
||||
Settings now sit where they belong and the wall-of-text descriptions
|
||||
have been replaced with hover help markers across every section.
|
||||
|
||||
What changed in this release:
|
||||
|
||||
- Twelve tabs collapsed into eight: General, Appearance, Window,
|
||||
Chat, Tabs, Privacy, Database and Information
|
||||
- Theme and font controls moved out of the Privacy tab into
|
||||
Appearance where they belong
|
||||
- Auto-Tell-Tabs settings, message preview and emote controls now
|
||||
live under one Chat tab with collapsible sections
|
||||
- About and Changelog merged into a single Information tab
|
||||
- Disabled settings keep their tooltip help marker visible so you
|
||||
can still read why an option is greyed out
|
||||
- Section headings start collapsed by default, the same pattern
|
||||
used for the Auto-Tell-Tabs preload section in 0.4.0
|
||||
|
||||
Configuration version bumps from 9 to 10 as a wipe migration. The
|
||||
old config file is copied to HellionChat.json.pre-v10-backup before
|
||||
the new defaults are written, so you can restore your previous
|
||||
setup by hand if anything looks off. A one-shot notification on
|
||||
first start explains the reset.
|
||||
|
||||
No changes to message storage, retention sweep, the privacy filter
|
||||
or the export pipeline. Tabs and chat history are untouched by the
|
||||
migration.
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
|
||||
**Hellion Chat 0.4.0 — Auto-Tell-Tabs**
|
||||
|
||||
Auto-Tell-Tabs lets you turn each /tell into a session-only tab
|
||||
dedicated to that conversation partner. The original use case is
|
||||
the FFXIV club greeter who has to track 5–15 parallel "hi, welcome"
|
||||
exchanges; everyone else can disable the feature in one click and
|
||||
go back to a single Tell Exclusive tab.
|
||||
|
||||
What lands in this release:
|
||||
|
||||
- Auto-spawn temp tab "Name@World" on /tell (incoming and outgoing)
|
||||
- Tab limit (default 15, range 1–50) with LRU drop that prefers
|
||||
greeted tabs first, then sorts by last activity
|
||||
- History preload from the local message store (default 20 tells,
|
||||
range 0–100) with a "— Earlier conversations —" separator above
|
||||
the live tell that triggered the spawn
|
||||
- Optional "mark as greeted" toggle button (off by default,
|
||||
greeter-specific) that dims the tab name and lets you flip the
|
||||
status
|
||||
- Section header "Active Tells (n)" or compact-mode separator in
|
||||
the sidebar between persistent tabs and the temp tabs
|
||||
- Settings UI under Chat (toggle / limit / compact / greeted-toggle)
|
||||
and Privacy (history preload count), with hover-tooltip help
|
||||
markers replacing the previous wall-of-text descriptions for the
|
||||
new sections
|
||||
- Save and load filters strip temp tabs from the on-disk config so
|
||||
a crash or a sidebar-mode toggle never persists or wipes them
|
||||
|
||||
Compatibility note: if XIV Messanger or another plugin is
|
||||
suppressing direct messages, disable its "Suppress DMs" option so
|
||||
Hellion Chat can receive tells and open the auto tabs.
|
||||
|
||||
Configuration version bumps from 8 to 9. Existing users get a one-
|
||||
shot notification on the first start, defaults are seeded by
|
||||
property initializers, persistent tabs are untouched.
|
||||
|
||||
The vertical sidebar tab view becomes the default for fresh
|
||||
installs; existing users keep their saved preference.
|
||||
|
||||
Inspired by the per-sender tab pattern in XIV InstantMessenger
|
||||
(Limiana, AGPL-3.0). No code was ported across the licence
|
||||
boundary; only the architectural concept influenced this design.
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
|
||||
**Hellion Chat 0.3.1 — Upstream emote regression fix**
|
||||
|
||||
Cherry-picks Infi's upstream commit ff899ff "Fix a regression
|
||||
from API 15 updates" which changes the BetterTTV emote DTOs
|
||||
(Emote and Top100) from public fields to public properties.
|
||||
System.Text.Json under the API 15 toolchain only honours the
|
||||
[JsonPropertyName] attribute on properties, so the previous
|
||||
field-based version deserialised every fetched emote into empty
|
||||
default values. Result: BetterTTV emotes were silently broken
|
||||
on fresh installs. The fix is six lines and applies cleanly on
|
||||
top of our defensive null-check from earlier; the EmoteCache
|
||||
path-traversal hardening from 0.3.0 stays as it is.
|
||||
|
||||
Authorship of the fix is preserved with git cherry-pick -x, so
|
||||
Infi shows up as the author on the commit. Thanks to him for
|
||||
catching it in the upstream codebase.
|
||||
|
||||
**Hellion Chat 0.3.0 — Audit hardening, brand sweep and rebrand of slash commands**
|
||||
|
||||
This release closes the remaining audit follow-ups from the
|
||||
0.2.0 cleanup and finishes turning Hellion Chat into a properly
|
||||
branded fork rather than a Chat 2 with a different name.
|
||||
|
||||
Slash commands have been renamed across the board so they no
|
||||
longer collide with the upstream plugin and tell you which
|
||||
plugin owns them at a glance:
|
||||
|
||||
- /chat2 becomes /hellion
|
||||
- /chat2Viewer becomes /hellionView
|
||||
- /clearlog2 becomes /clearhellion
|
||||
- /chat2Debugger becomes /hellionDebugger (internal)
|
||||
- /chat2SeString becomes /hellionSeString (internal)
|
||||
|
||||
This is a breaking change for anyone with macros bound to the
|
||||
old command names. The upstream Chat 2 commands keep working
|
||||
if you also have that plugin installed.
|
||||
|
||||
Privacy and storage hardening based on the post-0.2.0 audit:
|
||||
|
||||
- Privacy filter master switch now states explicitly that the
|
||||
filter only governs storage, not the live chat log
|
||||
- Emote cache refuses to write outside its own directory if a
|
||||
third-party API ever returns a path that escapes
|
||||
- Retention sweep is serialised so the 24h auto-sweep and the
|
||||
manual button cannot launch in parallel and race for the
|
||||
SQLite connection
|
||||
- DbViewer paging uses an int constant and the matching SQL
|
||||
parameter name (the upstream code passed a float and a name
|
||||
without the parameter prefix; both worked in practice but
|
||||
were inconsistent)
|
||||
|
||||
Visual identity now matches the Hellion Online Media website:
|
||||
|
||||
- Theme palette switched to Arctic Cyan plus Ember Orange,
|
||||
matching the website's BRANDING.md tokens
|
||||
- Active tabs and window title bars use a brand-color-dark teal
|
||||
variation as identity colour, replacing the previous slate
|
||||
violet that did not appear in the brand
|
||||
- Resize grips and scrollbar grabs picked up Ember Orange
|
||||
instead of industrial amber on hover and active states
|
||||
|
||||
About tab rewritten and properly localised:
|
||||
|
||||
- New "Why this fork exists" block sets out the mission in
|
||||
neutral terms, framing Chat 2's full-history default as the
|
||||
right one for most users while explaining the narrower
|
||||
default footprint this fork chose
|
||||
- All Hellion-specific About copy now lives in HellionStrings
|
||||
in EN and DE, so German users see the Hellion sections in
|
||||
German rather than the upstream English fallback
|
||||
- Webinterface absence is described as a focus mismatch
|
||||
(different use case, substantial rebuild) rather than as
|
||||
a security issue with the upstream code
|
||||
- Translator list at the bottom of the About tab is reachable
|
||||
again on smaller settings windows
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
|
||||
**Hellion Chat 0.2.0 — Webinterface removed**
|
||||
|
||||
The upstream webinterface has been removed in its entirety. It
|
||||
serves a different use case from the smaller default footprint
|
||||
this fork is built around, namely remote access to chat from a
|
||||
second device. Aligning it with the data minimisation defaults
|
||||
Hellion Chat ships with would have meant a substantial rebuild.
|
||||
Removing it was the cleaner path for this particular fork.
|
||||
|
||||
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**
|
||||
|
||||
Privacy
|
||||
- Channel whitelist filter in MessageStore.UpsertMessage with a
|
||||
Privacy-First default (own conversations only)
|
||||
- Per-channel retention with a 24-hour idempotent background sweep
|
||||
- Retroactive cleanup with a Ctrl+Shift confirm and VACUUM
|
||||
- Export to Markdown / JSON / CSV via Dalamud's file dialog
|
||||
|
||||
Onboarding
|
||||
- First-run wizard with three profiles: Privacy-First / Casual /
|
||||
Full History
|
||||
- Configuration migration that seeds defaults on update
|
||||
- One-shot migration from upstream Chat 2's pluginConfigs layout
|
||||
- Migrate3 idempotency recovery for half-migrated databases
|
||||
|
||||
Look & feel
|
||||
- Localized UI (English and German) with live language switching
|
||||
- Industrial HUD theme with cyan-teal action accents, slate-violet
|
||||
tabs, amber active highlights and a window-opacity slider
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
@@ -1,74 +0,0 @@
|
||||
using Dalamud.Plugin.Ipc;
|
||||
|
||||
namespace ChatTwo.Ipc;
|
||||
|
||||
public sealed class ExtraChat : IDisposable
|
||||
{
|
||||
#pragma warning disable CS0649 // Assigned through IPC
|
||||
[Serializable]
|
||||
private struct OverrideInfo
|
||||
{
|
||||
public string? Channel;
|
||||
public ushort UiColour;
|
||||
public uint Rgba;
|
||||
}
|
||||
#pragma warning restore CS0649
|
||||
|
||||
private ICallGateSubscriber<OverrideInfo, object> OverrideChannelGate { get; }
|
||||
private ICallGateSubscriber<Dictionary<string, uint>, Dictionary<string, uint>> ChannelCommandColoursGate { get; }
|
||||
private ICallGateSubscriber<Dictionary<Guid, string>, Dictionary<Guid, string>> ChannelNamesGate { get; }
|
||||
|
||||
internal (string, uint)? ChannelOverride { get; set; }
|
||||
|
||||
private Dictionary<string, uint> ChannelCommandColoursInternal { get; set; } = new();
|
||||
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal;
|
||||
|
||||
private Dictionary<Guid, string> ChannelNamesInternal { get; set; } = new();
|
||||
internal IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal;
|
||||
|
||||
internal ExtraChat()
|
||||
{
|
||||
OverrideChannelGate = Plugin.Interface.GetIpcSubscriber<OverrideInfo, object>("ExtraChat.OverrideChannelColour");
|
||||
ChannelCommandColoursGate = Plugin.Interface.GetIpcSubscriber<Dictionary<string, uint>, Dictionary<string, uint>>("ExtraChat.ChannelCommandColours");
|
||||
ChannelNamesGate = Plugin.Interface.GetIpcSubscriber<Dictionary<Guid, string>, Dictionary<Guid, string>>("ExtraChat.ChannelNames");
|
||||
|
||||
OverrideChannelGate.Subscribe(OnOverrideChannel);
|
||||
ChannelCommandColoursGate.Subscribe(OnChannelCommandColours);
|
||||
ChannelNamesGate.Subscribe(OnChannelNames);
|
||||
try
|
||||
{
|
||||
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
|
||||
ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OverrideChannelGate.Unsubscribe(OnOverrideChannel);
|
||||
}
|
||||
|
||||
private void OnOverrideChannel(OverrideInfo info)
|
||||
{
|
||||
if (info.Channel == null)
|
||||
{
|
||||
ChannelOverride = null;
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelOverride = (info.Channel, info.Rgba);
|
||||
}
|
||||
|
||||
private void OnChannelCommandColours(Dictionary<string, uint> obj)
|
||||
{
|
||||
ChannelCommandColoursInternal = obj;
|
||||
}
|
||||
|
||||
private void OnChannelNames(Dictionary<Guid, string> obj)
|
||||
{
|
||||
ChannelNamesInternal = obj;
|
||||
}
|
||||
}
|
||||
@@ -1,554 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using ChatTwo.Ipc;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Ui;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.IoC;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
|
||||
namespace ChatTwo;
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public sealed class Plugin : IDalamudPlugin
|
||||
{
|
||||
public const string PluginName = "Hellion Chat";
|
||||
|
||||
[PluginService] public static IPluginLog Log { get; private set; } = null!;
|
||||
[PluginService] public static IDalamudPluginInterface Interface { get; private set; } = null!;
|
||||
[PluginService] public static IChatGui ChatGui { get; private set; } = null!;
|
||||
[PluginService] public static IClientState ClientState { get; private set; } = null!;
|
||||
[PluginService] public static ICommandManager CommandManager { get; private set; } = null!;
|
||||
[PluginService] public static ICondition Condition { get; private set; } = null!;
|
||||
[PluginService] public static IDataManager DataManager { get; private set; } = null!;
|
||||
[PluginService] public static IFramework Framework { get; private set; } = null!;
|
||||
[PluginService] public static IGameGui GameGui { get; private set; } = null!;
|
||||
[PluginService] public static IKeyState KeyState { get; private set; } = null!;
|
||||
[PluginService] public static IObjectTable ObjectTable { get; private set; } = null!;
|
||||
[PluginService] public static IPartyList PartyList { get; private set; } = null!;
|
||||
[PluginService] public static ITargetManager TargetManager { get; private set; } = null!;
|
||||
[PluginService] public static ITextureProvider TextureProvider { get; private set; } = null!;
|
||||
[PluginService] public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;
|
||||
[PluginService] public static IGameConfig GameConfig { get; private set; } = null!;
|
||||
[PluginService] public static INotificationManager Notification { get; private set; } = null!;
|
||||
[PluginService] public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
|
||||
[PluginService] public static IPlayerState PlayerState { get; private set; } = null!;
|
||||
[PluginService] public static ISeStringEvaluator Evaluator { get; private set; } = null!;
|
||||
|
||||
public static Configuration Config = null!;
|
||||
public static FileDialogManager FileDialogManager { get; private set; } = null!;
|
||||
|
||||
public readonly WindowSystem WindowSystem = new(PluginName);
|
||||
public SettingsWindow SettingsWindow { get; }
|
||||
public ChatLogWindow ChatLogWindow { get; }
|
||||
public DbViewer DbViewer { get; }
|
||||
public InputPreview InputPreview { get; }
|
||||
public CommandHelpWindow CommandHelpWindow { get; }
|
||||
public SeStringDebugger SeStringDebugger { get; }
|
||||
public FirstRunWizard FirstRunWizard { get; }
|
||||
public DebuggerWindow DebuggerWindow { get; }
|
||||
|
||||
internal Commands Commands { get; }
|
||||
internal GameFunctions.GameFunctions Functions { get; }
|
||||
internal MessageManager MessageManager { get; }
|
||||
internal AutoTellTabsService AutoTellTabsService { get; }
|
||||
internal IpcManager Ipc { get; }
|
||||
internal ExtraChat ExtraChat { get; }
|
||||
internal TypingIpc TypingIpc { get; }
|
||||
internal FontManager FontManager { get; }
|
||||
|
||||
internal int DeferredSaveFrames = -1;
|
||||
|
||||
// Serialises retention sweeps. The 24h auto-sweep on plugin load and
|
||||
// the manual button in the Privacy tab both run on background threads;
|
||||
// without this gate, hitting the manual button moments after a fresh
|
||||
// plugin start would launch two sweeps in parallel and the second one
|
||||
// would just re-do work the first one already finished. The lock guards
|
||||
// the flag — the flag check itself bails before we touch the database.
|
||||
// Volatile because the ImGui thread reads the flag outside the lock to
|
||||
// gate the manual button; without it the JIT may cache the value in a
|
||||
// register and miss the background-thread update.
|
||||
internal readonly object RetentionSweepLock = new();
|
||||
internal volatile bool RetentionSweepRunning;
|
||||
|
||||
internal DateTime GameStarted { get; }
|
||||
|
||||
// Tab management needs to happen outside the chatlog window class for access reasons
|
||||
internal int LastTab { get; set; }
|
||||
internal int? WantedTab { get; set; }
|
||||
internal Tab CurrentTab
|
||||
{
|
||||
get
|
||||
{
|
||||
var i = LastTab;
|
||||
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
|
||||
}
|
||||
}
|
||||
|
||||
public Plugin()
|
||||
{
|
||||
try
|
||||
{
|
||||
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
||||
|
||||
// Hellion Chat: take over config + database from upstream ChatTwo
|
||||
// before Dalamud loads our plugin config. Idempotent: only acts on
|
||||
// the first start where the legacy paths exist and ours don't.
|
||||
MigrateFromChatTwoLayout();
|
||||
|
||||
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig
|
||||
// already strips temp tabs before persistence, but a previous
|
||||
// crash or external write could have left them in the JSON.
|
||||
// Drop them on load to guarantee the session-only invariant.
|
||||
Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
// Hellion Chat v9 → v10 — wipes the configuration so the new 8-tab
|
||||
// layout starts from defaults instead of mapping every previous setting
|
||||
// to its new position. Backup-Failure ist non-fatal, der Wipe läuft
|
||||
// trotzdem; dem User fehlt dann nur das manuelle Restore-Sicherheitsnetz.
|
||||
if (Config.Version < 10)
|
||||
{
|
||||
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
||||
if (pluginConfigsDir is not null)
|
||||
{
|
||||
var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json");
|
||||
var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v10-backup");
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(liveConfigPath))
|
||||
{
|
||||
File.Copy(liveConfigPath, backupPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HellionChat: pre-v10 config backup failed");
|
||||
}
|
||||
}
|
||||
|
||||
Config = new Configuration
|
||||
{
|
||||
Version = 10,
|
||||
FirstRunCompleted = true,
|
||||
};
|
||||
SaveConfig();
|
||||
|
||||
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
|
||||
{
|
||||
Title = HellionStrings.SettingsRefactor_Migration_Title,
|
||||
Content = HellionStrings.SettingsRefactor_Migration_Content,
|
||||
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info,
|
||||
InitialDuration = TimeSpan.FromSeconds(25),
|
||||
});
|
||||
}
|
||||
|
||||
// Hellion default tab layout for first-run and v10-wipe.
|
||||
// General catches player chat plus active gameplay events; the
|
||||
// System tab takes the technical noise so it does not bury real
|
||||
// conversation. Beginner tab only appears when the Novice
|
||||
// Network is enabled in Audio and Notifications, otherwise it
|
||||
// would just sit empty.
|
||||
if (Config.Tabs.Count == 0)
|
||||
{
|
||||
Config.Tabs.Add(TabsUtil.VanillaGeneral);
|
||||
Config.Tabs.Add(TabsUtil.HellionSystem);
|
||||
Config.Tabs.Add(TabsUtil.HellionFreeCompany);
|
||||
Config.Tabs.Add(TabsUtil.HellionParty);
|
||||
if (Config.ShowNoviceNetwork)
|
||||
Config.Tabs.Add(TabsUtil.HellionBeginner);
|
||||
Config.Tabs.Add(TabsUtil.HellionLinkshell);
|
||||
Config.Tabs.Add(TabsUtil.VanillaTellExclusive);
|
||||
}
|
||||
|
||||
LanguageChanged(Interface.UiLanguage);
|
||||
ImGuiUtil.Initialize(this);
|
||||
|
||||
FileDialogManager = new FileDialogManager();
|
||||
|
||||
Commands = new Commands();
|
||||
Functions = new GameFunctions.GameFunctions(this);
|
||||
Ipc = new IpcManager();
|
||||
TypingIpc = new TypingIpc(this);
|
||||
ExtraChat = new ExtraChat();
|
||||
FontManager = new FontManager();
|
||||
|
||||
MessageManager = new MessageManager(this); // Does it require UI?
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs service. Subscribes to the
|
||||
// MessageManager's MessageProcessed event for live tells and
|
||||
// to ClientState.Logout for the cleanup pass. Created after
|
||||
// MessageManager so the constructor can hand off the live
|
||||
// store and event source.
|
||||
AutoTellTabsService = new AutoTellTabsService(this, MessageManager, MessageManager.Store);
|
||||
AutoTellTabsService.Initialize();
|
||||
|
||||
// Hellion Chat — daily retention sweep, off-thread so it never
|
||||
// blocks plugin load. Skips itself when disabled or already ran
|
||||
// within the past 24 hours.
|
||||
RunRetentionSweepIfDue();
|
||||
|
||||
ChatLogWindow = new ChatLogWindow(this);
|
||||
SettingsWindow = new SettingsWindow(this);
|
||||
DbViewer = new DbViewer(this);
|
||||
InputPreview = new InputPreview(ChatLogWindow);
|
||||
CommandHelpWindow = new CommandHelpWindow(ChatLogWindow);
|
||||
SeStringDebugger = new SeStringDebugger(this);
|
||||
DebuggerWindow = new DebuggerWindow(this);
|
||||
FirstRunWizard = new FirstRunWizard(this);
|
||||
|
||||
WindowSystem.AddWindow(ChatLogWindow);
|
||||
WindowSystem.AddWindow(SettingsWindow);
|
||||
WindowSystem.AddWindow(DbViewer);
|
||||
WindowSystem.AddWindow(InputPreview);
|
||||
WindowSystem.AddWindow(CommandHelpWindow);
|
||||
WindowSystem.AddWindow(SeStringDebugger);
|
||||
WindowSystem.AddWindow(DebuggerWindow);
|
||||
WindowSystem.AddWindow(FirstRunWizard);
|
||||
|
||||
// Open the wizard on a fresh install. Existing ChatTwo users have
|
||||
// FirstRunCompleted set to true by the v6→v7 migration above.
|
||||
if (!Config.FirstRunCompleted)
|
||||
FirstRunWizard.IsOpen = true;
|
||||
|
||||
FontManager.BuildFonts();
|
||||
|
||||
Interface.UiBuilder.DisableCutsceneUiHide = true;
|
||||
Interface.UiBuilder.DisableGposeUiHide = true;
|
||||
|
||||
// let all the other components register, then initialize commands
|
||||
Commands.Initialise();
|
||||
|
||||
if (Interface.Reason is not PluginLoadReason.Boot)
|
||||
MessageManager.FilterAllTabsAsync();
|
||||
|
||||
Framework.Update += FrameworkUpdate;
|
||||
Interface.UiBuilder.Draw += Draw;
|
||||
Interface.LanguageChanged += LanguageChanged;
|
||||
// Hellion Chat — surface a "main UI" entry point so Dalamud's
|
||||
// plugin list shows the Open-Plugin button. Settings is the
|
||||
// most useful landing place; OpenConfigUi is already wired to
|
||||
// the same toggle inside SettingsWindow.
|
||||
Interface.UiBuilder.OpenMainUi += OpenMainUi;
|
||||
|
||||
if (Config.ShowEmotes)
|
||||
Task.Run(EmoteCache.LoadData);
|
||||
|
||||
#if !DEBUG
|
||||
// Avoid 300ms hitch when sending first message by preloading the
|
||||
// auto-translate cache. Don't do this in debug because it makes
|
||||
// profiling difficult.
|
||||
AutoTranslate.PreloadCache();
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Plugin load threw an error, turning off plugin");
|
||||
Dispose();
|
||||
|
||||
// Re-throw the exception to fail the plugin load.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Suppressing this warning because Dispose() is called in Plugin() if the
|
||||
// load fails, so some values may not be initialized.
|
||||
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
|
||||
public void Dispose()
|
||||
{
|
||||
Interface.UiBuilder.OpenMainUi -= OpenMainUi;
|
||||
Interface.LanguageChanged -= LanguageChanged;
|
||||
Interface.UiBuilder.Draw -= Draw;
|
||||
Framework.Update -= FrameworkUpdate;
|
||||
GameFunctions.GameFunctions.SetChatInteractable(true);
|
||||
|
||||
WindowSystem?.RemoveAllWindows();
|
||||
ChatLogWindow?.Dispose();
|
||||
DbViewer?.Dispose();
|
||||
InputPreview?.Dispose();
|
||||
SettingsWindow?.Dispose();
|
||||
DebuggerWindow?.Dispose();
|
||||
SeStringDebugger?.Dispose();
|
||||
|
||||
TypingIpc?.Dispose();
|
||||
ExtraChat?.Dispose();
|
||||
Ipc?.Dispose();
|
||||
// Dispose the Auto-Tell-Tabs service before MessageManager so it
|
||||
// can cleanly unsubscribe from the MessageProcessed event before
|
||||
// its source goes away.
|
||||
AutoTellTabsService?.Dispose();
|
||||
MessageManager?.DisposeAsync().AsTask().Wait();
|
||||
Functions?.Dispose();
|
||||
Commands?.Dispose();
|
||||
|
||||
EmoteCache.Dispose();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
|
||||
{
|
||||
File.Move(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
|
||||
// (Dalamud creates it), so check at the file level instead of
|
||||
// skipping when the directory is present. Move every legacy
|
||||
// entry whose target name is not occupied yet, then remove the
|
||||
// source dir if it ends up empty. Each move is wrapped on its
|
||||
// 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))
|
||||
{
|
||||
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
||||
if (File.Exists(target))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
File.Move(file, target);
|
||||
Log.Information($"HellionChat: migrated file {file} → {target}");
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
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
|
||||
{
|
||||
Directory.Move(dir, target);
|
||||
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Log.Warning(e, $"HellionChat: subdir move blocked for {dir}, will retry on next load");
|
||||
lockedBlocker = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
|
||||
{
|
||||
Directory.Delete(legacyConfigDir);
|
||||
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
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()
|
||||
{
|
||||
// Settings is the most useful landing surface — same target as the
|
||||
// Configure button. SettingsWindow.Toggle is internal and already
|
||||
// wired to OpenConfigUi, so re-using IsOpen keeps both entry points
|
||||
// behaviourally identical.
|
||||
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
|
||||
}
|
||||
|
||||
private void RunRetentionSweepIfDue()
|
||||
{
|
||||
if (!Config.RetentionEnabled)
|
||||
return;
|
||||
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
|
||||
return;
|
||||
|
||||
// Snapshot the policy so the user can edit settings while we run.
|
||||
// Spec defaults form the baseline; explicit user overrides win.
|
||||
var policy = new Dictionary<int, int>();
|
||||
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
|
||||
policy[(int)(ushort)type] = days;
|
||||
foreach (var (type, days) in Config.RetentionPerChannelDays)
|
||||
policy[(int)(ushort)type] = days;
|
||||
var defaultDays = Config.RetentionDefaultDays;
|
||||
|
||||
new Thread(() =>
|
||||
{
|
||||
// Bail out cheaply if a manual sweep is already in flight; the
|
||||
// lock around the actual work would queue us up otherwise and
|
||||
// we would just re-do whatever the manual run already did.
|
||||
lock (RetentionSweepLock)
|
||||
{
|
||||
if (RetentionSweepRunning)
|
||||
return;
|
||||
RetentionSweepRunning = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
||||
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
||||
SaveConfig();
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
||||
Framework.Run(() =>
|
||||
{
|
||||
MessageManager.ClearAllTabs();
|
||||
MessageManager.FilterAllTabsAsync();
|
||||
}).Wait();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Retention sweep ran, nothing expired.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error(e, "Retention sweep failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (RetentionSweepLock)
|
||||
RetentionSweepRunning = false;
|
||||
}
|
||||
}) { IsBackground = true }.Start();
|
||||
}
|
||||
|
||||
private void Draw()
|
||||
{
|
||||
// Hellion theme is pushed once per frame here so every plugin window
|
||||
// (chat log, settings, viewers, wizard, file dialog) renders with
|
||||
// the same palette. Skipping the push leaves the upstream Dalamud
|
||||
// look untouched for users who flipped the toggle off.
|
||||
using IDisposable? _style = Config.HellionThemeEnabled
|
||||
? HellionStyle.PushGlobal(Config.HellionThemeWindowOpacity)
|
||||
: null;
|
||||
|
||||
ChatLogWindow.BeginFrame();
|
||||
|
||||
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
|
||||
{
|
||||
ChatLogWindow.FinalizeFrame();
|
||||
TypingIpc.Update();
|
||||
return;
|
||||
}
|
||||
|
||||
ChatLogWindow.HideStateCheck();
|
||||
|
||||
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
|
||||
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int) ImGuiCol.Text];
|
||||
|
||||
using ((Config.FontsEnabled ? FontManager.RegularFont : FontManager.Axis).Push())
|
||||
WindowSystem.Draw();
|
||||
|
||||
ChatLogWindow.FinalizeFrame();
|
||||
TypingIpc.Update();
|
||||
|
||||
FileDialogManager.Draw();
|
||||
}
|
||||
|
||||
internal void SaveConfig()
|
||||
{
|
||||
// Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out
|
||||
// before serialization so a crash mid-session can never persist
|
||||
// them. We snapshot the full tab list first and restore it after
|
||||
// the save, preserving the user's order and open conversations.
|
||||
var snapshot = Config.Tabs.ToList();
|
||||
Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
Interface.SavePluginConfig(Config);
|
||||
|
||||
Config.Tabs.Clear();
|
||||
Config.Tabs.AddRange(snapshot);
|
||||
}
|
||||
|
||||
internal void LanguageChanged(string langCode)
|
||||
{
|
||||
var info = Config.LanguageOverride is LanguageOverride.None
|
||||
? new CultureInfo(langCode)
|
||||
: new CultureInfo(Config.LanguageOverride.Code());
|
||||
|
||||
Language.Culture = info;
|
||||
HellionStrings.Culture = info;
|
||||
}
|
||||
|
||||
private static readonly string[] ChatAddonNames =
|
||||
[
|
||||
"ChatLog",
|
||||
"ChatLogPanel_0",
|
||||
"ChatLogPanel_1",
|
||||
"ChatLogPanel_2",
|
||||
"ChatLogPanel_3"
|
||||
];
|
||||
|
||||
private void FrameworkUpdate(IFramework framework)
|
||||
{
|
||||
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
|
||||
SaveConfig();
|
||||
|
||||
if (!Config.HideChat)
|
||||
return;
|
||||
|
||||
foreach (var name in ChatAddonNames)
|
||||
if (GameFunctions.GameFunctions.IsAddonInteractable(name))
|
||||
GameFunctions.GameFunctions.SetAddonInteractable(name, false);
|
||||
}
|
||||
|
||||
public static bool InBattle => Condition[ConditionFlag.InCombat];
|
||||
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
|
||||
public static bool CutsceneActive => Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78];
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using ChatTwo.Code;
|
||||
|
||||
namespace ChatTwo.Privacy;
|
||||
|
||||
internal static class PrivacyDefaults
|
||||
{
|
||||
// Privacy-First default whitelist (DSGVO Art. 25 - Privacy by Default).
|
||||
// Only the player's own conversations are persisted out-of-the-box.
|
||||
// Public chat (Say/Shout/Yell), Novice Network, NPC dialogue, system
|
||||
// logs and battle messages are NOT persisted unless the user opts in.
|
||||
internal static readonly IReadOnlySet<ChatType> PrivacyFirstWhitelist = new HashSet<ChatType>
|
||||
{
|
||||
ChatType.TellIncoming,
|
||||
ChatType.TellOutgoing,
|
||||
ChatType.Party,
|
||||
ChatType.CrossParty,
|
||||
ChatType.Alliance,
|
||||
ChatType.FreeCompany,
|
||||
ChatType.Linkshell1,
|
||||
ChatType.Linkshell2,
|
||||
ChatType.Linkshell3,
|
||||
ChatType.Linkshell4,
|
||||
ChatType.Linkshell5,
|
||||
ChatType.Linkshell6,
|
||||
ChatType.Linkshell7,
|
||||
ChatType.Linkshell8,
|
||||
ChatType.CrossLinkshell1,
|
||||
ChatType.CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8,
|
||||
ChatType.ExtraChatLinkshell1,
|
||||
ChatType.ExtraChatLinkshell2,
|
||||
ChatType.ExtraChatLinkshell3,
|
||||
ChatType.ExtraChatLinkshell4,
|
||||
ChatType.ExtraChatLinkshell5,
|
||||
ChatType.ExtraChatLinkshell6,
|
||||
ChatType.ExtraChatLinkshell7,
|
||||
ChatType.ExtraChatLinkshell8,
|
||||
};
|
||||
|
||||
// Default retention windows per channel (in days). Channels not listed
|
||||
// here fall back to Configuration.RetentionDefaultDays. Reflects the
|
||||
// design spec: Tells 365, own-conversation channels 90, everything else
|
||||
// shorter via the global default.
|
||||
internal static readonly IReadOnlyDictionary<ChatType, int> DefaultRetentionDays = new Dictionary<ChatType, int>
|
||||
{
|
||||
[ChatType.TellIncoming] = 365,
|
||||
[ChatType.TellOutgoing] = 365,
|
||||
|
||||
[ChatType.Party] = 90,
|
||||
[ChatType.CrossParty] = 90,
|
||||
[ChatType.Alliance] = 90,
|
||||
[ChatType.PvpTeam] = 90,
|
||||
[ChatType.FreeCompany] = 90,
|
||||
|
||||
[ChatType.Linkshell1] = 90,
|
||||
[ChatType.Linkshell2] = 90,
|
||||
[ChatType.Linkshell3] = 90,
|
||||
[ChatType.Linkshell4] = 90,
|
||||
[ChatType.Linkshell5] = 90,
|
||||
[ChatType.Linkshell6] = 90,
|
||||
[ChatType.Linkshell7] = 90,
|
||||
[ChatType.Linkshell8] = 90,
|
||||
|
||||
[ChatType.CrossLinkshell1] = 90,
|
||||
[ChatType.CrossLinkshell2] = 90,
|
||||
[ChatType.CrossLinkshell3] = 90,
|
||||
[ChatType.CrossLinkshell4] = 90,
|
||||
[ChatType.CrossLinkshell5] = 90,
|
||||
[ChatType.CrossLinkshell6] = 90,
|
||||
[ChatType.CrossLinkshell7] = 90,
|
||||
[ChatType.CrossLinkshell8] = 90,
|
||||
|
||||
[ChatType.ExtraChatLinkshell1] = 90,
|
||||
[ChatType.ExtraChatLinkshell2] = 90,
|
||||
[ChatType.ExtraChatLinkshell3] = 90,
|
||||
[ChatType.ExtraChatLinkshell4] = 90,
|
||||
[ChatType.ExtraChatLinkshell5] = 90,
|
||||
[ChatType.ExtraChatLinkshell6] = 90,
|
||||
[ChatType.ExtraChatLinkshell7] = 90,
|
||||
[ChatType.ExtraChatLinkshell8] = 90,
|
||||
};
|
||||
|
||||
// Casual profile = Privacy-First plus public chat (Say/Shout/Yell, both
|
||||
// emote types, Novice Network), kept for a short 24-hour window so the
|
||||
// last RP scene or shout trade is still searchable but third-party data
|
||||
// doesn't accumulate forever.
|
||||
internal static readonly IReadOnlySet<ChatType> CasualWhitelist = new HashSet<ChatType>(PrivacyFirstWhitelist)
|
||||
{
|
||||
ChatType.Say,
|
||||
ChatType.Shout,
|
||||
ChatType.Yell,
|
||||
ChatType.CustomEmote,
|
||||
ChatType.StandardEmote,
|
||||
ChatType.NoviceNetwork,
|
||||
};
|
||||
|
||||
internal static readonly IReadOnlyDictionary<ChatType, int> CasualRetentionOverrides = new Dictionary<ChatType, int>
|
||||
{
|
||||
[ChatType.Say] = 1,
|
||||
[ChatType.Shout] = 1,
|
||||
[ChatType.Yell] = 1,
|
||||
[ChatType.CustomEmote] = 1,
|
||||
[ChatType.StandardEmote] = 1,
|
||||
[ChatType.NoviceNetwork] = 1,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ChatTwo.Tests")]
|
||||
@@ -1,549 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Privacy_Tab_Title" xml:space="preserve">
|
||||
<value>Privacy</value>
|
||||
</data>
|
||||
<data name="Privacy_FilterEnabled_Name" xml:space="preserve">
|
||||
<value>Enable privacy filter</value>
|
||||
</data>
|
||||
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
|
||||
<value>When enabled, only messages from whitelisted channels are persisted to the database. Disabling restores upstream ChatTwo behavior (everything except battle messages is stored).</value>
|
||||
</data>
|
||||
<data name="Privacy_FilterEnabled_StorageOnly_Help" xml:space="preserve">
|
||||
<value>The filter only controls what is written to the local database. The chat log itself keeps showing every message live, disallowed channels just stop being saved. Use the channel hide options in your in-game chat tabs if you want to remove channels from the visible chat.</value>
|
||||
</data>
|
||||
<data name="Privacy_Filter_Tree_Heading" xml:space="preserve">
|
||||
<value>Privacy filter and whitelist</value>
|
||||
</data>
|
||||
<data name="Privacy_Whitelist_Help" xml:space="preserve">
|
||||
<value>Pick which channels are stored in the local database. Privacy-First default: only your own conversations. Use the buttons below to apply a preset.</value>
|
||||
</data>
|
||||
<data name="Privacy_Preset_PrivacyFirst" xml:space="preserve">
|
||||
<value>Privacy-First (recommended)</value>
|
||||
</data>
|
||||
<data name="Privacy_Preset_ClearAll" xml:space="preserve">
|
||||
<value>Clear all</value>
|
||||
</data>
|
||||
<data name="Privacy_Preset_SelectAll" xml:space="preserve">
|
||||
<value>Select all</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_DirectMessages" xml:space="preserve">
|
||||
<value>Direct Messages</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_PartyAlliance" xml:space="preserve">
|
||||
<value>Party & Alliance</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_FreeCompany" xml:space="preserve">
|
||||
<value>Free Company</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_Linkshells" xml:space="preserve">
|
||||
<value>Linkshells</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_CrossLinkshells" xml:space="preserve">
|
||||
<value>Cross-World Linkshells</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_ExtraChat" xml:space="preserve">
|
||||
<value>ExtraChat (Encrypted)</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_PublicChat" xml:space="preserve">
|
||||
<value>Public Chat (third-party data)</value>
|
||||
</data>
|
||||
<data name="Privacy_Group_SystemLogs" xml:space="preserve">
|
||||
<value>System & Game Logs</value>
|
||||
</data>
|
||||
<data name="Privacy_PersistUnknown_Name" xml:space="preserve">
|
||||
<value>Persist unknown channel types</value>
|
||||
</data>
|
||||
<data name="Privacy_PersistUnknown_Description" xml:space="preserve">
|
||||
<value>Failsafe for ChatTypes added by future FFXIV patches that this plugin does not yet know about. Default OFF (Privacy-First). Turn ON if you want a complete log including future channels.</value>
|
||||
</data>
|
||||
<data name="Cleanup_Heading" xml:space="preserve">
|
||||
<value>Apply filter to existing database</value>
|
||||
</data>
|
||||
<data name="Cleanup_Help_Intro" xml:space="preserve">
|
||||
<value>The privacy filter only applies to new messages. Use the cleanup below to retroactively remove already-stored messages that don't match your saved whitelist.</value>
|
||||
</data>
|
||||
<data name="Cleanup_Help_SavedNote" xml:space="preserve">
|
||||
<value>Cleanup uses your SAVED whitelist (Plugin.Config), not unsaved edits above. Click Save first if you want to apply your current edits.</value>
|
||||
</data>
|
||||
<data name="Retention_Help_SavedNote" xml:space="preserve">
|
||||
<value>The manual sweep uses your SAVED retention policy, not the slider values above. Click Save first if you want the run to apply your current edits.</value>
|
||||
</data>
|
||||
<data name="Cleanup_RefreshPreview" xml:space="preserve">
|
||||
<value>Refresh preview</value>
|
||||
</data>
|
||||
<data name="Cleanup_NoPreview" xml:space="preserve">
|
||||
<value>No preview yet. Click Refresh to compute the impact.</value>
|
||||
</data>
|
||||
<data name="Cleanup_TotalStored" xml:space="preserve">
|
||||
<value>Total stored messages: {0:N0}</value>
|
||||
</data>
|
||||
<data name="Cleanup_WillKeep" xml:space="preserve">
|
||||
<value>Will keep: {0:N0}</value>
|
||||
</data>
|
||||
<data name="Cleanup_WillDelete" xml:space="preserve">
|
||||
<value>Will delete: {0:N0}</value>
|
||||
</data>
|
||||
<data name="Cleanup_Breakdown" xml:space="preserve">
|
||||
<value>Per-channel breakdown</value>
|
||||
</data>
|
||||
<data name="Cleanup_Marker_Keep" xml:space="preserve">
|
||||
<value>[KEEP] </value>
|
||||
</data>
|
||||
<data name="Cleanup_Marker_Delete" xml:space="preserve">
|
||||
<value>[DELETE]</value>
|
||||
</data>
|
||||
<data name="Cleanup_Apply_Label" xml:space="preserve">
|
||||
<value>Apply current filter to database</value>
|
||||
</data>
|
||||
<data name="Cleanup_Apply_Tooltip" xml:space="preserve">
|
||||
<value>Ctrl+Shift: Hard-deletes {0:N0} messages, then runs VACUUM. Cannot be undone.</value>
|
||||
</data>
|
||||
<data name="Cleanup_Running" xml:space="preserve">
|
||||
<value>Cleanup running in background…</value>
|
||||
</data>
|
||||
<data name="Cleanup_PreviewError" xml:space="preserve">
|
||||
<value>Failed to compute cleanup preview, see /xllog</value>
|
||||
</data>
|
||||
<data name="Cleanup_Success" xml:space="preserve">
|
||||
<value>Privacy cleanup complete: {0:N0} messages removed.</value>
|
||||
</data>
|
||||
<data name="Cleanup_Error" xml:space="preserve">
|
||||
<value>Privacy cleanup failed, see /xllog</value>
|
||||
</data>
|
||||
<data name="Retention_Heading" xml:space="preserve">
|
||||
<value>Message retention</value>
|
||||
</data>
|
||||
<data name="Retention_Enabled_Name" xml:space="preserve">
|
||||
<value>Auto-delete messages after a per-channel retention window</value>
|
||||
</data>
|
||||
<data name="Retention_Enabled_Description" xml:space="preserve">
|
||||
<value>When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). Off by default. The plugin never deletes history without your explicit consent.</value>
|
||||
</data>
|
||||
<data name="Retention_Default_Label" xml:space="preserve">
|
||||
<value>Default retention (days, 0 = never)</value>
|
||||
</data>
|
||||
<data name="Retention_Default_Help" xml:space="preserve">
|
||||
<value>Applies to channels without an explicit override below.</value>
|
||||
</data>
|
||||
<data name="Retention_Reset_Spec" xml:space="preserve">
|
||||
<value>Reset overrides to spec defaults</value>
|
||||
</data>
|
||||
<data name="Retention_Clear_Overrides" xml:space="preserve">
|
||||
<value>Clear all overrides</value>
|
||||
</data>
|
||||
<data name="Retention_Tree_Heading" xml:space="preserve">
|
||||
<value>Per-channel retention overrides</value>
|
||||
</data>
|
||||
<data name="Retention_Tag_Override" xml:space="preserve">
|
||||
<value>[override]</value>
|
||||
</data>
|
||||
<data name="Retention_Tag_Spec" xml:space="preserve">
|
||||
<value>[spec]</value>
|
||||
</data>
|
||||
<data name="Retention_Tag_Global" xml:space="preserve">
|
||||
<value>[global]</value>
|
||||
</data>
|
||||
<data name="Retention_Reset_Button" xml:space="preserve">
|
||||
<value>reset</value>
|
||||
</data>
|
||||
<data name="Retention_Apply_Label" xml:space="preserve">
|
||||
<value>Apply retention policy now</value>
|
||||
</data>
|
||||
<data name="Retention_Apply_Tooltip" xml:space="preserve">
|
||||
<value>Ctrl+Shift: runs the retention sweep immediately using the SAVED policy. Save your changes first.</value>
|
||||
</data>
|
||||
<data name="Retention_Running" xml:space="preserve">
|
||||
<value>Retention sweep running in background…</value>
|
||||
</data>
|
||||
<data name="Retention_LastRun_Never" xml:space="preserve">
|
||||
<value>Last run: never</value>
|
||||
</data>
|
||||
<data name="Retention_LastRun_At" xml:space="preserve">
|
||||
<value>Last run: {0:yyyy-MM-dd HH:mm}</value>
|
||||
</data>
|
||||
<data name="Retention_Success" xml:space="preserve">
|
||||
<value>Retention sweep complete: {0:N0} messages removed.</value>
|
||||
</data>
|
||||
<data name="Retention_Error" xml:space="preserve">
|
||||
<value>Retention sweep failed, see /xllog</value>
|
||||
</data>
|
||||
<data name="Wizard_Title" xml:space="preserve">
|
||||
<value>Hellion Chat — Welcome</value>
|
||||
</data>
|
||||
<data name="Wizard_Intro" xml:space="preserve">
|
||||
<value>Pick a starting profile. You can change anything later under Settings → Privacy.</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_PrivacyFirst_Heading" xml:space="preserve">
|
||||
<value>Privacy-First (recommended)</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_PrivacyFirst_Description" xml:space="preserve">
|
||||
<value>Only your own conversations are stored: Tells, Party, FC, Linkshells, Cross-World Linkshells, Alliance and ExtraChat. Public chat, NPC dialogue and system spam are dropped at the storage layer. Retention follows the spec defaults (Tells 365 days, own-conversation channels 90 days).</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_PrivacyFirst_Apply" xml:space="preserve">
|
||||
<value>Use Privacy-First</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_Casual_Heading" xml:space="preserve">
|
||||
<value>Casual</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_Casual_Description" xml:space="preserve">
|
||||
<value>Privacy-First plus a 24-hour window for public chat (Say, Shout, Yell, both emote types, Novice Network). For RP players who want to look up the last scene without keeping public chat forever.</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_Casual_Apply" xml:space="preserve">
|
||||
<value>Use Casual</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_FullHistory_Heading" xml:space="preserve">
|
||||
<value>Full History</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_FullHistory_Description" xml:space="preserve">
|
||||
<value>Disables the privacy filter entirely. Stores everything except battle logs, just like upstream Chat 2. Retention is OFF, history grows forever.</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_FullHistory_GdprWarning" xml:space="preserve">
|
||||
<value>GDPR notice: storing third-party messages (Say/Shout/Yell of strangers, NPC dialogue with player names, etc.) for an unlimited time may exceed the personal/household exemption (Art. 2(2)(c)). Use this profile only if you have a clear reason to keep the full archive.</value>
|
||||
</data>
|
||||
<data name="Wizard_Profile_FullHistory_Apply" xml:space="preserve">
|
||||
<value>Use Full History</value>
|
||||
</data>
|
||||
<data name="Wizard_Reopen_Button" xml:space="preserve">
|
||||
<value>Show wizard again</value>
|
||||
</data>
|
||||
<data name="Export_Heading" xml:space="preserve">
|
||||
<value>Export (GDPR Art. 15 — right of access)</value>
|
||||
</data>
|
||||
<data name="Export_Help" xml:space="preserve">
|
||||
<value>Export stored messages to Markdown, JSON or CSV. Use this to fulfil a request for access from someone whose messages you have, or to take your own history with you.</value>
|
||||
</data>
|
||||
<data name="Export_Range_Label" xml:space="preserve">
|
||||
<value>Last X days (0 = all time)</value>
|
||||
</data>
|
||||
<data name="Export_Sender_Label" xml:space="preserve">
|
||||
<value>Sender contains (optional, case-insensitive)</value>
|
||||
</data>
|
||||
<data name="Export_Channels_Heading" xml:space="preserve">
|
||||
<value>Limit to channels</value>
|
||||
</data>
|
||||
<data name="Export_Channels_AllOff" xml:space="preserve">
|
||||
<value>(none selected = all stored channels)</value>
|
||||
</data>
|
||||
<data name="Export_Format_Label" xml:space="preserve">
|
||||
<value>Format</value>
|
||||
</data>
|
||||
<data name="Export_Format_Markdown" xml:space="preserve">
|
||||
<value>Markdown</value>
|
||||
</data>
|
||||
<data name="Export_Format_Json" xml:space="preserve">
|
||||
<value>JSON</value>
|
||||
</data>
|
||||
<data name="Export_Format_Csv" xml:space="preserve">
|
||||
<value>CSV</value>
|
||||
</data>
|
||||
<data name="Export_Button" xml:space="preserve">
|
||||
<value>Export to file…</value>
|
||||
</data>
|
||||
<data name="Export_Dialog_Title" xml:space="preserve">
|
||||
<value>Save export</value>
|
||||
</data>
|
||||
<data name="Export_Running" xml:space="preserve">
|
||||
<value>Export running in background…</value>
|
||||
</data>
|
||||
<data name="Export_Success" xml:space="preserve">
|
||||
<value>Export complete: {0:N0} messages written to {1}</value>
|
||||
</data>
|
||||
<data name="Export_Empty" xml:space="preserve">
|
||||
<value>Export complete: no messages matched the filter.</value>
|
||||
</data>
|
||||
<data name="Export_Error" xml:space="preserve">
|
||||
<value>Export failed, see /xllog</value>
|
||||
</data>
|
||||
<data name="Theme_Enabled_Name" xml:space="preserve">
|
||||
<value>Use the Hellion theme across all plugin windows</value>
|
||||
</data>
|
||||
<data name="Theme_Enabled_Description" xml:space="preserve">
|
||||
<value>Hellion Online Media palette of Arctic Cyan plus Ember Orange, applied across the chat log, settings, viewers and the wizard. Disable to fall back to the default Dalamud look.</value>
|
||||
</data>
|
||||
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
|
||||
<value>Window opacity</value>
|
||||
</data>
|
||||
<data name="Theme_WindowOpacity_Help" xml:space="preserve">
|
||||
<value>How opaque the plugin panes are. Lower values let the game shine through; form fields and dialogs stay opaque on top so they remain readable.</value>
|
||||
</data>
|
||||
<data name="Theme_UseHellionFont_Name" xml:space="preserve">
|
||||
<value>Use the bundled Hellion font (Exo 2)</value>
|
||||
</data>
|
||||
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
|
||||
<value>Renders chat and UI in Exo 2 (SIL Open Font License 1.1) which ships with the plugin. Disable to fall back to whatever font you picked under Settings → Fonts.</value>
|
||||
</data>
|
||||
|
||||
<data name="About_Maintainer_Heading" xml:space="preserve">
|
||||
<value>Maintainer</value>
|
||||
</data>
|
||||
<data name="About_Maintainer_Body" xml:space="preserve">
|
||||
<value>I maintain Hellion Chat through Hellion Online Media. The website has the contact details for licensing, legal or business questions.</value>
|
||||
</data>
|
||||
<data name="About_Maintainer_Website_Label" xml:space="preserve">
|
||||
<value>Website:</value>
|
||||
</data>
|
||||
|
||||
<data name="About_Mission_Heading" xml:space="preserve">
|
||||
<value>Why this fork exists</value>
|
||||
</data>
|
||||
<data name="About_Mission_P1" xml:space="preserve">
|
||||
<value>Hellion Chat is not trying to replace Chat 2. Chat 2 ships a complete chat experience with full history available for filtering, search and replay. That default is the right one for most users. This fork takes a different stance: a smaller default footprint, with extra knobs for users who want to keep less third-party chat on disk.</value>
|
||||
</data>
|
||||
<data name="About_Mission_P2" xml:space="preserve">
|
||||
<value>The reason I wanted that narrower default was personal. After two years on Chat 2 my database had grown past two million messages, most of them /say, /shout and /yell from strangers in Limsa. That data is exactly what makes Chat 2's full-history view powerful and most users are happy to keep it. For my own taste I wanted a smaller default. So I built this fork.</value>
|
||||
</data>
|
||||
<data name="About_Mission_P3" xml:space="preserve">
|
||||
<value>I am not chasing a big audience and the fork is not in competition with Chat 2. The code is open under the same EUPL-1.2 licence as the upstream plugin. Infi, Anna or anyone else are welcome to read it, borrow ideas, ask questions, or ignore the project. All three are fine by me.</value>
|
||||
</data>
|
||||
|
||||
<data name="About_BuiltOn_Heading" xml:space="preserve">
|
||||
<value>Built on Chat 2</value>
|
||||
</data>
|
||||
<data name="About_BuiltOn_P1" xml:space="preserve">
|
||||
<value>Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens). The chat replacement window, the IPC integration, the rendering engine and the entire storage core come from upstream Chat 2.</value>
|
||||
</data>
|
||||
<data name="About_BuiltOn_P2" xml:space="preserve">
|
||||
<value>The webinterface is the only major piece I removed. It is built for remote access to chat from a second device, which is a different focus than the smaller default footprint this fork is built around. Aligning it with these defaults would have meant a substantial rebuild, so removing it was the cleaner path for this particular fork.</value>
|
||||
</data>
|
||||
<data name="About_BuiltOn_Upstream_Label" xml:space="preserve">
|
||||
<value>Upstream repository:</value>
|
||||
</data>
|
||||
|
||||
<data name="About_License_Heading" xml:space="preserve">
|
||||
<value>License</value>
|
||||
</data>
|
||||
<data name="About_License_P1" xml:space="preserve">
|
||||
<value>Hellion Chat and Chat 2 both ship under the European Union Public Licence v1.2 (EUPL-1.2).</value>
|
||||
</data>
|
||||
<data name="About_License_P2" xml:space="preserve">
|
||||
<value>© 2023 to 2026, the Chat 2 authors (Infi, Anna and the upstream contributors).</value>
|
||||
</data>
|
||||
<data name="About_License_P3" xml:space="preserve">
|
||||
<value>© 2026 Hellion Online Media for the additions made in this fork.</value>
|
||||
</data>
|
||||
|
||||
<data name="About_SE_Heading" xml:space="preserve">
|
||||
<value>FINAL FANTASY XIV disclaimer</value>
|
||||
</data>
|
||||
<data name="About_SE_P1" xml:space="preserve">
|
||||
<value>FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.</value>
|
||||
</data>
|
||||
<data name="About_SE_P2" xml:space="preserve">
|
||||
<value>Hellion Chat is an unofficial, fan-made plugin. It has no affiliation with Square Enix and is not endorsed, sponsored or approved by them.</value>
|
||||
</data>
|
||||
|
||||
<data name="About_Localization_Heading" xml:space="preserve">
|
||||
<value>Localization</value>
|
||||
</data>
|
||||
<data name="About_Localization_P1" xml:space="preserve">
|
||||
<value>The German translations of the Hellion-specific strings come from me. Other languages are not provided yet.</value>
|
||||
</data>
|
||||
<data name="About_Localization_P2" xml:space="preserve">
|
||||
<value>The translator list below covers the upstream Chat 2 strings on Crowdin. Those volunteers translated Chat 2, not the Hellion additions.</value>
|
||||
</data>
|
||||
<data name="About_Translators_TreeNode" xml:space="preserve">
|
||||
<value>Chat 2 community translators (upstream)</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Auto-Tell-Tabs (runtime strings) -->
|
||||
<data name="AutoTellTabs_SectionHeader" xml:space="preserve">
|
||||
<value>Active Tells</value>
|
||||
</data>
|
||||
<data name="AutoTellTabs_HistorySeparator" xml:space="preserve">
|
||||
<value>— Earlier conversations —</value>
|
||||
</data>
|
||||
<data name="AutoTellTabs_HistoryLoadError" xml:space="preserve">
|
||||
<value>History could not be loaded.</value>
|
||||
</data>
|
||||
<data name="AutoTellTabs_GreetedTooltip" xml:space="preserve">
|
||||
<value>Marked as greeted. Click to remove the marker.</value>
|
||||
</data>
|
||||
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
|
||||
<value>Mark as greeted.</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Auto-Tell-Tabs (Chat settings tab) -->
|
||||
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
|
||||
<value>Auto-Tell-Tabs</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Enable_Name" xml:space="preserve">
|
||||
<value>Open a tab automatically for each tell partner</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Enable_Description" xml:space="preserve">
|
||||
<value>When you receive or send a /tell, a temporary tab dedicated to that player is opened automatically. Tabs vanish on logout.</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Limit_Name" xml:space="preserve">
|
||||
<value>Maximum number of auto tell tabs</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
|
||||
<value>When the limit is reached, greeted tabs with the oldest activity are dropped first. The change applies on the next /tell.</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
|
||||
<value>Compact display</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_Compact_Description" xml:space="preserve">
|
||||
<value>Show only a thin separator between persistent tabs and auto tell tabs, without the section header.</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_GreetedToggle_Name" xml:space="preserve">
|
||||
<value>Show "mark as greeted" button</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_GreetedToggle_Description" xml:space="preserve">
|
||||
<value>Adds a click-to-toggle button next to each auto tell tab to mark a partner as already greeted, dimming the tab name when set. Useful for club greeters tracking many parallel conversations; off by default.</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_PreloadHint" xml:space="preserve">
|
||||
<value>The number of preloaded tells is configured in the Privacy tab.</value>
|
||||
</data>
|
||||
<data name="ChatLog_AutoTellTabs_ConflictHint" xml:space="preserve">
|
||||
<value>Heads-up: if XIV Messanger or a similar plugin is suppressing direct messages, turn its "Suppress DMs" option off so Hellion Chat can receive tells and open the auto tabs.</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Auto-Tell-Tabs (Privacy settings tab) -->
|
||||
<data name="Privacy_AutoTellTabs_Section_Title" xml:space="preserve">
|
||||
<value>Tell history in auto tabs</value>
|
||||
</data>
|
||||
<data name="Privacy_AutoTellTabs_Preload_Name" xml:space="preserve">
|
||||
<value>Number of preloaded tells</value>
|
||||
</data>
|
||||
<data name="Privacy_AutoTellTabs_Preload_Description" xml:space="preserve">
|
||||
<value>How many earlier tell messages are loaded from the database when an auto tell tab is opened. 0 disables the preload.</value>
|
||||
</data>
|
||||
<data name="Privacy_AutoTellTabs_Preload_Hint" xml:space="preserve">
|
||||
<value>Only takes effect when auto tell tabs are enabled in the Chat tab.</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Settings UX Polish v10 wipe migration -->
|
||||
<data name="SettingsRefactor_Migration_Title" xml:space="preserve">
|
||||
<value>Settings reorganised</value>
|
||||
</data>
|
||||
<data name="SettingsRefactor_Migration_Content" xml:space="preserve">
|
||||
<value>Hellion Chat 0.5.0 reorganised the settings into themed tabs. Your chat database and your message history stay untouched. Settings have been reset to defaults; if you want to pick a privacy profile again, the reopen button is in the Privacy tab. A backup of your previous config is at HellionChat.json.pre-v10-backup next to the live config file.</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Settings UX Polish 8-tab structure -->
|
||||
<data name="Settings_Tab_General" xml:space="preserve">
|
||||
<value>General</value>
|
||||
</data>
|
||||
<data name="Settings_Tab_Appearance" xml:space="preserve">
|
||||
<value>Appearance</value>
|
||||
</data>
|
||||
<data name="Settings_Tab_Window" xml:space="preserve">
|
||||
<value>Window</value>
|
||||
</data>
|
||||
<data name="Settings_Tab_Chat" xml:space="preserve">
|
||||
<value>Chat</value>
|
||||
</data>
|
||||
<data name="Settings_Tab_Information" xml:space="preserve">
|
||||
<value>Information</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — General-Tab section headings -->
|
||||
<data name="Settings_General_Input_Heading" xml:space="preserve">
|
||||
<value>Input</value>
|
||||
</data>
|
||||
<data name="Settings_General_Audio_Heading" xml:space="preserve">
|
||||
<value>Audio & Notifications</value>
|
||||
</data>
|
||||
<data name="Settings_General_Performance_Heading" xml:space="preserve">
|
||||
<value>Performance</value>
|
||||
</data>
|
||||
<data name="Settings_General_Language_Heading" xml:space="preserve">
|
||||
<value>Language & Input Helpers</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Appearance-Tab section headings -->
|
||||
<data name="Settings_Appearance_Theme_Heading" xml:space="preserve">
|
||||
<value>Theme</value>
|
||||
</data>
|
||||
<data name="Settings_Appearance_Fonts_Heading" xml:space="preserve">
|
||||
<value>Fonts</value>
|
||||
</data>
|
||||
<data name="Settings_Appearance_Colours_Heading" xml:space="preserve">
|
||||
<value>Chat Colours</value>
|
||||
</data>
|
||||
<data name="Settings_Appearance_Timestamps_Heading" xml:space="preserve">
|
||||
<value>Timestamps</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Window-Tab section headings -->
|
||||
<data name="Settings_Window_Hide_Heading" xml:space="preserve">
|
||||
<value>Hide</value>
|
||||
</data>
|
||||
<data name="Settings_Window_InactivityHide_Heading" xml:space="preserve">
|
||||
<value>Inactivity Hide</value>
|
||||
</data>
|
||||
<data name="Settings_Window_Frame_Heading" xml:space="preserve">
|
||||
<value>Window Frame</value>
|
||||
</data>
|
||||
<data name="Settings_Window_Tooltips_Heading" xml:space="preserve">
|
||||
<value>Tooltips</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Chat-Tab section headings -->
|
||||
<data name="Settings_Chat_AutoTellTabs_Heading" xml:space="preserve">
|
||||
<value>Auto-Tell-Tabs</value>
|
||||
</data>
|
||||
<data name="Settings_Chat_Behaviour_Heading" xml:space="preserve">
|
||||
<value>Message Behaviour</value>
|
||||
</data>
|
||||
<data name="Settings_Chat_Preview_Heading" xml:space="preserve">
|
||||
<value>Preview</value>
|
||||
</data>
|
||||
<data name="Settings_Chat_Emotes_Heading" xml:space="preserve">
|
||||
<value>Emotes</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Database-Tab section headings -->
|
||||
<data name="Settings_Database_Storage_Heading" xml:space="preserve">
|
||||
<value>Storage</value>
|
||||
</data>
|
||||
<data name="Settings_Database_Viewer_Heading" xml:space="preserve">
|
||||
<value>Overview</value>
|
||||
</data>
|
||||
<data name="Settings_Database_Stats_Heading" xml:space="preserve">
|
||||
<value>Maintenance</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Information-Tab section headings -->
|
||||
<data name="Settings_Information_VersionInfo_Heading" xml:space="preserve">
|
||||
<value>Version Info</value>
|
||||
</data>
|
||||
<data name="Settings_Information_About_Heading" xml:space="preserve">
|
||||
<value>About HellionChat</value>
|
||||
</data>
|
||||
<data name="Settings_Information_Changelog_Heading" xml:space="preserve">
|
||||
<value>Changelog</value>
|
||||
</data>
|
||||
|
||||
<!-- Hellion Chat — Default tab presets (channel-themed) -->
|
||||
<data name="Tabs_Presets_System" xml:space="preserve">
|
||||
<value>System</value>
|
||||
</data>
|
||||
<data name="Tabs_Presets_FreeCompany" xml:space="preserve">
|
||||
<value>Free Company</value>
|
||||
</data>
|
||||
<data name="Tabs_Presets_Party" xml:space="preserve">
|
||||
<value>Party</value>
|
||||
</data>
|
||||
<data name="Tabs_Presets_Beginner" xml:space="preserve">
|
||||
<value>Beginner</value>
|
||||
</data>
|
||||
<data name="Tabs_Presets_Linkshell" xml:space="preserve">
|
||||
<value>Linkshell</value>
|
||||
</data>
|
||||
<data name="Tabs_Presets_Linkshell_Hint" xml:space="preserve">
|
||||
<value>If you use multiple linkshells, the maintainer recommends one tab per shell for cleaner readability. Duplicate this tab and narrow the channel selection per copy.</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,230 +0,0 @@
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
|
||||
namespace ChatTwo.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// ImGui style override for Hellion Chat. Industrial HUD palette with three
|
||||
/// distinct accents — cyan-teal as the primary action color, industrial
|
||||
/// amber for active state highlights, slate-violet for title bars and
|
||||
/// active tabs — on a deep-slate frame background with steel borders.
|
||||
///
|
||||
/// Two entry points:
|
||||
/// Push — local color stack, scoped via using-block. Use inside
|
||||
/// Hellion-only surfaces (Privacy tab, first-run wizard).
|
||||
/// PushGlobal — full color + style variable stack. Pushed once per frame
|
||||
/// in Plugin.Draw so every Hellion-rendered window inherits
|
||||
/// the look. Cheap to pop because ImGui keeps its own stack.
|
||||
/// </summary>
|
||||
internal static class HellionStyle
|
||||
{
|
||||
// Encoded as 0xRRGGBBAA, matching ChatTwo convention (see Settings.cs
|
||||
// Ko-fi buttons). RgbaToAbgr handles the byte swap to the format ImGui
|
||||
// expects. Hex values are sourced from the Hellion Online Media brand
|
||||
// guide ("Arctic Cyan + Ember Glow", BRANDING.md in the website repo).
|
||||
|
||||
// Primary — Arctic Cyan, used for every interactive control (buttons,
|
||||
// checks, sliders, separators when hovered). Three brand stages plus a
|
||||
// hover that lifts to brand-color-light and a press that drops to
|
||||
// brand-color-dark.
|
||||
private const uint PrimaryRgba = 0x00BED2FF; // brand-color
|
||||
private const uint PrimaryHoverRgba = 0x4DD9E8FF; // brand-color-light
|
||||
private const uint PrimaryActiveRgba = 0x0097A7FF; // brand-color-dark
|
||||
|
||||
// Identity — brand-color-dark teal for window title bars and the
|
||||
// active tab. Sits visibly below the primary cyan on buttons so the
|
||||
// user sees "where am I" (deep teal) versus "what can I click"
|
||||
// (brand cyan) without leaving the cyan family.
|
||||
private const uint IdentityRgba = 0x0097A7FF; // brand-color-dark
|
||||
private const uint IdentityHoverRgba = 0x4DD9E8FF; // brand-color-light
|
||||
private const uint IdentityDeepRgba = 0x005670FF; // dimmer teal for unfocused-active tab
|
||||
|
||||
// Accent — Ember Orange for warm highlights on grips and scrollbar
|
||||
// pulls. Replaces the previous industrial amber so the plugin matches
|
||||
// the website's CTA palette. AccentActive is reserved for any future
|
||||
// pressed-state on accent surfaces; the current slots only need
|
||||
// AccentRgba and AccentHoverRgba.
|
||||
private const uint AccentRgba = 0xF97316FF; // accent-color
|
||||
private const uint AccentHoverRgba = 0xFB923CFF; // accent-color-light
|
||||
|
||||
// Surfaces — Hellion brand background ladder. Window darkest, frame
|
||||
// hover ladder climbs into surface tones. Matches the website's
|
||||
// background / background-medium / background-light / surface vars.
|
||||
private const uint WindowBgRgba = 0x070B12FF; // background
|
||||
private const uint ChildBgRgba = 0x0C1220FF; // background-medium
|
||||
private const uint PopupBgRgba = 0x0C1220FF; // background-medium
|
||||
private const uint FrameBgRgba = 0x141E30FF; // background-light
|
||||
private const uint FrameBgHoverRgba = 0x1A2538FF; // surface
|
||||
private const uint FrameBgActiveRgba = 0x22303FFF; // surface-hover
|
||||
// Cyan-tinted border — matches website --border-brand (cyan @ 40% α).
|
||||
private const uint BorderRgba = 0x00BED266;
|
||||
private const uint BorderShadowRgba = 0x00000000;
|
||||
|
||||
// Headers / collapsing-headers / tree nodes / selectables — same
|
||||
// surface ladder as frames so panels feel consistent.
|
||||
private const uint HeaderRgba = 0x141E30FF;
|
||||
private const uint HeaderHoverRgba = 0x1A2538FF;
|
||||
private const uint HeaderActiveRgba = 0x22303FFF;
|
||||
|
||||
// Title bars — Identity teal on active so the focused window reads
|
||||
// as "yours" without using accent or primary slots.
|
||||
private const uint TitleBgRgba = 0x070B12FF;
|
||||
private const uint TitleBgActiveRgba = IdentityRgba;
|
||||
private const uint TitleBgCollapsedRgba = 0x05080EFF;
|
||||
|
||||
// Tabs — neutral inactive, Identity-light on hover, Identity teal on
|
||||
// active. Unfocused-active uses the deeper Identity stage so an
|
||||
// unfocused window's active tab still reads but does not pull focus.
|
||||
private const uint TabRgba = 0x141E30FF;
|
||||
private const uint TabHoveredRgba = IdentityHoverRgba;
|
||||
private const uint TabActiveRgba = IdentityRgba;
|
||||
private const uint TabUnfocusedRgba = 0x0C1220FF;
|
||||
private const uint TabUnfocusedActiveRgba = IdentityDeepRgba;
|
||||
|
||||
// Scrollbar — Ember on grab so the pull stands out without competing
|
||||
// with the cyan action buttons. Idle grab is a subtle surface tone,
|
||||
// hover/active climb into accent.
|
||||
private const uint ScrollbarBgRgba = 0x070B12FF;
|
||||
private const uint ScrollbarGrabRgba = 0x22303FFF; // surface-hover
|
||||
private const uint ScrollbarGrabHoveredRgba = AccentHoverRgba;
|
||||
private const uint ScrollbarGrabActiveRgba = AccentRgba;
|
||||
|
||||
// Resize grip — same Ember treatment as the scrollbar.
|
||||
private const uint ResizeGripRgba = 0x141E30FF;
|
||||
private const uint ResizeGripHoveredRgba = AccentHoverRgba;
|
||||
private const uint ResizeGripActiveRgba = AccentRgba;
|
||||
|
||||
// Separator and check mark / slider follow the primary cyan.
|
||||
|
||||
/// <summary>
|
||||
/// Local color stack for Hellion-only surfaces. Cheap. Use inside a
|
||||
/// `using var _ = HellionStyle.Push();` block.
|
||||
/// </summary>
|
||||
internal static IDisposable Push()
|
||||
{
|
||||
var stack = new StackHandle();
|
||||
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
|
||||
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
|
||||
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
|
||||
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
|
||||
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
|
||||
stack.PushColor(ImGuiCol.Border, BorderRgba);
|
||||
stack.PushColor(ImGuiCol.Header, HeaderRgba);
|
||||
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
|
||||
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
|
||||
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global color and style-variable stack pushed once per frame in
|
||||
/// Plugin.Draw. Covers every ImGui surface the plugin renders so the
|
||||
/// Hellion look is consistent across upstream and Hellion tabs.
|
||||
/// </summary>
|
||||
/// <param name="windowOpacity">Window background alpha (0.5–1.0). Lower
|
||||
/// values let the game shine through the plugin panes.</param>
|
||||
internal static IDisposable PushGlobal(float windowOpacity = 1.0f)
|
||||
{
|
||||
var stack = new StackHandle();
|
||||
|
||||
// Mix the configured opacity into both the outer window and the
|
||||
// inner content child backgrounds — without ChildBg following the
|
||||
// slider the chat log stays opaque inside even when the user
|
||||
// wants to see the game behind it during combat. Form fields and
|
||||
// popups (FrameBg, PopupBg) still stay opaque so input is readable.
|
||||
var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF);
|
||||
var windowBgWithAlpha = (WindowBgRgba & 0xFFFFFF00u) | alphaByte;
|
||||
var childBgWithAlpha = (ChildBgRgba & 0xFFFFFF00u) | alphaByte;
|
||||
|
||||
// Layout — geometric edges, modest rounding, single-pixel borders.
|
||||
stack.PushStyleVar(ImGuiStyleVar.WindowRounding, 4f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.ChildRounding, 3f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.PopupRounding, 3f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.FrameRounding, 2f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.GrabRounding, 2f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.TabRounding, 2f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 2f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f);
|
||||
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
|
||||
|
||||
// Surfaces.
|
||||
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
|
||||
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
|
||||
stack.PushColor(ImGuiCol.PopupBg, PopupBgRgba);
|
||||
stack.PushColor(ImGuiCol.Border, BorderRgba);
|
||||
stack.PushColor(ImGuiCol.BorderShadow, BorderShadowRgba);
|
||||
|
||||
// Frames (input fields, combos, sliders).
|
||||
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
|
||||
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
|
||||
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
|
||||
|
||||
// Title bars — tertiary identity on active.
|
||||
stack.PushColor(ImGuiCol.TitleBg, TitleBgRgba);
|
||||
stack.PushColor(ImGuiCol.TitleBgActive, TitleBgActiveRgba);
|
||||
stack.PushColor(ImGuiCol.TitleBgCollapsed, TitleBgCollapsedRgba);
|
||||
|
||||
// Buttons — primary cyan.
|
||||
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
|
||||
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
|
||||
|
||||
// Headers / selectables — slate with subtle steps.
|
||||
stack.PushColor(ImGuiCol.Header, HeaderRgba);
|
||||
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
|
||||
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
|
||||
|
||||
// Tabs — tertiary identity for the active tab.
|
||||
stack.PushColor(ImGuiCol.Tab, TabRgba);
|
||||
stack.PushColor(ImGuiCol.TabHovered, TabHoveredRgba);
|
||||
stack.PushColor(ImGuiCol.TabActive, TabActiveRgba);
|
||||
stack.PushColor(ImGuiCol.TabUnfocused, TabUnfocusedRgba);
|
||||
stack.PushColor(ImGuiCol.TabUnfocusedActive, TabUnfocusedActiveRgba);
|
||||
|
||||
// Scrollbar.
|
||||
stack.PushColor(ImGuiCol.ScrollbarBg, ScrollbarBgRgba);
|
||||
stack.PushColor(ImGuiCol.ScrollbarGrab, ScrollbarGrabRgba);
|
||||
stack.PushColor(ImGuiCol.ScrollbarGrabHovered, ScrollbarGrabHoveredRgba);
|
||||
stack.PushColor(ImGuiCol.ScrollbarGrabActive, ScrollbarGrabActiveRgba);
|
||||
|
||||
// Resize grip — secondary amber on active.
|
||||
stack.PushColor(ImGuiCol.ResizeGrip, ResizeGripRgba);
|
||||
stack.PushColor(ImGuiCol.ResizeGripHovered, ResizeGripHoveredRgba);
|
||||
stack.PushColor(ImGuiCol.ResizeGripActive, ResizeGripActiveRgba);
|
||||
|
||||
// Check mark + slider grab — primary cyan.
|
||||
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
|
||||
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
|
||||
|
||||
// Separator — primary cyan when hovered/active so the eye
|
||||
// immediately sees that splitters are interactive.
|
||||
stack.PushColor(ImGuiCol.Separator, BorderRgba);
|
||||
stack.PushColor(ImGuiCol.SeparatorHovered, PrimaryHoverRgba);
|
||||
stack.PushColor(ImGuiCol.SeparatorActive, PrimaryRgba);
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
private sealed class StackHandle : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _items = new(64);
|
||||
|
||||
internal void PushColor(ImGuiCol slot, uint rgba)
|
||||
=> _items.Add(ImRaii.PushColor(slot, ColourUtil.RgbaToAbgr(rgba)));
|
||||
|
||||
internal void PushStyleVar(ImGuiStyleVar var, float value)
|
||||
=> _items.Add(ImRaii.PushStyle(var, value));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = _items.Count - 1; i >= 0; i--)
|
||||
_items[i].Dispose();
|
||||
_items.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using System.Numerics;
|
||||
using Dalamud.Interface.Style;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo.Ui;
|
||||
|
||||
internal class Popout : Window
|
||||
{
|
||||
private readonly ChatLogWindow ChatLogWindow;
|
||||
private readonly Tab Tab;
|
||||
private readonly int Idx;
|
||||
|
||||
private long FrameTime; // set every frame
|
||||
private long LastActivityTime = Environment.TickCount64;
|
||||
|
||||
public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx) : base($"{tab.Name}##popout")
|
||||
{
|
||||
ChatLogWindow = chatLogWindow;
|
||||
Tab = tab;
|
||||
Idx = idx;
|
||||
|
||||
Size = new Vector2(350, 350);
|
||||
SizeCondition = ImGuiCond.FirstUseEver;
|
||||
|
||||
IsOpen = true;
|
||||
RespectCloseHotkey = false;
|
||||
DisableWindowSounds = true;
|
||||
}
|
||||
|
||||
public override void PreOpenCheck()
|
||||
{
|
||||
if (!Tab.PopOut)
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
public override bool DrawConditions()
|
||||
{
|
||||
FrameTime = Environment.TickCount64;
|
||||
if (Tab.IndependentHide ? HideStateCheck() : ChatLogWindow.IsHidden)
|
||||
return false;
|
||||
|
||||
if (!Plugin.Config.HideWhenInactive || (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle) || !Tab.UnhideOnActivity)
|
||||
{
|
||||
LastActivityTime = FrameTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Activity in the tab, this popout window, or the main chat log window.
|
||||
var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime);
|
||||
lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime);
|
||||
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
|
||||
}
|
||||
|
||||
public override void PreDraw()
|
||||
{
|
||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
|
||||
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
|
||||
|
||||
Flags = ImGuiWindowFlags.None;
|
||||
if (!Plugin.Config.ShowPopOutTitleBar)
|
||||
Flags |= ImGuiWindowFlags.NoTitleBar;
|
||||
|
||||
if (!Tab.CanMove)
|
||||
Flags |= ImGuiWindowFlags.NoMove;
|
||||
|
||||
if (!Tab.CanResize)
|
||||
Flags |= ImGuiWindowFlags.NoResize;
|
||||
|
||||
if (!ChatLogWindow.PopOutDocked[Idx])
|
||||
{
|
||||
if (Tab.IndependentOpacity)
|
||||
{
|
||||
BgAlpha = Tab.Opacity / 100f;
|
||||
}
|
||||
else
|
||||
{
|
||||
BgAlpha = Plugin.Config.HellionThemeEnabled
|
||||
? Plugin.Config.HellionThemeWindowOpacity
|
||||
: Plugin.Config.WindowAlpha / 100f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
using var id = ImRaii.PushId($"popout-{Tab.Identifier}");
|
||||
|
||||
if (!Plugin.Config.ShowPopOutTitleBar)
|
||||
{
|
||||
ImGui.TextUnformatted(Tab.Name);
|
||||
ImGui.Separator();
|
||||
}
|
||||
|
||||
var handler = ChatLogWindow.HandlerLender.Borrow();
|
||||
ChatLogWindow.DrawMessageLog(Tab, handler, ImGui.GetContentRegionAvail().Y, false);
|
||||
|
||||
if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows))
|
||||
LastActivityTime = FrameTime;
|
||||
}
|
||||
|
||||
public override void PostDraw()
|
||||
{
|
||||
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
|
||||
|
||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
|
||||
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
|
||||
}
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
ChatLogWindow.PopOutWindows.Remove(Tab.Identifier);
|
||||
ChatLogWindow.Plugin.WindowSystem.RemoveWindow(this);
|
||||
|
||||
Tab.PopOut = false;
|
||||
ChatLogWindow.Plugin.SaveConfig();
|
||||
}
|
||||
|
||||
private enum HideState
|
||||
{
|
||||
None,
|
||||
Cutscene,
|
||||
CutsceneOverride,
|
||||
User,
|
||||
Battle
|
||||
}
|
||||
|
||||
private HideState CurrentHideState = HideState.None;
|
||||
|
||||
private bool HideStateCheck()
|
||||
{
|
||||
// if the chat has no hide state set, and the player has entered battle, we hide chat if they have configured it
|
||||
if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
|
||||
CurrentHideState = HideState.Battle;
|
||||
|
||||
// If the chat is hidden because of battle, we reset it here
|
||||
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
|
||||
CurrentHideState = HideState.None;
|
||||
|
||||
// if the chat has no hide state and in a cutscene, set the hide state to cutscene
|
||||
if (Tab.HideDuringCutscenes && CurrentHideState == HideState.None && (Plugin.CutsceneActive || Plugin.GposeActive))
|
||||
{
|
||||
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
|
||||
CurrentHideState = HideState.Cutscene;
|
||||
}
|
||||
|
||||
// if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none
|
||||
if (CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride && !Plugin.CutsceneActive && !Plugin.GposeActive)
|
||||
CurrentHideState = HideState.None;
|
||||
|
||||
// if the chat is hidden because of a cutscene and the chat has been activated, show chat
|
||||
if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
|
||||
CurrentHideState = HideState.CutsceneOverride;
|
||||
|
||||
// if the user hid the chat and is now activating chat, reset the hide state
|
||||
if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
|
||||
CurrentHideState = HideState.None;
|
||||
|
||||
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle || (Tab.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn);
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Utility;
|
||||
using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload;
|
||||
|
||||
namespace ChatTwo.Ui;
|
||||
|
||||
public class SeStringDebugger : Window
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
|
||||
public SeStringDebugger(Plugin plugin) : base("SeString Debugger###chat2-sestringdebugger")
|
||||
{
|
||||
Plugin = plugin;
|
||||
|
||||
SizeConstraints = new WindowSizeConstraints
|
||||
{
|
||||
MinimumSize = new Vector2(475, 600),
|
||||
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
|
||||
};
|
||||
|
||||
RespectCloseHotkey = false;
|
||||
DisableWindowSounds = true;
|
||||
|
||||
#if DEBUG
|
||||
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute += Toggle;
|
||||
#endif
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
#if DEBUG
|
||||
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute -= Toggle;
|
||||
#endif
|
||||
}
|
||||
|
||||
private void Toggle(string _, string __) => Toggle();
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
if (Plugin.MessageManager.LastMessage.Sender == null)
|
||||
{
|
||||
ImGui.TextUnformatted("Nothing to show");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Make SeString freely selectable through chat
|
||||
ImGui.TextUnformatted("Sender Content");
|
||||
ImGui.Spacing();
|
||||
if (Plugin.MessageManager.LastMessage.Sender != null)
|
||||
ProcessPayloads(Plugin.MessageManager.LastMessage.Sender.Payloads);
|
||||
else
|
||||
ImGui.TextUnformatted("Nothing to show");
|
||||
|
||||
ImGui.TextUnformatted("Message Content");
|
||||
ImGui.Spacing();
|
||||
if (Plugin.MessageManager.LastMessage.Message != null)
|
||||
ProcessPayloads(Plugin.MessageManager.LastMessage.Message.Payloads);
|
||||
else
|
||||
ImGui.TextUnformatted("Nothing to show");
|
||||
}
|
||||
|
||||
private void ProcessPayloads(List<Payload> payloads)
|
||||
{
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
switch (payload)
|
||||
{
|
||||
case UIForegroundPayload color:
|
||||
{
|
||||
RenderMetadataDictionary("Link ForegroundColor", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Enabled?", color.IsEnabled.ToString() },
|
||||
{ "ColorKey", color.IsEnabled ? color.ColorKey.ToString() : "Color Ended" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MapLinkPayload map:
|
||||
{
|
||||
RenderMetadataDictionary("Link MapLinkPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Map.RowId", map.Map.RowId.ToString() },
|
||||
{ "Map.PlaceName", map.Map.Value.PlaceName.Value.Name.ToString() },
|
||||
{ "Map.PlaceNameRegion", map.Map.Value.PlaceNameRegion.Value.Name.ToString() },
|
||||
{ "Map.PlaceNameSub", map.Map.Value.PlaceNameSub.Value.Name.ToString() },
|
||||
{ "TerritoryType.RowId", map.TerritoryType.RowId.ToString() },
|
||||
{ "RawX", map.RawX.ToString() },
|
||||
{ "RawY", map.RawY.ToString() },
|
||||
{ "XCoord", map.XCoord.ToString(CultureInfo.InvariantCulture) },
|
||||
{ "YCoord", map.YCoord.ToString(CultureInfo.InvariantCulture) },
|
||||
{ "CoordinateString", map.CoordinateString },
|
||||
{ "DataString", map.DataString },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case QuestPayload quest:
|
||||
{
|
||||
RenderMetadataDictionary("Link QuestPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Quest.RowId", quest.Quest.RowId.ToString() },
|
||||
{ "Quest.Name", quest.Quest.Value.Name.ToString() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case DalamudLinkPayload link:
|
||||
{
|
||||
RenderMetadataDictionary("Link DalamudLinkPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "CommandId", link.CommandId.ToString() },
|
||||
{ "Plugin", link.Plugin },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case DalamudPartyFinderPayload pf:
|
||||
{
|
||||
RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "ListingId", pf.ListingId.ToString() },
|
||||
{ "LinkType", EnumName(pf.LinkType) },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PlayerPayload player:
|
||||
{
|
||||
RenderMetadataDictionary("Link PlayerPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Displayed", player.DisplayedName },
|
||||
{ "Player Name", player.PlayerName },
|
||||
{ "World Name", player.World.Value.Name.ExtractText() },
|
||||
{ "Data", string.Join(" ", player.Encode().Select(b => b.ToString("X2"))) },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case ItemPayload item:
|
||||
{
|
||||
RenderMetadataDictionary("Link ItemPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "ItemId", item.ItemId.ToString() },
|
||||
{ "RawItemId", item.RawItemId.ToString() },
|
||||
{ "Kind", EnumName(item.Kind) },
|
||||
{ "IsHQ", item.IsHQ.ToString() },
|
||||
{ "Item.Name", item.Kind == ItemKind.EventItem ? Sheets.EventItemSheet.GetRow(item.ItemId).Name.ExtractText() : Sheets.ItemSheet.GetRow(item.ItemId).Name.ExtractText() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AutoTranslatePayload at:
|
||||
{
|
||||
RenderMetadataDictionary("Link AutoTranslatePayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Text", at.Text },
|
||||
{ "Key/Group", $"{at.Key}/{at.Group}" },
|
||||
{ "Data", string.Join(" ", at.Encode().Select(b => b.ToString("X2"))) },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case IconPayload icon:
|
||||
{
|
||||
var found = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out _);
|
||||
RenderMetadataDictionary("Link IconPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Found", found.ToString() },
|
||||
{ "Icon ID", ((uint) icon.Icon).ToString() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case RawPayload raw:
|
||||
{
|
||||
var colorPayload = ColorPayload.From(raw.Data);
|
||||
if (colorPayload != null)
|
||||
{
|
||||
RenderMetadataDictionary("Link ColorPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Unshifted", colorPayload.UnshiftedColor.ToString("X8") },
|
||||
{ "Color", colorPayload.Color.ToString("X8") },
|
||||
{ "Enabled?", colorPayload.Enabled.ToString() },
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderMetadataDictionary("Link RawPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Data", string.Join(" ", raw.Data.Select(b => b.ToString("X2"))) },
|
||||
{ "Type", EnumName(raw.Type) },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StatusPayload status:
|
||||
{
|
||||
RenderMetadataDictionary("Link StatusPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Status.RowId", status.Status.RowId.ToString() },
|
||||
{ "Status.Name", status.Status.Value.Name.ExtractText() },
|
||||
{ "Status.Icon", status.Status.Value.Icon.ToString() }
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case Util.PartyFinderPayload pf:
|
||||
{
|
||||
RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Id", pf.Id.ToString() }
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AchievementPayload achievement:
|
||||
{
|
||||
RenderMetadataDictionary("Link AchievementPayload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Id", achievement.Id.ToString() }
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
var payloadData = payload.Encode();
|
||||
|
||||
var initialByte = payloadData.First();
|
||||
if (initialByte != 0x02)
|
||||
{
|
||||
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Content", Encoding.UTF8.GetString(payloadData) },
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var unknown = new RawPayload(payloadData);
|
||||
RenderMetadataDictionary("Link Unknown", new Dictionary<string, string?>
|
||||
{
|
||||
{ "Unknown", string.Join(" ", unknown.Data.Select(b => b.ToString("X2"))) },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? EnumName<T>(T? value) where T : Enum
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var rawValue = Convert.ChangeType(value, value.GetTypeCode());
|
||||
return (Enum.GetName(value.GetType(), value) ?? "Unknown") + $" ({rawValue})";
|
||||
}
|
||||
|
||||
private static void RenderMetadataDictionary(string name, Dictionary<string, string?> metadata)
|
||||
{
|
||||
var style = ImGui.GetStyle();
|
||||
|
||||
ImGui.Text($"{name}:");
|
||||
using var indent = ImRaii.PushIndent(style.IndentSpacing);
|
||||
using (var table = ImRaii.Table($"##chat2-{name}", 2))
|
||||
{
|
||||
if (!table.Success)
|
||||
return;
|
||||
|
||||
ImGui.TableSetupColumn($"##chat2-{name}-key", ImGuiTableColumnFlags.WidthStretch, 0.4f);
|
||||
ImGui.TableSetupColumn($"##chat2-{name}-value");
|
||||
for (var i = 0; i < metadata.Count; i++)
|
||||
{
|
||||
using var id = ImRaii.PushId(i);
|
||||
|
||||
var (key, value) = metadata.ElementAt(i);
|
||||
ImGui.TableNextColumn();
|
||||
ImGui.Text(key);
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
ImGuiTextVisibleWhitespace(value);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.NewLine();
|
||||
}
|
||||
|
||||
// ImGuiTextVisibleWhitespace replaces leading and trailing whitespace with
|
||||
// visible characters. The extra characters are rendered with a muted font.
|
||||
private static void ImGuiTextVisibleWhitespace(string? original)
|
||||
{
|
||||
if (string.IsNullOrEmpty(original))
|
||||
{
|
||||
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
|
||||
ImGui.TextUnformatted(original == null ? "(null)" : "(empty)");
|
||||
return;
|
||||
}
|
||||
|
||||
var text = original;
|
||||
var start = 0;
|
||||
var end = text.Length;
|
||||
|
||||
using var pushedStyle = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0));
|
||||
|
||||
while (start < end && char.IsWhiteSpace(text[start]))
|
||||
start++;
|
||||
|
||||
if (start > 0)
|
||||
{
|
||||
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
|
||||
ImGui.TextWrapped(new string('_', start));
|
||||
|
||||
ImGui.SameLine();
|
||||
}
|
||||
|
||||
while (end > start && char.IsWhiteSpace(text[end - 1]))
|
||||
end--;
|
||||
|
||||
ImGui.TextWrapped(text[start..end]);
|
||||
|
||||
if (end < text.Length)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
|
||||
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
|
||||
ImGui.TextWrapped(new string('_', text.Length - end));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
using System.Numerics;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Ui.SettingsTabs;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Utility;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo.Ui;
|
||||
|
||||
public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
|
||||
private Configuration Mutable { get; }
|
||||
private List<ISettingsTab> Tabs { get; }
|
||||
private int CurrentTab;
|
||||
|
||||
internal SettingsWindow(Plugin plugin) : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
|
||||
{
|
||||
Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
|
||||
|
||||
SizeCondition = ImGuiCond.FirstUseEver;
|
||||
SizeConstraints = new WindowSizeConstraints
|
||||
{
|
||||
MinimumSize = new Vector2(475, 600),
|
||||
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
|
||||
};
|
||||
|
||||
Plugin = plugin;
|
||||
Mutable = new Configuration();
|
||||
|
||||
Tabs =
|
||||
[
|
||||
new General(Plugin, Mutable),
|
||||
new Appearance(Plugin, Mutable),
|
||||
new SettingsTabs.Window(Plugin, Mutable),
|
||||
new Chat(Plugin, Mutable),
|
||||
new SettingsTabs.Tabs(Plugin, Mutable),
|
||||
new SettingsTabs.Privacy(Plugin, Mutable),
|
||||
new Database(Plugin, Mutable),
|
||||
new Information(Mutable),
|
||||
];
|
||||
|
||||
RespectCloseHotkey = false;
|
||||
DisableWindowSounds = true;
|
||||
|
||||
Initialise();
|
||||
|
||||
Plugin.Commands.Register("/hellion", "Perform various actions with Hellion Chat.").Execute += Command;
|
||||
Plugin.Interface.UiBuilder.OpenConfigUi += Toggle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Plugin.Interface.UiBuilder.OpenConfigUi -= Toggle;
|
||||
Plugin.Commands.Register("/hellion").Execute -= Command;
|
||||
}
|
||||
|
||||
private void Command(string command, string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
Toggle();
|
||||
}
|
||||
|
||||
private void Initialise()
|
||||
{
|
||||
Mutable.UpdateFrom(Plugin.Config, false);
|
||||
}
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
if (ImGui.IsWindowAppearing())
|
||||
Initialise();
|
||||
|
||||
using (var table = ImRaii.Table("##chat2-settings-table", 2))
|
||||
{
|
||||
if (table.Success)
|
||||
{
|
||||
ImGui.TableSetupColumn("tab", ImGuiTableColumnFlags.WidthFixed);
|
||||
ImGui.TableSetupColumn("settings", ImGuiTableColumnFlags.WidthStretch);
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
var changed = false;
|
||||
for (var i = 0; i < Tabs.Count; i++)
|
||||
{
|
||||
if (!ImGui.Selectable($"{Tabs[i].Name}###tab-{i}", CurrentTab == i))
|
||||
continue;
|
||||
|
||||
CurrentTab = i;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
||||
var style = ImGui.GetStyle();
|
||||
var height = ImGui.GetContentRegionAvail().Y - style.FramePadding.Y * 2 - style.ItemSpacing.Y - style.ItemInnerSpacing.Y * 2 - ImGui.CalcTextSize("A").Y;
|
||||
|
||||
using var child = ImRaii.Child("##chat2-settings", new Vector2(-1, height));
|
||||
if (child.Success)
|
||||
Tabs[CurrentTab].Draw(changed);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
var save = ImGui.Button(Language.Settings_Save);
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGui.Button(Language.Settings_SaveAndClose))
|
||||
{
|
||||
save = true;
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGui.Button(Language.Settings_Discard))
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
const string buttonLabel = "Anna's Ko-fi";
|
||||
const string buttonLabel2 = "Infi's Ko-fi";
|
||||
|
||||
using (ImRaii.PushColor(ImGuiCol.Button, ColourUtil.RgbaToAbgr(0xFF5E5BFF)))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.RgbaToAbgr(0xFF7775FF)))
|
||||
using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.RgbaToAbgr(0xFF4542FF)))
|
||||
using (ImRaii.PushColor(ImGuiCol.Text, 0xFFFFFFFF))
|
||||
{
|
||||
var buttonWidth = ImGui.CalcTextSize(buttonLabel).X + ImGui.GetStyle().FramePadding.X * 2;
|
||||
var buttonWidth2 = ImGui.CalcTextSize(buttonLabel2).X + ImGui.GetStyle().FramePadding.X * 2;
|
||||
ImGui.SameLine(ImGui.GetContentRegionAvail().X - buttonWidth - buttonWidth2 - ImGui.GetStyle().ItemSpacing.X);
|
||||
|
||||
if (ImGui.Button(buttonLabel2))
|
||||
Dalamud.Utility.Util.OpenLink("https://ko-fi.com/infiii");
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGui.Button(buttonLabel))
|
||||
Dalamud.Utility.Util.OpenLink("https://ko-fi.com/lojewalo");
|
||||
}
|
||||
|
||||
if (!save)
|
||||
return;
|
||||
|
||||
// calculate all conditions before updating config
|
||||
var hideChanged = !Mutable.HideChat && Mutable.HideChat != Plugin.Config.HideChat;
|
||||
var languageChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride;
|
||||
var fontChanged = Mutable.GlobalFontV2 != Plugin.Config.GlobalFontV2
|
||||
|| Mutable.JapaneseFontV2 != Plugin.Config.JapaneseFontV2
|
||||
|| Mutable.ItalicFontV2 != Plugin.Config.ItalicFontV2
|
||||
|| Mutable.ExtraGlyphRanges != Plugin.Config.ExtraGlyphRanges
|
||||
|| Mutable.UseHellionFont != Plugin.Config.UseHellionFont;
|
||||
var fontSizeChanged = Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001
|
||||
|| Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001;
|
||||
var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled;
|
||||
|
||||
Plugin.Config.UpdateFrom(Mutable, true);
|
||||
|
||||
// save after 60 frames have passed, which should hopefully not
|
||||
// commit any changes that cause a crash
|
||||
Plugin.DeferredSaveFrames = 60;
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
Plugin.MessageManager.FilterAllTabsAsync();
|
||||
|
||||
if (fontChanged || fontSizeChanged || italicStateChanged)
|
||||
Plugin.FontManager.BuildFonts();
|
||||
|
||||
if (languageChanged)
|
||||
Plugin.LanguageChanged(Plugin.Interface.UiLanguage);
|
||||
|
||||
if (hideChanged)
|
||||
GameFunctions.GameFunctions.SetChatInteractable(true);
|
||||
|
||||
if (Plugin.Config.ShowEmotes)
|
||||
Task.Run(EmoteCache.LoadData);
|
||||
|
||||
Initialise();
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.FontIdentifier;
|
||||
using Dalamud.Interface.Style;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo.Ui.SettingsTabs;
|
||||
|
||||
internal sealed class Appearance : ISettingsTab
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
private Configuration Mutable { get; }
|
||||
|
||||
public string Name => HellionStrings.Settings_Tab_Appearance + "###tabs-appearance";
|
||||
|
||||
internal Appearance(Plugin plugin, Configuration mutable)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Mutable = mutable;
|
||||
}
|
||||
|
||||
public void Draw(bool changed)
|
||||
{
|
||||
DrawThemeSection();
|
||||
ImGui.Spacing();
|
||||
DrawFontsSection();
|
||||
ImGui.Spacing();
|
||||
DrawColoursSection();
|
||||
ImGui.Spacing();
|
||||
DrawTimestampsSection();
|
||||
}
|
||||
|
||||
private void DrawThemeSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Theme_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(HellionStrings.Theme_Enabled_Name, ref Mutable.HellionThemeEnabled);
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Theme_Enabled_Description);
|
||||
|
||||
// Clamp 0.5–1.0 stays consistent with Privacy.cs which already
|
||||
// shipped this slider; lower values would let chat windows
|
||||
// disappear behind game UI.
|
||||
using (ImRaii.Disabled(!Mutable.HellionThemeEnabled))
|
||||
{
|
||||
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
|
||||
var opacity = Mutable.HellionThemeWindowOpacity;
|
||||
if (ImGui.SliderFloat($"{HellionStrings.Theme_WindowOpacity_Label}##theme-opacity", ref opacity, 0.5f, 1.0f, "%.2f"))
|
||||
{
|
||||
Mutable.HellionThemeWindowOpacity = Math.Clamp(opacity, 0.5f, 1.0f);
|
||||
}
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Theme_WindowOpacity_Help);
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGui.Checkbox(Language.Options_OverrideStyle_Name, ref Mutable.OverrideStyle);
|
||||
ImGuiUtil.HelpMarker(Language.Options_OverrideStyle_Name_Desc);
|
||||
|
||||
if (Mutable.OverrideStyle)
|
||||
{
|
||||
DrawStyleCombo();
|
||||
}
|
||||
|
||||
// The Bestand-Slider WindowAlpha targets the chat log window's
|
||||
// background only. The Hellion theme opacity above already covers
|
||||
// every plugin window globally, so the two sliders fight each
|
||||
// other when the theme is active. Disable the legacy slider in
|
||||
// that case to make Hellion theme the single source of truth.
|
||||
using (ImRaii.Disabled(Mutable.HellionThemeEnabled))
|
||||
{
|
||||
ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawStyleCombo()
|
||||
{
|
||||
var styles = StyleModel.GetConfiguredStyles();
|
||||
if (styles == null)
|
||||
{
|
||||
ImGui.TextUnformatted(Language.Options_OverrideStyle_NotAvailable);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentStyle = Mutable.ChosenStyle ?? Language.Options_OverrideStyle_NotSelected;
|
||||
using var combo = ImRaii.Combo(Language.Options_OverrideStyleDropdown_Name, currentStyle);
|
||||
if (!combo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var style in styles)
|
||||
{
|
||||
if (ImGui.Selectable(style.Name, Mutable.ChosenStyle == style.Name))
|
||||
{
|
||||
Mutable.ChosenStyle = style.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawFontsSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Fonts_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
if (ImGui.Checkbox(HellionStrings.Theme_UseHellionFont_Name, ref Mutable.UseHellionFont))
|
||||
{
|
||||
// Mutex with the Bestand custom-font stack. Leaving FontsEnabled
|
||||
// checked alongside UseHellionFont made both checkboxes look
|
||||
// active even though the lower stack was greyed out, which
|
||||
// confused the user during the v0.5.0 walkthrough.
|
||||
if (Mutable.UseHellionFont)
|
||||
Mutable.FontsEnabled = false;
|
||||
}
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description);
|
||||
ImGui.Spacing();
|
||||
|
||||
using var fontDisabled = ImRaii.Disabled(Mutable.UseHellionFont);
|
||||
|
||||
ImGui.Checkbox(Language.Options_FontsEnabled, ref Mutable.FontsEnabled);
|
||||
ImGui.Spacing();
|
||||
|
||||
var unused = false;
|
||||
if (!Mutable.FontsEnabled)
|
||||
{
|
||||
ImGuiUtil.FontSizeCombo(Language.Options_FontSize_Name, ref Mutable.FontSizeV2);
|
||||
}
|
||||
else
|
||||
{
|
||||
var globalChooser = ImGuiUtil.FontChooser(Language.Options_Font_Name, Mutable.GlobalFontV2, false, ref unused);
|
||||
globalChooser?.ResultTask.ContinueWith(r =>
|
||||
{
|
||||
if (r.IsCompletedSuccessfully)
|
||||
Mutable.GlobalFontV2 = r.Result;
|
||||
});
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Reset##global"))
|
||||
{
|
||||
Mutable.GlobalFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), SizePt = 12.75f };
|
||||
}
|
||||
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_Font_Description, Plugin.PluginName));
|
||||
ImGuiUtil.WarningText(Language.Options_Font_Warning);
|
||||
ImGui.Spacing();
|
||||
|
||||
// LocaleNames being null means it is likely a game font which all support JP symbols.
|
||||
var japaneseChooser = ImGuiUtil.FontChooser(Language.Options_JapaneseFont_Name, Mutable.JapaneseFontV2, false, ref unused, id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false, "いろはにほへと ちりぬるを");
|
||||
japaneseChooser?.ResultTask.ContinueWith(r =>
|
||||
{
|
||||
if (r.IsCompletedSuccessfully)
|
||||
Mutable.JapaneseFontV2 = r.Result;
|
||||
});
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Reset##japanese"))
|
||||
{
|
||||
Mutable.JapaneseFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium), SizePt = 12.75f };
|
||||
}
|
||||
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName));
|
||||
ImGui.Spacing();
|
||||
|
||||
var italicChooser = ImGuiUtil.FontChooser(Language.Options_ItalicFont_Name, Mutable.ItalicFontV2, true, ref Mutable.ItalicEnabled);
|
||||
italicChooser?.ResultTask.ContinueWith(r =>
|
||||
{
|
||||
if (r.IsCompletedSuccessfully)
|
||||
Mutable.ItalicFontV2 = r.Result;
|
||||
});
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button("Reset##italic"))
|
||||
{
|
||||
Mutable.ItalicEnabled = false;
|
||||
Mutable.ItalicFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), SizePt = 12.75f };
|
||||
}
|
||||
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_Italic_Description, Plugin.PluginName));
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name))
|
||||
{
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName));
|
||||
|
||||
var range = (int)Mutable.ExtraGlyphRanges;
|
||||
foreach (var extra in Enum.GetValues<ExtraGlyphRanges>())
|
||||
{
|
||||
ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra);
|
||||
}
|
||||
|
||||
Mutable.ExtraGlyphRanges = (ExtraGlyphRanges)range;
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
ImGuiUtil.FontSizeCombo(Language.Options_SymbolsFontSize_Name, ref Mutable.SymbolsFontSizeV2);
|
||||
ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description);
|
||||
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawColoursSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Colours_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
foreach (var (_, types) in ChatTypeExt.SortOrder)
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.UndoAlt, $"{type}", Language.Options_ChatColours_Reset))
|
||||
{
|
||||
Mutable.ChatColours.Remove(type);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.LongArrowAltDown, $"{type}", Language.Options_ChatColours_Import))
|
||||
{
|
||||
var gameColour = Plugin.Functions.Chat.GetChannelColor(type);
|
||||
Mutable.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
var vec = Mutable.ChatColours.TryGetValue(type, out var colour)
|
||||
? ColourUtil.RgbaToVector3(colour)
|
||||
: ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0);
|
||||
if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs))
|
||||
{
|
||||
Mutable.ChatColours[type] = ColourUtil.Vector3ToRgba(vec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTimestampsSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Timestamps_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_PrettierTimestamps_Name, ref Mutable.PrettierTimestamps);
|
||||
ImGuiUtil.HelpMarker(Language.Options_PrettierTimestamps_Description);
|
||||
|
||||
if (Mutable.PrettierTimestamps)
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_MoreCompactPretty_Name, ref Mutable.MoreCompactPretty);
|
||||
ImGuiUtil.HelpMarker(Language.Options_MoreCompactPretty_Description);
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideSameTimestamps_Name, ref Mutable.HideSameTimestamps);
|
||||
ImGuiUtil.HelpMarker(Language.Options_HideSameTimestamps_Description);
|
||||
}
|
||||
|
||||
ImGui.Checkbox(Language.Options_Use24HourClock_Name, ref Mutable.Use24HourClock);
|
||||
ImGuiUtil.HelpMarker(Language.Options_Use24HourClock_Description);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Game.Text;
|
||||
|
||||
namespace ChatTwo.Ui.SettingsTabs;
|
||||
|
||||
internal sealed class Database : ISettingsTab
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
private Configuration Mutable { get; }
|
||||
|
||||
public string Name => Language.Options_Database_Tab + "###tabs-database";
|
||||
|
||||
internal Database(Plugin plugin, Configuration mutable)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Mutable = mutable;
|
||||
}
|
||||
|
||||
private bool ShowAdvanced;
|
||||
|
||||
private long DatabaseLastRefreshTicks;
|
||||
private long DatabaseSize;
|
||||
private long DatabaseLogSize;
|
||||
private int DatabaseMessageCount;
|
||||
|
||||
public void Draw(bool changed)
|
||||
{
|
||||
// Shift-on-open keeps the Advanced tools available without a permanent
|
||||
// toggle in the UI, mirroring upstream Chat 2 behaviour.
|
||||
if (changed)
|
||||
ShowAdvanced = ImGui.GetIO().KeyShift;
|
||||
|
||||
DrawStorageSection();
|
||||
ImGui.Spacing();
|
||||
DrawViewerSection();
|
||||
ImGui.Spacing();
|
||||
DrawStatsSection();
|
||||
}
|
||||
|
||||
private void DrawStorageSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Storage_Heading);
|
||||
if (!tree.Success)
|
||||
return;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_DatabaseBattleMessages_Name, ref Mutable.DatabaseBattleMessages);
|
||||
ImGuiUtil.HelpMarker(Language.Options_DatabaseBattleMessages_Description);
|
||||
|
||||
if (ImGui.Checkbox(Language.Options_LoadPreviousSession_Name, ref Mutable.LoadPreviousSession))
|
||||
if (Mutable.LoadPreviousSession)
|
||||
Mutable.FilterIncludePreviousSessions = true;
|
||||
ImGuiUtil.HelpMarker(Language.Options_LoadPreviousSession_Description);
|
||||
|
||||
if (ImGui.Checkbox(Language.Options_FilterIncludePreviousSessions_Name, ref Mutable.FilterIncludePreviousSessions))
|
||||
if (!Mutable.FilterIncludePreviousSessions)
|
||||
Mutable.LoadPreviousSession = false;
|
||||
ImGuiUtil.HelpMarker(Language.Options_FilterIncludePreviousSessions_Description);
|
||||
|
||||
var old = new FileInfo(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat.db"));
|
||||
var migratedOld = new FileInfo(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-litedb.db"));
|
||||
if (old.Exists || migratedOld.Exists)
|
||||
{
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGui.TextUnformatted(Language.Options_Database_Old_Heading);
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGuiUtil.CtrlShiftButton(Language.Options_Database_Old_Delete, Language.Options_Database_Old_Delete_Tooltip))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (old.Exists)
|
||||
old.Delete();
|
||||
else
|
||||
migratedOld.Delete();
|
||||
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Error(e, "Unable to delete old database");
|
||||
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Error, NotificationType.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawViewerSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Viewer_Heading);
|
||||
if (!tree.Success)
|
||||
return;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
// Refresh the database size and message count every 5 seconds to avoid
|
||||
// constant stat calls and spamming the database.
|
||||
if (DatabaseLastRefreshTicks + 5 * 1000 < Environment.TickCount64)
|
||||
{
|
||||
DatabaseSize = Plugin.MessageManager.Store.DatabaseSize();
|
||||
DatabaseLogSize = Plugin.MessageManager.Store.DatabaseLogSize();
|
||||
DatabaseMessageCount = Plugin.MessageManager.Store.MessageCount();
|
||||
DatabaseLastRefreshTicks = Environment.TickCount64;
|
||||
}
|
||||
|
||||
// Copy the directory path instead of the file path so people can
|
||||
// paste it into their file explorer.
|
||||
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Path, MessageManager.DatabasePath()));
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
|
||||
{
|
||||
var path = Path.GetDirectoryName(MessageManager.DatabasePath());
|
||||
ImGui.SetClipboardText(path);
|
||||
WrapperUtil.AddNotification(Language.Options_Database_Metadata_CopyConfigPathNotification, NotificationType.Info);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
|
||||
ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath);
|
||||
}
|
||||
|
||||
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Size, StringUtil.BytesToString(DatabaseSize)));
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseSize));
|
||||
|
||||
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_LogSize, StringUtil.BytesToString(DatabaseLogSize)));
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseLogSize));
|
||||
|
||||
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount));
|
||||
|
||||
if (ImGuiUtil.CtrlShiftButton(Language.Options_ClearDatabase_Button, Language.Options_ClearDatabase_Tooltip))
|
||||
{
|
||||
Plugin.Log.Warning("Clearing messages from database");
|
||||
Plugin.MessageManager.Store.ClearMessages();
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
|
||||
// Refresh on next draw
|
||||
DatabaseLastRefreshTicks = 0;
|
||||
WrapperUtil.AddNotification(Language.Options_ClearDatabase_Success, NotificationType.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawStatsSection()
|
||||
{
|
||||
if (!ShowAdvanced)
|
||||
return;
|
||||
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Stats_Heading);
|
||||
if (!tree.Success)
|
||||
return;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
||||
|
||||
ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning);
|
||||
if (ImGuiUtil.CtrlShiftButton("Perform maintenance", "Ctrl+Shift: MessageManager.Store.PerformMaintenance()"))
|
||||
Plugin.MessageManager.Store.PerformMaintenance();
|
||||
|
||||
if (ImGuiUtil.CtrlShiftButton("Reload messages from database", "Ctrl+Shift: MessageManager.FilterAllTabs()"))
|
||||
{
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
Plugin.MessageManager.FilterAllTabsAsync();
|
||||
}
|
||||
|
||||
if (ImGuiUtil.CtrlShiftButton("Inject 10,000 messages", "Ctrl+Shift: creates 10,000 unique messages (async)"))
|
||||
new Thread(() => InsertMessages(10_000)).Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertMessages(int count)
|
||||
{
|
||||
Plugin.Log.Info($"Inserting {count} messages due to user request");
|
||||
|
||||
// Generate
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var playerName = Plugin.PlayerState.CharacterName;
|
||||
var worldId = Plugin.PlayerState.HomeWorld.ValueNullable?.RowId ?? 0;
|
||||
var senderSource = new SeStringBuilder()
|
||||
.AddText("<")
|
||||
.Add(new PlayerPayload(playerName, worldId))
|
||||
.AddText("Random Message")
|
||||
.Add(RawPayload.LinkTerminator)
|
||||
.AddText(">: ")
|
||||
.Build();
|
||||
var senderChunks = ChunkUtil.ToChunks(senderSource, ChunkSource.Sender, ChatType.Debug).ToList();
|
||||
var messages = new List<Message>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var contentSource = new SeStringBuilder()
|
||||
.AddText("Random message payload - ")
|
||||
.AddItalics(Guid.NewGuid().ToString())
|
||||
.Build();
|
||||
var contentChunks = ChunkUtil.ToChunks(contentSource, ChunkSource.Content, ChatType.Debug).ToList();
|
||||
|
||||
var chatCode = new ChatCode(XivChatType.Say, 0, 0);
|
||||
messages.Add(new Message(
|
||||
Guid.NewGuid(),
|
||||
Plugin.MessageManager.CurrentContentId,
|
||||
Plugin.MessageManager.CurrentContentId,
|
||||
DateTimeOffset.UtcNow,
|
||||
chatCode,
|
||||
senderChunks,
|
||||
contentChunks,
|
||||
senderSource,
|
||||
contentSource,
|
||||
Guid.Empty
|
||||
));
|
||||
}
|
||||
|
||||
var elapsedTicks = stopwatch.ElapsedTicks;
|
||||
stopwatch.Stop();
|
||||
Plugin.Log.Info($"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
|
||||
|
||||
// Insert
|
||||
stopwatch = Stopwatch.StartNew();
|
||||
foreach (var message in messages)
|
||||
Plugin.MessageManager.Store.UpsertMessage(message);
|
||||
|
||||
elapsedTicks = stopwatch.ElapsedTicks;
|
||||
stopwatch.Stop();
|
||||
Plugin.Log.Info($"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
|
||||
|
||||
// Clear tabs during framework frame
|
||||
Plugin.Framework.Run(() =>
|
||||
{
|
||||
stopwatch = Stopwatch.StartNew();
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
elapsedTicks = stopwatch.ElapsedTicks;
|
||||
stopwatch.Stop();
|
||||
Plugin.Log.Info($"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
|
||||
}).Wait();
|
||||
|
||||
// Fetch and filter during framework frame
|
||||
Plugin.Framework.Run(() =>
|
||||
{
|
||||
stopwatch = Stopwatch.StartNew();
|
||||
// Intentionally synchronous
|
||||
Plugin.MessageManager.FilterAllTabs();
|
||||
elapsedTicks = stopwatch.ElapsedTicks;
|
||||
stopwatch.Stop();
|
||||
Plugin.Log.Info($"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
|
||||
}).Wait();
|
||||
}
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Export;
|
||||
using ChatTwo.Privacy;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo.Ui.SettingsTabs;
|
||||
|
||||
internal sealed class Privacy : ISettingsTab
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
private Configuration Mutable { get; }
|
||||
|
||||
public string Name => HellionStrings.Privacy_Tab_Title + "###tabs-privacy";
|
||||
|
||||
internal Privacy(Plugin plugin, Configuration mutable)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Mutable = mutable;
|
||||
}
|
||||
|
||||
// (HeadingKey lookup, ChatType list). Heading is resolved per-frame so
|
||||
// a runtime LanguageChanged call updates the labels immediately.
|
||||
private static readonly (Func<string> Heading, ChatType[] Types)[] Groups =
|
||||
[
|
||||
(() => HellionStrings.Privacy_Group_DirectMessages, [ChatType.TellIncoming, ChatType.TellOutgoing]),
|
||||
(() => HellionStrings.Privacy_Group_PartyAlliance, [ChatType.Party, ChatType.CrossParty, ChatType.Alliance, ChatType.PvpTeam]),
|
||||
(() => HellionStrings.Privacy_Group_FreeCompany, [ChatType.FreeCompany, ChatType.FreeCompanyAnnouncement, ChatType.FreeCompanyLoginLogout]),
|
||||
(() => HellionStrings.Privacy_Group_Linkshells, [
|
||||
ChatType.Linkshell1, ChatType.Linkshell2, ChatType.Linkshell3, ChatType.Linkshell4,
|
||||
ChatType.Linkshell5, ChatType.Linkshell6, ChatType.Linkshell7, ChatType.Linkshell8,
|
||||
]),
|
||||
(() => HellionStrings.Privacy_Group_CrossLinkshells, [
|
||||
ChatType.CrossLinkshell1, ChatType.CrossLinkshell2, ChatType.CrossLinkshell3, ChatType.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5, ChatType.CrossLinkshell6, ChatType.CrossLinkshell7, ChatType.CrossLinkshell8,
|
||||
]),
|
||||
(() => HellionStrings.Privacy_Group_ExtraChat, [
|
||||
ChatType.ExtraChatLinkshell1, ChatType.ExtraChatLinkshell2, ChatType.ExtraChatLinkshell3, ChatType.ExtraChatLinkshell4,
|
||||
ChatType.ExtraChatLinkshell5, ChatType.ExtraChatLinkshell6, ChatType.ExtraChatLinkshell7, ChatType.ExtraChatLinkshell8,
|
||||
]),
|
||||
(() => HellionStrings.Privacy_Group_PublicChat, [ChatType.Say, ChatType.Shout, ChatType.Yell, ChatType.NoviceNetwork, ChatType.CustomEmote, ChatType.StandardEmote]),
|
||||
(() => HellionStrings.Privacy_Group_SystemLogs, [
|
||||
ChatType.System, ChatType.Notice, ChatType.Urgent, ChatType.Echo,
|
||||
ChatType.NpcDialogue, ChatType.NpcAnnouncement,
|
||||
ChatType.LootNotice, ChatType.LootRoll, ChatType.RetainerSale,
|
||||
ChatType.Crafting, ChatType.Gathering, ChatType.Sign, ChatType.RandomNumber,
|
||||
]),
|
||||
];
|
||||
|
||||
private Dictionary<int, long>? CleanupCounts;
|
||||
private long CleanupKeepCount;
|
||||
private long CleanupDeleteCount;
|
||||
private bool CleanupRunning;
|
||||
|
||||
// The retention-running state lives on Plugin so the auto-sweep and
|
||||
// this manual button see the same flag. UI reads stay lock-free
|
||||
// because ImGui is single-threaded and bool reads are atomic in .NET.
|
||||
private bool RetentionRunning => Plugin.RetentionSweepRunning;
|
||||
|
||||
// Export form state
|
||||
private int ExportRangeDays = 30;
|
||||
private string ExportSenderSubstring = string.Empty;
|
||||
private readonly HashSet<ChatType> ExportSelectedChannels = [];
|
||||
private ExportFormat ExportFormat = ExportFormat.Markdown;
|
||||
private bool ExportRunning;
|
||||
|
||||
public void Draw(bool changed)
|
||||
{
|
||||
if (ImGui.Button(HellionStrings.Wizard_Reopen_Button))
|
||||
Plugin.FirstRunWizard.IsOpen = true;
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawPrivacyFilterSection();
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawRetentionSection();
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawCleanupSection();
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawExportSection();
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
DrawAutoTellTabsPreloadSection();
|
||||
}
|
||||
|
||||
private void DrawAutoTellTabsPreloadSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Privacy_AutoTellTabs_Section_Title);
|
||||
if (!tree.Success)
|
||||
return;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
var preload = Mutable.AutoTellTabsHistoryPreload;
|
||||
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
|
||||
if (ImGui.SliderInt(HellionStrings.Privacy_AutoTellTabs_Preload_Name, ref preload, 0, 100))
|
||||
{
|
||||
Mutable.AutoTellTabsHistoryPreload = preload;
|
||||
}
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description);
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGuiUtil.HelpText(HellionStrings.Privacy_AutoTellTabs_Preload_Hint);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPrivacyFilterSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Privacy_Filter_Tree_Heading);
|
||||
if (!tree.Success)
|
||||
return;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGuiUtil.OptionCheckbox(
|
||||
ref Mutable.PrivacyFilterEnabled,
|
||||
HellionStrings.Privacy_FilterEnabled_Name,
|
||||
HellionStrings.Privacy_FilterEnabled_Description);
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Privacy_FilterEnabled_StorageOnly_Help);
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
using (ImRaii.Disabled(!Mutable.PrivacyFilterEnabled))
|
||||
{
|
||||
ImGuiUtil.HelpText(HellionStrings.Privacy_Whitelist_Help);
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGui.Button(HellionStrings.Privacy_Preset_PrivacyFirst))
|
||||
Mutable.PrivacyPersistChannels = [..PrivacyDefaults.PrivacyFirstWhitelist];
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(HellionStrings.Privacy_Preset_ClearAll))
|
||||
Mutable.PrivacyPersistChannels.Clear();
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(HellionStrings.Privacy_Preset_SelectAll))
|
||||
foreach (var group in Groups)
|
||||
foreach (var t in group.Types)
|
||||
Mutable.PrivacyPersistChannels.Add(t);
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
foreach (var (heading, types) in Groups)
|
||||
{
|
||||
using var groupTree = ImRaii.TreeNode(heading());
|
||||
if (!groupTree.Success)
|
||||
continue;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
var enabled = Mutable.PrivacyPersistChannels.Contains(type);
|
||||
var label = type.ToString();
|
||||
if (ImGui.Checkbox($"{label}##privacy-{(int)type}", ref enabled))
|
||||
{
|
||||
if (enabled)
|
||||
Mutable.PrivacyPersistChannels.Add(type);
|
||||
else
|
||||
Mutable.PrivacyPersistChannels.Remove(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.Separator();
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(
|
||||
ref Mutable.PrivacyPersistUnknownChannels,
|
||||
HellionStrings.Privacy_PersistUnknown_Name,
|
||||
HellionStrings.Privacy_PersistUnknown_Description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawExportSection()
|
||||
{
|
||||
ImGui.TextUnformatted(HellionStrings.Export_Heading);
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGuiUtil.HelpText(HellionStrings.Export_Help);
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGui.InputInt(HellionStrings.Export_Range_Label, ref ExportRangeDays))
|
||||
ExportRangeDays = Math.Max(0, ExportRangeDays);
|
||||
|
||||
ImGui.InputText(HellionStrings.Export_Sender_Label, ref ExportSenderSubstring, 256);
|
||||
|
||||
using (var tree = ImRaii.TreeNode(HellionStrings.Export_Channels_Heading))
|
||||
{
|
||||
if (tree.Success)
|
||||
{
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff);
|
||||
foreach (var (heading, types) in Groups)
|
||||
{
|
||||
using var subTree = ImRaii.TreeNode($"{heading()}##export-group-{heading()}");
|
||||
if (!subTree.Success)
|
||||
continue;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
foreach (var type in types)
|
||||
{
|
||||
var enabled = ExportSelectedChannels.Contains(type);
|
||||
if (ImGui.Checkbox($"{type}##export-{(int)type}", ref enabled))
|
||||
{
|
||||
if (enabled)
|
||||
ExportSelectedChannels.Add(type);
|
||||
else
|
||||
ExportSelectedChannels.Remove(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGui.TextUnformatted(HellionStrings.Export_Format_Label);
|
||||
ImGui.SameLine();
|
||||
var fmt = (int)ExportFormat;
|
||||
if (ImGui.RadioButton(HellionStrings.Export_Format_Markdown, ref fmt, (int)ExportFormat.Markdown))
|
||||
ExportFormat = ExportFormat.Markdown;
|
||||
ImGui.SameLine();
|
||||
if (ImGui.RadioButton(HellionStrings.Export_Format_Json, ref fmt, (int)ExportFormat.Json))
|
||||
ExportFormat = ExportFormat.Json;
|
||||
ImGui.SameLine();
|
||||
if (ImGui.RadioButton(HellionStrings.Export_Format_Csv, ref fmt, (int)ExportFormat.Csv))
|
||||
ExportFormat = ExportFormat.Csv;
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
using (ImRaii.Disabled(ExportRunning))
|
||||
{
|
||||
if (ImGui.Button(HellionStrings.Export_Button))
|
||||
PromptExport();
|
||||
}
|
||||
|
||||
if (ExportRunning)
|
||||
ImGuiUtil.HelpText(HellionStrings.Export_Running);
|
||||
}
|
||||
}
|
||||
|
||||
private void PromptExport()
|
||||
{
|
||||
var defaultName = $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}";
|
||||
var ext = ExportFormat.Extension();
|
||||
|
||||
Plugin.FileDialogManager.SaveFileDialog(
|
||||
HellionStrings.Export_Dialog_Title,
|
||||
ExportFormat.Filter(),
|
||||
defaultName,
|
||||
ext,
|
||||
(success, path) =>
|
||||
{
|
||||
if (!success || string.IsNullOrWhiteSpace(path))
|
||||
return;
|
||||
StartExport(path);
|
||||
});
|
||||
}
|
||||
|
||||
private void StartExport(string path)
|
||||
{
|
||||
if (ExportRunning)
|
||||
return;
|
||||
ExportRunning = true;
|
||||
|
||||
var types = ExportSelectedChannels.Count > 0
|
||||
? ExportSelectedChannels.Select(t => (int)(ushort)t).ToList()
|
||||
: null;
|
||||
|
||||
DateTimeOffset? from = ExportRangeDays > 0
|
||||
? DateTimeOffset.UtcNow.AddDays(-ExportRangeDays)
|
||||
: null;
|
||||
|
||||
var senderSubstring = string.IsNullOrWhiteSpace(ExportSenderSubstring) ? null : ExportSenderSubstring.Trim();
|
||||
var format = ExportFormat;
|
||||
var filterDesc = new MessageExporter.FilterDescription(types, from, null, senderSubstring);
|
||||
|
||||
new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = Plugin.MessageManager.Store.StreamForExport(types, from, null);
|
||||
var written = MessageExporter.ExportToFile(path, format, enumerator, filterDesc);
|
||||
|
||||
if (written > 0)
|
||||
WrapperUtil.AddNotification(string.Format(HellionStrings.Export_Success, written, path), NotificationType.Success);
|
||||
else
|
||||
WrapperUtil.AddNotification(HellionStrings.Export_Empty, NotificationType.Info);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Error(e, "Export failed");
|
||||
WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ExportRunning = false;
|
||||
}
|
||||
}) { IsBackground = true }.Start();
|
||||
}
|
||||
|
||||
private void DrawRetentionSection()
|
||||
{
|
||||
ImGui.TextUnformatted(HellionStrings.Retention_Heading);
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGuiUtil.OptionCheckbox(
|
||||
ref Mutable.RetentionEnabled,
|
||||
HellionStrings.Retention_Enabled_Name,
|
||||
HellionStrings.Retention_Enabled_Description);
|
||||
|
||||
using (ImRaii.Disabled(!Mutable.RetentionEnabled))
|
||||
{
|
||||
ImGui.Spacing();
|
||||
|
||||
var defaultDays = Mutable.RetentionDefaultDays;
|
||||
if (ImGui.InputInt(HellionStrings.Retention_Default_Label, ref defaultDays))
|
||||
Mutable.RetentionDefaultDays = Math.Max(0, defaultDays);
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Retention_Default_Help);
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGui.Button(HellionStrings.Retention_Reset_Spec))
|
||||
{
|
||||
Mutable.RetentionPerChannelDays =
|
||||
PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value);
|
||||
}
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button(HellionStrings.Retention_Clear_Overrides))
|
||||
Mutable.RetentionPerChannelDays.Clear();
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
using (var tree = ImRaii.TreeNode(HellionStrings.Retention_Tree_Heading))
|
||||
{
|
||||
if (tree.Success)
|
||||
{
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
foreach (var (heading, types) in Groups)
|
||||
{
|
||||
using var subTree = ImRaii.TreeNode(heading());
|
||||
if (!subTree.Success)
|
||||
continue;
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
foreach (var type in types)
|
||||
{
|
||||
var hasOverride = Mutable.RetentionPerChannelDays.TryGetValue(type, out var days);
|
||||
var hasSpecDefault = PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDays);
|
||||
if (!hasOverride)
|
||||
days = hasSpecDefault ? specDays : Mutable.RetentionDefaultDays;
|
||||
|
||||
var tag = hasOverride
|
||||
? HellionStrings.Retention_Tag_Override
|
||||
: hasSpecDefault
|
||||
? HellionStrings.Retention_Tag_Spec
|
||||
: HellionStrings.Retention_Tag_Global;
|
||||
if (ImGui.InputInt($"{type} {tag}##retention-{(int)type}", ref days))
|
||||
{
|
||||
days = Math.Max(0, days);
|
||||
Mutable.RetentionPerChannelDays[type] = days;
|
||||
}
|
||||
|
||||
if (hasOverride)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
if (ImGui.Button($"{HellionStrings.Retention_Reset_Button}##retention-reset-{(int)type}"))
|
||||
Mutable.RetentionPerChannelDays.Remove(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.HelpText(HellionStrings.Retention_Help_SavedNote);
|
||||
ImGui.Spacing();
|
||||
|
||||
using (ImRaii.Disabled(RetentionRunning))
|
||||
{
|
||||
if (ImGuiUtil.CtrlShiftButton(HellionStrings.Retention_Apply_Label, HellionStrings.Retention_Apply_Tooltip))
|
||||
StartRetentionRun();
|
||||
}
|
||||
|
||||
if (RetentionRunning)
|
||||
ImGuiUtil.HelpText(HellionStrings.Retention_Running);
|
||||
|
||||
ImGui.Spacing();
|
||||
var lastRun = Plugin.Config.RetentionLastRunAt;
|
||||
ImGuiUtil.HelpText(lastRun == DateTimeOffset.MinValue
|
||||
? HellionStrings.Retention_LastRun_Never
|
||||
: string.Format(HellionStrings.Retention_LastRun_At, lastRun.ToLocalTime()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartRetentionRun()
|
||||
{
|
||||
// Take the shared retention lock so we cannot fight the auto-sweep
|
||||
// for the database connection. If the auto-sweep is already in
|
||||
// flight we just bail — the user can press the button again once
|
||||
// it finishes.
|
||||
lock (Plugin.RetentionSweepLock)
|
||||
{
|
||||
if (Plugin.RetentionSweepRunning)
|
||||
return;
|
||||
Plugin.RetentionSweepRunning = true;
|
||||
}
|
||||
|
||||
var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
|
||||
var defaultDays = Plugin.Config.RetentionDefaultDays;
|
||||
|
||||
new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = Plugin.MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
||||
Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
||||
Plugin.SaveConfig();
|
||||
|
||||
Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages.");
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
Plugin.Framework.Run(() =>
|
||||
{
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
Plugin.MessageManager.FilterAllTabsAsync();
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
WrapperUtil.AddNotification(string.Format(HellionStrings.Retention_Success, deleted), NotificationType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Error(e, "Manual retention run failed");
|
||||
WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (Plugin.RetentionSweepLock)
|
||||
Plugin.RetentionSweepRunning = false;
|
||||
}
|
||||
}) { IsBackground = true }.Start();
|
||||
}
|
||||
|
||||
private void DrawCleanupSection()
|
||||
{
|
||||
ImGui.TextUnformatted(HellionStrings.Cleanup_Heading);
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_Intro);
|
||||
ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_SavedNote);
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
using (ImRaii.Disabled(CleanupRunning))
|
||||
{
|
||||
if (ImGui.Button(HellionStrings.Cleanup_RefreshPreview))
|
||||
RefreshCleanupPreview();
|
||||
}
|
||||
|
||||
if (CleanupCounts is null)
|
||||
{
|
||||
ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview);
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_TotalStored, CleanupKeepCount + CleanupDeleteCount));
|
||||
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillKeep, CleanupKeepCount));
|
||||
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillDelete, CleanupDeleteCount));
|
||||
|
||||
using (var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown))
|
||||
{
|
||||
if (tree.Success)
|
||||
{
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
foreach (var (chatType, count) in CleanupCounts.OrderByDescending(p => p.Value))
|
||||
{
|
||||
var name = Enum.IsDefined(typeof(ChatType), (ushort)chatType)
|
||||
? ((ChatType)(ushort)chatType).ToString()
|
||||
: $"Unknown({chatType})";
|
||||
var keeps = WouldBeKept(chatType);
|
||||
var marker = keeps ? HellionStrings.Cleanup_Marker_Keep : HellionStrings.Cleanup_Marker_Delete;
|
||||
ImGuiUtil.HelpText($"{marker} {name} — {count:N0}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
using (ImRaii.Disabled(CleanupRunning || CleanupDeleteCount == 0))
|
||||
{
|
||||
if (ImGuiUtil.CtrlShiftButton(HellionStrings.Cleanup_Apply_Label,
|
||||
string.Format(HellionStrings.Cleanup_Apply_Tooltip, CleanupDeleteCount)))
|
||||
StartCleanup();
|
||||
}
|
||||
|
||||
if (CleanupRunning)
|
||||
ImGuiUtil.HelpText(HellionStrings.Cleanup_Running);
|
||||
}
|
||||
}
|
||||
|
||||
private bool WouldBeKept(int chatType)
|
||||
{
|
||||
if (!Plugin.Config.PrivacyFilterEnabled)
|
||||
return true;
|
||||
if (Plugin.Config.PrivacyPersistChannels.Contains((ChatType)(ushort)chatType))
|
||||
return true;
|
||||
return Plugin.Config.PrivacyPersistUnknownChannels;
|
||||
}
|
||||
|
||||
private void RefreshCleanupPreview()
|
||||
{
|
||||
try
|
||||
{
|
||||
CleanupCounts = Plugin.MessageManager.Store.GetMessageCountsByChatType();
|
||||
CleanupKeepCount = 0;
|
||||
CleanupDeleteCount = 0;
|
||||
foreach (var (chatType, count) in CleanupCounts)
|
||||
{
|
||||
if (WouldBeKept(chatType))
|
||||
CleanupKeepCount += count;
|
||||
else
|
||||
CleanupDeleteCount += count;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Error(e, "Failed to compute cleanup preview");
|
||||
WrapperUtil.AddNotification(HellionStrings.Cleanup_PreviewError, NotificationType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartCleanup()
|
||||
{
|
||||
if (CleanupRunning)
|
||||
return;
|
||||
|
||||
CleanupRunning = true;
|
||||
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
|
||||
|
||||
new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
|
||||
Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
|
||||
|
||||
Plugin.Framework.Run(() =>
|
||||
{
|
||||
Plugin.MessageManager.ClearAllTabs();
|
||||
Plugin.MessageManager.FilterAllTabsAsync();
|
||||
}).Wait();
|
||||
|
||||
WrapperUtil.AddNotification(string.Format(HellionStrings.Cleanup_Success, deleted), NotificationType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Error(e, "Privacy cleanup failed");
|
||||
WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupRunning = false;
|
||||
CleanupCounts = null;
|
||||
}
|
||||
}).Start();
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
|
||||
namespace ChatTwo.Ui.SettingsTabs;
|
||||
|
||||
internal sealed class Tabs : ISettingsTab
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
private Configuration Mutable { get; }
|
||||
|
||||
public string Name => Language.Options_Tabs_Tab + "###tabs-tabs";
|
||||
|
||||
private int ToOpen = -2;
|
||||
|
||||
internal Tabs(Plugin plugin, Configuration mutable)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Mutable = mutable;
|
||||
}
|
||||
|
||||
public void Draw(bool changed)
|
||||
{
|
||||
const string addTabPopup = "add-tab-popup";
|
||||
|
||||
ImGuiUtil.HelpText(HellionStrings.Tabs_Presets_Linkshell_Hint);
|
||||
ImGui.Spacing();
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Plus, tooltip: Language.Options_Tabs_Add))
|
||||
ImGui.OpenPopup(addTabPopup);
|
||||
|
||||
using (var popup = ImRaii.Popup(addTabPopup))
|
||||
{
|
||||
if (popup)
|
||||
{
|
||||
if (ImGui.Selectable(Language.Options_Tabs_NewTab))
|
||||
Mutable.Tabs.Add(new Tab());
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_General)))
|
||||
Mutable.Tabs.Add(TabsUtil.VanillaGeneral);
|
||||
|
||||
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Event)))
|
||||
Mutable.Tabs.Add(TabsUtil.VanillaEvent);
|
||||
|
||||
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Tell)))
|
||||
Mutable.Tabs.Add(TabsUtil.VanillaTellExclusive);
|
||||
}
|
||||
}
|
||||
|
||||
var toRemove = -1;
|
||||
var doOpens = ToOpen > -2;
|
||||
for (var i = 0; i < Mutable.Tabs.Count; i++)
|
||||
{
|
||||
var tab = Mutable.Tabs[i];
|
||||
|
||||
if (doOpens)
|
||||
ImGui.SetNextItemOpen(i == ToOpen);
|
||||
|
||||
using var treeNode = ImRaii.TreeNode($"{tab.Name}###tab-{i}");
|
||||
if (!treeNode.Success)
|
||||
continue;
|
||||
|
||||
using var pushedId = ImRaii.PushId($"tab-{i}");
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.TrashAlt, tooltip: Language.Options_Tabs_Delete))
|
||||
{
|
||||
toRemove = i;
|
||||
ToOpen = -1;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowUp, tooltip: Language.Options_Tabs_MoveUp) && i > 0)
|
||||
{
|
||||
(Mutable.Tabs[i - 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i - 1]);
|
||||
ToOpen = i - 1;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowDown, tooltip: Language.Options_Tabs_MoveDown) && i < Mutable.Tabs.Count - 1)
|
||||
{
|
||||
(Mutable.Tabs[i + 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i + 1]);
|
||||
ToOpen = i + 1;
|
||||
}
|
||||
|
||||
ImGui.InputText(Language.Options_Tabs_Name, ref tab.Name, 512, ImGuiInputTextFlags.EnterReturnsTrue);
|
||||
ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp);
|
||||
ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut);
|
||||
if (tab.PopOut)
|
||||
{
|
||||
using var _ = ImRaii.PushIndent(10.0f);
|
||||
ImGui.Checkbox(Language.Options_Tabs_IndependentOpacity, ref tab.IndependentOpacity);
|
||||
if (tab.IndependentOpacity)
|
||||
ImGuiUtil.DragFloatVertical(Language.Options_Tabs_Opacity, ref tab.Opacity, 0.25f, 0f, 100f, $"{tab.Opacity:N2}%%", ImGuiSliderFlags.AlwaysClamp);
|
||||
|
||||
ImGui.Checkbox(Language.Options_Tabs_IndependentHide, ref tab.IndependentHide);
|
||||
if (tab.IndependentHide)
|
||||
{
|
||||
using var __ = ImRaii.PushIndent(10.0f);
|
||||
ImGuiUtil.OptionCheckbox(ref tab.HideDuringCutscenes, Language.Options_HideDuringCutscenes_Name);
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.HideWhenNotLoggedIn, Language.Options_HideWhenNotLoggedIn_Name);
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.HideWhenUiHidden, Language.Options_HideWhenUiHidden_Name);
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.HideInLoadingScreens, Language.Options_HideInLoadingScreens_Name);
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.HideInBattle, Language.Options_HideInBattle_Name);
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.CanMove, Language.Popout_CanMove_Name);
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.OptionCheckbox(ref tab.CanResize, Language.Popout_CanResize_Name);
|
||||
ImGui.Spacing();
|
||||
}
|
||||
|
||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Tabs_UnreadMode, tab.UnreadMode.Name()))
|
||||
{
|
||||
if (combo.Success)
|
||||
{
|
||||
foreach (var mode in Enum.GetValues<UnreadMode>())
|
||||
{
|
||||
if (ImGui.Selectable(mode.Name(), tab.UnreadMode == mode))
|
||||
tab.UnreadMode = mode;
|
||||
|
||||
if (mode.Tooltip() is { } tooltip && ImGui.IsItemHovered())
|
||||
ImGuiUtil.Tooltip(tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Mutable.HideWhenInactive)
|
||||
ImGui.Checkbox(Language.Options_Tabs_InactivityBehaviour, ref tab.UnhideOnActivity);
|
||||
|
||||
ImGui.Checkbox(Language.Options_Tabs_NoInput, ref tab.InputDisabled);
|
||||
if (!tab.InputDisabled)
|
||||
{
|
||||
var input = tab.Channel?.ToChatType().Name() ?? Language.Options_Tabs_NoInputChannel;
|
||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Tabs_InputChannel, input))
|
||||
{
|
||||
if (combo.Success)
|
||||
{
|
||||
if (ImGui.Selectable(Language.Options_Tabs_NoInputChannel, tab.Channel == null))
|
||||
tab.Channel = null;
|
||||
|
||||
foreach (var channel in Enum.GetValues<InputChannel>())
|
||||
if (ImGui.Selectable(channel.ToChatType().Name(), tab.Channel == channel))
|
||||
tab.Channel = channel;
|
||||
}
|
||||
}
|
||||
|
||||
var player = Plugin.ObjectTable.LocalPlayer;
|
||||
if (tab.Channel == InputChannel.Tell && player != null)
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_Tabs_SenderMessages, ref tab.AllSenderMessages);
|
||||
ImGuiUtil.HelpText(Language.Options_Help_SenderMessages);
|
||||
|
||||
var worlds = Sheets.WorldsOnDatacenter(player).OrderByDescending(world => world.DataCenter.RowId).ThenBy(world => world.Name.ToString()).ToList();
|
||||
|
||||
using (ImRaii.ItemWidth(ImGui.GetWindowWidth() / 3f))
|
||||
{
|
||||
ImGui.Text(Language.Options_Header_Target);
|
||||
ImGui.SameLine();
|
||||
|
||||
var name = tab.TellTarget.Name;
|
||||
if (ImGui.InputText("##targetInput", ref name, 21))
|
||||
tab.TellTarget.Name = name;
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
var selectedWorld = worlds.FindIndex(world => world.RowId == tab.TellTarget.World);
|
||||
if (selectedWorld == -1)
|
||||
selectedWorld = 0;
|
||||
|
||||
using (var combo = ImRaii.Combo("###player-world", worlds[selectedWorld].Name.ToString()))
|
||||
{
|
||||
if (combo.Success)
|
||||
{
|
||||
var lastDc = worlds.First().DataCenter.RowId;
|
||||
foreach (var (idx, world) in worlds.Index())
|
||||
{
|
||||
if (ImGui.Selectable(world.Name.ToString(), selectedWorld == idx))
|
||||
{
|
||||
selectedWorld = idx;
|
||||
tab.TellTarget.World = worlds[selectedWorld].RowId;
|
||||
}
|
||||
|
||||
if (lastDc == world.DataCenter.RowId)
|
||||
continue;
|
||||
|
||||
lastDc = world.DataCenter.RowId;
|
||||
ImGui.Separator();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var target = (Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target) as IPlayerCharacter;
|
||||
using (ImRaii.Disabled(target == null))
|
||||
{
|
||||
if (ImGui.Button("Set to target") && target != null)
|
||||
tab.TellTarget.FromTarget(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, tab.SelectedChannels);
|
||||
ImGuiUtil.ExtraChatSelector(Language.Options_Tabs_ExtraChatChannels, ref tab.ExtraChatAll, tab.ExtraChatChannels);
|
||||
}
|
||||
|
||||
if (toRemove > -1)
|
||||
{
|
||||
Mutable.Tabs.RemoveAt(toRemove);
|
||||
Plugin.WantedTab = 0;
|
||||
}
|
||||
|
||||
if (doOpens)
|
||||
ToOpen = -2;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace ChatTwo.Ui.SettingsTabs;
|
||||
|
||||
internal sealed class Window : ISettingsTab
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
private Configuration Mutable { get; }
|
||||
|
||||
public string Name => HellionStrings.Settings_Tab_Window + "###tabs-window";
|
||||
|
||||
internal Window(Plugin plugin, Configuration mutable)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Mutable = mutable;
|
||||
}
|
||||
|
||||
public void Draw(bool changed)
|
||||
{
|
||||
DrawHideSection();
|
||||
ImGui.Spacing();
|
||||
DrawInactivityHideSection();
|
||||
ImGui.Spacing();
|
||||
DrawFrameSection();
|
||||
ImGui.Spacing();
|
||||
DrawTooltipsSection();
|
||||
}
|
||||
|
||||
private void DrawHideSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Hide_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_HideChat_Name, ref Mutable.HideChat);
|
||||
ImGuiUtil.HelpMarker(Language.Options_HideChat_Description);
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideDuringCutscenes_Name, ref Mutable.HideDuringCutscenes);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideDuringCutscenes_Description, Plugin.PluginName));
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideWhenNotLoggedIn_Name, ref Mutable.HideWhenNotLoggedIn);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideWhenNotLoggedIn_Description, Plugin.PluginName));
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideWhenUiHidden_Name, ref Mutable.HideWhenUiHidden);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideWhenUiHidden_Description, Plugin.PluginName));
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideInLoadingScreens_Name, ref Mutable.HideInLoadingScreens);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideInLoadingScreens_Description, Plugin.PluginName));
|
||||
|
||||
ImGui.Checkbox(Language.Options_HideInBattle_Name, ref Mutable.HideInBattle);
|
||||
ImGuiUtil.HelpMarker(Language.Options_HideInBattle_Description);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawInactivityHideSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_InactivityHide_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_HideWhenInactive_Name, ref Mutable.HideWhenInactive);
|
||||
ImGuiUtil.HelpMarker(Language.Options_HideWhenInactive_Description);
|
||||
|
||||
if (!Mutable.HideWhenInactive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ImGuiUtil.InputIntVertical(Language.Options_InactivityHideTimeout_Name, Language.Options_InactivityHideTimeout_Description, ref Mutable.InactivityHideTimeout, 1, 10);
|
||||
// Untergrenze von 2 Sekunden gegen Selbst-Soft-Lock.
|
||||
Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout);
|
||||
|
||||
using (ImRaii.Disabled(Mutable.HideInBattle))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_InactivityHideActiveDuringBattle_Name, ref Mutable.InactivityHideActiveDuringBattle);
|
||||
ImGuiUtil.HelpMarker(Language.Options_InactivityHideActiveDuringBattle_Description);
|
||||
}
|
||||
|
||||
using var channelTree = ImRaii.TreeNode(Language.Options_InactivityHideChannels_Name);
|
||||
if (!channelTree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ImGuiUtil.CtrlShiftButton(Language.Options_InactivityHideChannels_All_Label, Language.Options_InactivityHideChannels_Button_Tooltip))
|
||||
{
|
||||
Mutable.InactivityHideChannelsV2 = TabsUtil.AllChannels();
|
||||
Mutable.InactivityHideExtraChatAll = true;
|
||||
Mutable.InactivityHideExtraChatChannels = [];
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGuiUtil.CtrlShiftButton(Language.Options_InactivityHideChannels_None_Label, Language.Options_InactivityHideChannels_Button_Tooltip))
|
||||
{
|
||||
Mutable.InactivityHideChannelsV2 = [];
|
||||
Mutable.InactivityHideExtraChatAll = false;
|
||||
Mutable.InactivityHideExtraChatChannels = [];
|
||||
}
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, Mutable.InactivityHideChannelsV2);
|
||||
ImGuiUtil.ExtraChatSelector(Language.Options_Tabs_ExtraChatChannels, ref Mutable.InactivityHideExtraChatAll, Mutable.InactivityHideExtraChatChannels);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawFrameSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Frame_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_CanMove_Name, ref Mutable.CanMove);
|
||||
|
||||
ImGui.Checkbox(Language.Options_CanResize_Name, ref Mutable.CanResize);
|
||||
|
||||
ImGui.Checkbox(Language.Options_ShowTitleBar_Name, ref Mutable.ShowTitleBar);
|
||||
|
||||
ImGui.Checkbox(Language.Options_ShowPopOutTitleBar_Name, ref Mutable.ShowPopOutTitleBar);
|
||||
|
||||
ImGui.Checkbox(Language.Options_ShowHideButton_Name, ref Mutable.ShowHideButton);
|
||||
ImGuiUtil.HelpMarker(Language.Options_ShowHideButton_Description);
|
||||
|
||||
ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTooltipsSection()
|
||||
{
|
||||
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Tooltips_Heading);
|
||||
if (!tree.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
|
||||
{
|
||||
ImGui.Checkbox(Language.Options_NativeItemTooltips_Name, ref Mutable.NativeItemTooltips);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_NativeItemTooltips_Description, Plugin.PluginName));
|
||||
|
||||
if (Mutable.NativeItemTooltips)
|
||||
{
|
||||
ImGuiUtil.DragFloatVertical(Language.Options_TooltipOffset_Name, Language.Options_TooltipOffset_Desc, ref Mutable.TooltipOffset, 1, 0f, 400f, $"{Mutable.TooltipOffset:N0}px", ImGuiSliderFlags.AlwaysClamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
internal static class ColourUtil {
|
||||
private static (byte r, byte g, byte b) RgbaToRgbComponents(uint rgba)
|
||||
{
|
||||
var r = (byte) ((rgba & 0xFF000000) >> 24);
|
||||
var g = (byte) ((rgba & 0xFF0000) >> 16);
|
||||
var b = (byte) ((rgba & 0xFF00) >> 8);
|
||||
return (r, g, b);
|
||||
}
|
||||
|
||||
internal static uint RgbaToAbgr(uint rgba) => BinaryPrimitives.ReverseEndianness(rgba);
|
||||
|
||||
internal static Vector3 RgbaToVector3(uint rgba)
|
||||
{
|
||||
var (r, g, b) = RgbaToRgbComponents(rgba);
|
||||
return new Vector3((float) r / 255, (float) g / 255, (float) b / 255);
|
||||
}
|
||||
|
||||
internal static uint Vector3ToRgba(Vector3 col)
|
||||
{
|
||||
return ComponentsToRgba(
|
||||
(byte) Math.Round(col.X * 255),
|
||||
(byte) Math.Round(col.Y * 255),
|
||||
(byte) Math.Round(col.Z * 255)
|
||||
);
|
||||
}
|
||||
|
||||
internal static uint Vector4ToAbgr(Vector4 col)
|
||||
{
|
||||
return RgbaToAbgr(ComponentsToRgba(
|
||||
(byte) Math.Round(col.X * 255),
|
||||
(byte) Math.Round(col.Y * 255),
|
||||
(byte) Math.Round(col.Z * 255),
|
||||
(byte) Math.Round(col.W * 255)
|
||||
));
|
||||
}
|
||||
|
||||
public static unsafe uint ArgbToRgba(uint x)
|
||||
{
|
||||
var buf = (byte*)&x;
|
||||
(buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
|
||||
return x;
|
||||
}
|
||||
|
||||
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF)
|
||||
=> alpha | (uint) (red << 24) | (uint) (green << 16) | (uint) (blue << 8);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
public static class MathUtil
|
||||
{
|
||||
public record Rectangle
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
public int Width;
|
||||
public int Height;
|
||||
|
||||
public int SizeX;
|
||||
public int SizeY;
|
||||
|
||||
public Rectangle(int x, int y, int width, int height)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Width = width;
|
||||
Height = height;
|
||||
|
||||
SizeX = X + Width;
|
||||
SizeY = Y + Height;
|
||||
}
|
||||
|
||||
public Rectangle(Vector2 pos, Vector2 size) : this((int) pos.X, (int) pos.Y, (int) size.X, (int) size.Y) { }
|
||||
|
||||
public override string ToString()
|
||||
=> $"X: {X} Y: {Y} Width: {Width} Height: {Height}";
|
||||
}
|
||||
|
||||
// From: https://stackoverflow.com/a/306379
|
||||
/// <summary>
|
||||
/// Checks if two rectangles overlap at any point.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns>True if overlapping</returns>
|
||||
public static bool HasOverlap(this Rectangle a, Rectangle b)
|
||||
{
|
||||
bool ValueInRange(int value, int min, int max)
|
||||
=> value > min && value < max;
|
||||
|
||||
var xOverlap = ValueInRange(a.X, b.X, b.X + b.Width) || ValueInRange(b.X, a.X, a.X + a.Width);
|
||||
var yOverlap = ValueInRange(a.Y, b.Y, b.Y + b.Height) || ValueInRange(b.Y, a.Y, a.Y + a.Height);
|
||||
|
||||
return xOverlap && yOverlap;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
public static class MemoryUtil
|
||||
{
|
||||
public static unsafe void PrintMemoryArea(nint address, int length)
|
||||
{
|
||||
var ptr = (byte*)address;
|
||||
var str = new StringBuilder("\n");
|
||||
for(var i = 0; i < length; i++)
|
||||
{
|
||||
str.Append($"{ptr![i]:X02}");
|
||||
|
||||
if (i == 0)
|
||||
continue;
|
||||
|
||||
if ((i+1) % 16 == 0)
|
||||
str.Append('\n');
|
||||
else if ((i+1) % 4 == 0)
|
||||
str.Append(' ');
|
||||
}
|
||||
|
||||
Plugin.Log.Information(str.ToString());
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
internal static class StringUtil
|
||||
{
|
||||
internal static byte[] ToTerminatedBytes(this string s)
|
||||
{
|
||||
var utf8 = Encoding.UTF8;
|
||||
var bytes = new byte[utf8.GetByteCount(s) + 1];
|
||||
utf8.GetBytes(s, 0, s.Length, bytes, 0);
|
||||
bytes[^1] = 0;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Taken from https://stackoverflow.com/a/4975942
|
||||
internal static string BytesToString(long byteCount)
|
||||
{
|
||||
string[] suf = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; // Longs run out around EB
|
||||
if (byteCount == 0)
|
||||
return "0" + suf[0];
|
||||
|
||||
var bytes = Math.Abs(byteCount);
|
||||
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
|
||||
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
|
||||
return (Math.Sign(byteCount) * num).ToString("N0") + suf[place];
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Resources;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
public static class TabsUtil
|
||||
{
|
||||
public static Dictionary<ChatType, (ChatSource, ChatSource)> AllChannels()
|
||||
{
|
||||
var channels = new Dictionary<ChatType, (ChatSource, ChatSource)>();
|
||||
foreach (var chatType in Enum.GetValues<ChatType>())
|
||||
channels[chatType] = (ChatSourceExt.All, ChatSourceExt.All);
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
// Hellion-tuned General preset. The pure player-talk catch-all plus
|
||||
// the active-gameplay event streams (loot, crafting, gathering, NPC
|
||||
// dialogue, party-finder pings). Pure technical noise (System, Error,
|
||||
// Login/Logout spam, retainer sales, alarms, sign messages) lives in
|
||||
// the dedicated System tab so it doesn't bury actual conversation.
|
||||
public static Tab VanillaGeneral => new()
|
||||
{
|
||||
Name = Language.Tabs_Presets_General,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
// Player chat
|
||||
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Yell] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Shout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
// Active-gameplay events
|
||||
[ChatType.NpcDialogue] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.LootNotice] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.LootRoll] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Crafting] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Gathering] = (ChatSource.LocalPlayer, ChatSource.LocalPlayer),
|
||||
[ChatType.PeriodicRecruitmentNotification] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
}
|
||||
};
|
||||
|
||||
public static Tab VanillaEvent => new()
|
||||
{
|
||||
Name = Language.Tabs_Presets_Event,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)> { [ChatType.NpcDialogue] = (ChatSourceExt.All, ChatSourceExt.All), },
|
||||
};
|
||||
|
||||
public static Tab VanillaTellExclusive => new()
|
||||
{
|
||||
Name = Language.Tabs_Presets_Tell,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
Channel = InputChannel.Tell,
|
||||
AllSenderMessages = true,
|
||||
};
|
||||
|
||||
// Hellion default-tab presets used by the v10 wipe migration. Names are
|
||||
// kept in HellionStrings (EN+DE) instead of Language.* so the upstream
|
||||
// resource files stay untouched. Channel selections cover the channels
|
||||
// a typical Eorzea raider uses without forcing the user to hand-tick
|
||||
// each box on first start.
|
||||
public static Tab HellionFreeCompany => new()
|
||||
{
|
||||
Name = HellionStrings.Tabs_Presets_FreeCompany,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompanyAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
Channel = InputChannel.FreeCompany,
|
||||
};
|
||||
|
||||
public static Tab HellionParty => new()
|
||||
{
|
||||
Name = HellionStrings.Tabs_Presets_Party,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeamAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.LootNotice] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.LootRoll] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
Channel = InputChannel.Party,
|
||||
};
|
||||
|
||||
public static Tab HellionBeginner => new()
|
||||
{
|
||||
Name = HellionStrings.Tabs_Presets_Beginner,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.NoviceNetwork] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
Channel = InputChannel.NoviceNetwork,
|
||||
};
|
||||
|
||||
public static Tab HellionSystem => new()
|
||||
{
|
||||
Name = HellionStrings.Tabs_Presets_System,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Debug] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Urgent] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Notice] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.System] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Error] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Echo] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.GatheringSystem] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.NpcAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.RetainerSale] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Progress] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.RandomNumber] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Orchestrion] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.MessageBook] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Alarm] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Sign] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.GlamourNotifications] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
|
||||
public static Tab HellionLinkshell => new()
|
||||
{
|
||||
Name = HellionStrings.Tabs_Presets_Linkshell,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
|
||||
public static Dictionary<ChatType, (ChatSource, ChatSource)> MostlyPlayer => new()
|
||||
{
|
||||
// Special
|
||||
[ChatType.Debug] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Urgent] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Notice] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
// Chat
|
||||
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Yell] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Shout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.NoviceNetwork] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.StandardEmote] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.CustomEmote] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
// Announcements
|
||||
[ChatType.System] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Error] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.Echo] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompanyAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeamAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.RandomNumber] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.MessageBook] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using ChatTwo.Resources;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
public static class WrapperUtil
|
||||
{
|
||||
public static void AddNotification(string content, NotificationType type, bool minimized = true)
|
||||
{
|
||||
Plugin.Notification.AddNotification(new Notification { Content = content, Type = type, Minimized = minimized });
|
||||
}
|
||||
|
||||
public static void TryOpenUri(Uri uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
Plugin.Log.Debug($"Opening URI {uri} in default browser");
|
||||
Dalamud.Utility.Util.OpenLink(uri.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error($"Error opening URI: {ex}");
|
||||
AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -1,109 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0-windows7.0": {
|
||||
"DalamudPackager": {
|
||||
"type": "Direct",
|
||||
"requested": "[15.0.0, )",
|
||||
"resolved": "15.0.0",
|
||||
"contentHash": "411vwC8/X8Z/sQ2TI6v3SvOn66xFPeOjFn3Zn+h0d3Ox2t1kFm66AhDvmx/qcMwVrR+Hidxj0dadpQ2dgyXMBQ=="
|
||||
},
|
||||
"DotNet.ReproducibleBuilds": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.2.39, )",
|
||||
"resolved": "1.2.39",
|
||||
"contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg=="
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.4, )",
|
||||
"resolved": "3.1.4",
|
||||
"contentHash": "BH0wlHWmVoZpbAPyyt2Awbq30C+ZsS3eHSkYdnyUAbqVJ22fAJDzn2xTieBeoT5QlcBzp61vHcv878YJGfi3mg==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "3.1.4",
|
||||
"MessagePackAnalyzer": "3.1.4",
|
||||
"Microsoft.NET.StringTools": "17.11.4"
|
||||
}
|
||||
},
|
||||
"Microsoft.Data.Sqlite": {
|
||||
"type": "Direct",
|
||||
"requested": "[9.0.0, )",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "lw6wthgXGx3r/U775k1UkUAWIn0kAT0wj4ZRq0WlhPx4WAOiBsIjgDKgWkXcNTGT0KfHiClkM+tyPVFDvxeObw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "9.0.0",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.10",
|
||||
"SQLitePCLRaw.core": "2.1.10"
|
||||
}
|
||||
},
|
||||
"morelinq": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.4.0, )",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg=="
|
||||
},
|
||||
"Pidgin": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.3.0, )",
|
||||
"resolved": "3.3.0",
|
||||
"contentHash": "2rvIoIogQG1+vqvXCuz1xiAVljaiacG/wCz/TNpN74TzWw+9iSCjhBLf7kVg24sBi6tArRdrcklHq49ovW2NLA=="
|
||||
},
|
||||
"SixLabors.ImageSharp": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.12, )",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.4",
|
||||
"contentHash": "aVWrDAkCdqxwQsz/q0ldPh2EFn48M99YUzE9OvZjMq2RNLKz4o2z88iGFvSvbMqOWRweRvKPHBJZe22PRqzslQ=="
|
||||
},
|
||||
"MessagePackAnalyzer": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.4",
|
||||
"contentHash": "CTaSsN/liJ7MhLCAB7Z4ZLBNuVGCq9lt2BT/cbrc9vzGv89yK3CqIA+z9T19a11eQYl9etZHL6MQJgCqECRVpg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "cFfZjFL+tqzGYw9lB31EkV1IWF5xRQNk2k+MQd+Cf86Gl6zTeAoiZIFw5sRB1Z8OxpEC7nu+nTDsLSjieBAPTw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.StringTools": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.11.4",
|
||||
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
"contentHash": "UxWuisvZ3uVcVOLJQv7urM/JiQH+v3TmaJc1BLKl5Dxfm/nTzTUrqswCqg/INiYLi61AXnHo1M1JPmPqqLnAdg==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "2.1.10",
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "2.1.10"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
"contentHash": "Ii8JCbC7oiVclaE/mbDEK000EFIJ+ShRPwAvvV89GOZhQ+ZLtlnSWl6ksCNMKu/VGXA4Nfi2B7LhN/QFN9oBcw=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
"contentHash": "mAr69tDbnf3QJpRy2nJz8Qdpebdil00fvycyByR58Cn9eARvR+UiG2Vzsp+4q1tV3ikwiYIjlXCQFc12GfebbA=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
"contentHash": "uZVTi02C1SxqzgT0HqTWatIbWGb40iIkfc3FpFCpE/r7g6K0PqzDUeefL6P6HPhDtc6BacN3yQysfzP7ks+wSQ==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HellionChat", "HellionChat\HellionChat.csproj", "{739F75E6-B65F-41EF-9D90-F7BC519E4875}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x64.Build.0 = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,517 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner.
|
||||
// Subscribes to MessageManager.MessageProcessed and ClientState.Logout.
|
||||
internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
private readonly MessageManager _messageManager;
|
||||
private readonly MessageStore _store;
|
||||
private readonly object _tempTabsLock = new();
|
||||
|
||||
// Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
|
||||
// of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
|
||||
// in their own bucket. A configurable cap is a vault-backlog anchor for
|
||||
// a later cycle if tester feedback demands it.
|
||||
internal const int MaxPinnedTempTabs = 5;
|
||||
|
||||
private bool _initialized;
|
||||
|
||||
internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store)
|
||||
{
|
||||
_plugin = plugin;
|
||||
_messageManager = messageManager;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
// Derived from the tab list on read. Pin/Unpin/Promote/Logout simply
|
||||
// mutate IsPinned or remove tabs — the count adapts automatically.
|
||||
// Replaces the F2.1 Interlocked counter because the new pin-state
|
||||
// transitions are cold-path and don't need lock-free reads.
|
||||
internal int ActiveTempTabCount =>
|
||||
Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool);
|
||||
|
||||
internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
|
||||
|
||||
internal void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pinned tabs come out of the JSON with TellTarget set but
|
||||
// CurrentChannel reset (NonSerialized). Without re-seeding, the chat
|
||||
// input has no tell-target on the active pinned tab, and the
|
||||
// game-side channel hook only repaints CurrentChannel once the user
|
||||
// triggers a /tell or channel switch.
|
||||
RehydratePinnedTabs();
|
||||
|
||||
_messageManager.MessageProcessed += HandleTell;
|
||||
Plugin.ClientState.Logout += OnLogout;
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
private void RehydratePinnedTabs()
|
||||
{
|
||||
var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
|
||||
Plugin.LogProxy.Debug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found");
|
||||
|
||||
foreach (var tab in Plugin.Config.Tabs)
|
||||
{
|
||||
if (!TabLifecycleHelpers.IsInPinnedPool(tab))
|
||||
continue;
|
||||
|
||||
if (tab.TellTarget is null || !tab.TellTarget.IsSet())
|
||||
{
|
||||
Plugin.LogProxy.Warning(
|
||||
$"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget "
|
||||
+ $"(Name={tab.TellTarget?.Name ?? "<null>"} World={tab.TellTarget?.World ?? 0}). "
|
||||
+ "Chat input on this tab will be empty until the partner sends a tell or you /tell manually."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tab.Channel ??= InputChannel.Tell;
|
||||
tab.CurrentChannel.Channel = InputChannel.Tell;
|
||||
tab.CurrentChannel.TellTarget = tab.TellTarget.Clone();
|
||||
|
||||
// MessageList is NonSerialized so pinned tabs come back empty.
|
||||
// Preload the same history window the spawn path uses so the user
|
||||
// sees the recent conversation, not a blank tab.
|
||||
PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty);
|
||||
|
||||
Plugin.LogProxy.Debug(
|
||||
$"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Plugin.ClientState.Logout -= OnLogout;
|
||||
_messageManager.MessageProcessed -= HandleTell;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
internal void HandleTell(Message message)
|
||||
{
|
||||
if (!Plugin.Config.EnableAutoTellTabs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
message.Code.Type != ChatType.TellIncoming
|
||||
&& message.Code.Type != ChatType.TellOutgoing
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var partner = ExtractTellPartner(message);
|
||||
if (partner == null)
|
||||
{
|
||||
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
|
||||
Plugin.LogProxy.Warning(
|
||||
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
|
||||
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
|
||||
+ $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, "
|
||||
+ $"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||
if (existing != null)
|
||||
{
|
||||
// Already routed via MessageManager pipeline. Repair the
|
||||
// tell-target if the fallback hit a pinned tab whose
|
||||
// TellTarget didn't survive a previous round-trip — keeps
|
||||
// FindTempTab fast on the next message.
|
||||
if (
|
||||
existing.IsPinned
|
||||
&& (existing.TellTarget is null || !existing.TellTarget.IsSet())
|
||||
)
|
||||
{
|
||||
existing.TellTarget = new TellTarget(
|
||||
partner.Value.Name,
|
||||
partner.Value.World,
|
||||
0,
|
||||
TellReason.Direct
|
||||
);
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
||||
{
|
||||
DropOldestTempTab();
|
||||
}
|
||||
|
||||
SpawnTempTab(partner.Value, message);
|
||||
}
|
||||
}
|
||||
|
||||
private (string Name, uint World)? ExtractTellPartner(Message message)
|
||||
{
|
||||
if (message.Code.Type == ChatType.TellIncoming)
|
||||
{
|
||||
// Sender is the partner; check chunks first, then raw SeString as fallback
|
||||
var fromSender =
|
||||
ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
if (fromSender != null)
|
||||
{
|
||||
return (fromSender.PlayerName, fromSender.World.RowId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Outgoing tell: check content first, then channels's TellTarget as fallback
|
||||
var fromContent =
|
||||
ChunkUtil.TryGetPlayerPayload(message.Content)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
if (fromContent != null)
|
||||
{
|
||||
return (fromContent.PlayerName, fromContent.World.RowId);
|
||||
}
|
||||
|
||||
var current =
|
||||
_plugin.CurrentTab.CurrentChannel.TellTarget
|
||||
?? _plugin.CurrentTab.CurrentChannel.TempTellTarget;
|
||||
if (current != null && current.IsSet())
|
||||
{
|
||||
return (current.Name, current.World);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Tab? FindTempTab(string name, uint world)
|
||||
{
|
||||
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||
t.IsTempTab
|
||||
&& t.TellTarget != null
|
||||
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
|
||||
&& t.TellTarget.World == world
|
||||
);
|
||||
if (byTarget != null)
|
||||
return byTarget;
|
||||
|
||||
// Fallback: match by tab name. Pinned tabs are named via
|
||||
// FormatTabName(player, world) at spawn time, so the name is a
|
||||
// stable secondary key when TellTarget didn't survive a save/load
|
||||
// (older configs from a renamed pin, malformed migrations, etc.).
|
||||
var expectedName = FormatTabName(name, world);
|
||||
return Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||
t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
}
|
||||
|
||||
internal void DropOldestTempTab()
|
||||
{
|
||||
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
||||
// never drop candidates. They leave the bucket only via Unpin or
|
||||
// PromoteToPermanent.
|
||||
var victim = Plugin
|
||||
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
|
||||
.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab))
|
||||
.OrderByDescending(t => t.Tab.IsGreeted)
|
||||
.ThenBy(t => t.Tab.LastActivity)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (victim.Tab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up pop-out window if tab is popped out
|
||||
if (victim.Tab.PopOut)
|
||||
{
|
||||
var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p =>
|
||||
p.TabIdentifier == victim.Tab.Identifier
|
||||
);
|
||||
if (popout != null)
|
||||
{
|
||||
popout.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||
|
||||
// Re-anchor active tab to avoid silent switch when tab is dropped
|
||||
if (victim.Index <= _plugin.LastTab)
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||
{
|
||||
var tab = BuildTempTab(partner.Name, partner.World);
|
||||
|
||||
// Preload history: chronological order with current message already persisted
|
||||
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
|
||||
|
||||
tab.AddMessage(currentMessage, unread: true);
|
||||
|
||||
// Open as pop-out if configured (set before Tabs.Add for next render-tick)
|
||||
if (Plugin.Config.AutoTellTabsOpenAsPopout)
|
||||
{
|
||||
tab.PopOut = true;
|
||||
}
|
||||
|
||||
Plugin.Config.Tabs.Add(tab);
|
||||
}
|
||||
|
||||
private static Tab BuildTempTab(string playerName, uint worldRowId)
|
||||
{
|
||||
return new Tab
|
||||
{
|
||||
Name = FormatTabName(playerName, worldRowId),
|
||||
IsTempTab = true,
|
||||
AllSenderMessages = true,
|
||||
TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct),
|
||||
Channel = InputChannel.Tell,
|
||||
DisplayTimestamp = true,
|
||||
UnreadMode = UnreadMode.Unseen,
|
||||
HideWhenInactive = false,
|
||||
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
|
||||
{
|
||||
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTabName(string playerName, uint worldRowId)
|
||||
{
|
||||
if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow))
|
||||
{
|
||||
return $"{playerName}@{worldRow.Name}";
|
||||
}
|
||||
// Fallback if world lookup misses (rare; only for unseen worlds)
|
||||
return $"{playerName}@World{worldRowId}";
|
||||
}
|
||||
|
||||
private void PreloadHistory(Tab tab, string senderName, uint senderWorld, Guid currentMessageId)
|
||||
{
|
||||
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
|
||||
if (preloadCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Pull one extra row: current message is already in store and would eat a preload slot
|
||||
var history = _store.GetTellHistoryWithSender(
|
||||
_messageManager.CurrentContentId,
|
||||
senderName,
|
||||
senderWorld,
|
||||
preloadCount + 1
|
||||
);
|
||||
|
||||
var historicMessages = history
|
||||
.Where(m => m.Id != currentMessageId)
|
||||
.Take(preloadCount)
|
||||
.ToList();
|
||||
|
||||
if (historicMessages.Count == 0)
|
||||
{
|
||||
// No prior tells; leave tab empty to avoid orphaned "history loaded" marker
|
||||
return;
|
||||
}
|
||||
|
||||
// History is oldest-first; add in order for chronological display
|
||||
foreach (var message in historicMessages)
|
||||
{
|
||||
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
||||
}
|
||||
|
||||
// Separator between history and live tell (sorts after history but before current)
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
|
||||
MessageManager.MessageDisplayLimit
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
|
||||
Plugin.LogProxy.Error(ex, "[AutoTellTabs] History preload failed");
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
|
||||
MessageManager.MessageDisplayLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static Message MakeSystemMarker(string text)
|
||||
{
|
||||
var seString = new SeStringBuilder().AddText(text).Build();
|
||||
var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList();
|
||||
var code = new ChatCode((XivChatType)ChatType.System, 0, 0);
|
||||
return Message.FakeMessage(chunks, code);
|
||||
}
|
||||
|
||||
internal void MarkGreeted(Tab tab)
|
||||
{
|
||||
SetGreeted(tab, true);
|
||||
}
|
||||
|
||||
internal void UnmarkGreeted(Tab tab)
|
||||
{
|
||||
SetGreeted(tab, false);
|
||||
}
|
||||
|
||||
internal bool IsGreeted(Tab tab)
|
||||
{
|
||||
return tab.IsGreeted;
|
||||
}
|
||||
|
||||
private void SetGreeted(Tab tab, bool greeted)
|
||||
{
|
||||
if (tab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
||||
if (!Plugin.Config.Tabs.Contains(tab))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tab.IsGreeted = greeted;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLogout(int type, int code)
|
||||
{
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Pinned TempTabs must survive char-switch — that's the whole point
|
||||
// of pinning. Only unpinned ones get stripped.
|
||||
var lastIndex = _plugin.LastTab;
|
||||
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
var currentWasUnpinnedTempTab =
|
||||
lastIndexValid
|
||||
&& TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]);
|
||||
|
||||
var poppedTempTabIds = Plugin
|
||||
.Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
|
||||
.Select(t => t.Identifier)
|
||||
.ToList();
|
||||
if (poppedTempTabIds.Count > 0)
|
||||
{
|
||||
var poppedSet = poppedTempTabIds.ToHashSet();
|
||||
foreach (
|
||||
var popout in _plugin
|
||||
.ChatLogWindow.ActivePopouts.Where(p => poppedSet.Contains(p.TabIdentifier))
|
||||
.ToList()
|
||||
)
|
||||
{
|
||||
popout.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
||||
|
||||
// Force switch to tab 0 if active tab was an unpinned temp tab or
|
||||
// index is now out of range. Pinned tabs survive — no switch needed.
|
||||
var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
if (currentWasUnpinnedTempTab || !stillValid)
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryPin(Tab tab)
|
||||
{
|
||||
if (!tab.IsTempTab || tab.IsPinned)
|
||||
{
|
||||
Plugin.LogProxy.Debug(
|
||||
$"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PinnedTempTabCount >= MaxPinnedTempTabs)
|
||||
{
|
||||
WrapperUtil.AddNotification(
|
||||
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
|
||||
NotificationType.Warning
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
tab.IsPinned = true;
|
||||
Plugin.LogProxy.Debug(
|
||||
$"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
|
||||
);
|
||||
_plugin.SaveConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void Unpin(Tab tab)
|
||||
{
|
||||
if (!tab.IsPinned)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If the unpinned pool is already full, dropping the oldest before
|
||||
// flipping the flag avoids counting the just-unpinned tab as a drop
|
||||
// candidate.
|
||||
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
|
||||
{
|
||||
DropOldestTempTab();
|
||||
}
|
||||
|
||||
tab.IsPinned = false;
|
||||
Plugin.LogProxy.Debug("[Pin] Unpinned tab '{tab.Name}'");
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
|
||||
internal void PromoteToPermanent(Tab tab)
|
||||
{
|
||||
if (!tab.IsTempTab)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tab.IsTempTab = false;
|
||||
tab.IsPinned = false;
|
||||
tab.TellTarget = TellTarget.Empty();
|
||||
Plugin.LogProxy.Debug(
|
||||
$"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"
|
||||
);
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Branding;
|
||||
|
||||
// Centralised — a future invite/URL rotation only touches this file.
|
||||
internal static class BrandingLinks
|
||||
{
|
||||
public const string HellionForgeDiscordInvite = "https://discord.gg/X9V7Kcv5gR";
|
||||
public const string HellionForgeGitea = "https://gitea.hellion-forge.cloud/Hellion-Forge";
|
||||
public const string HellionChatRepo =
|
||||
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat";
|
||||
public const string HellionForgeWebsite = "https://hellion-forge.cloud";
|
||||
public const string HellionMediaWebsite = "https://hellion-media.de/de";
|
||||
|
||||
// CA2255 warns against [ModuleInitializer] in library code, but Dalamud
|
||||
// loads the plugin DLL directly so the module-init pass is the right hook
|
||||
// for a one-shot URL sanity check at plugin load.
|
||||
#pragma warning disable CA2255
|
||||
[ModuleInitializer]
|
||||
#pragma warning restore CA2255
|
||||
internal static void ValidateUrls()
|
||||
{
|
||||
UrlValidation.ValidateAll(
|
||||
nameof(BrandingLinks),
|
||||
HellionForgeDiscordInvite,
|
||||
HellionForgeGitea,
|
||||
HellionChatRepo,
|
||||
HellionForgeWebsite,
|
||||
HellionMediaWebsite
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Linq;
|
||||
using Dalamud.Plugin;
|
||||
using HellionChat.Resources;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
internal static class ChatTwoConflictDetector
|
||||
{
|
||||
private const string UpstreamInternalName = "ChatTwo";
|
||||
|
||||
public static void ThrowIfChatTwoIsLoaded(IDalamudPluginInterface pluginInterface)
|
||||
{
|
||||
var conflict = pluginInterface.InstalledPlugins.FirstOrDefault(p =>
|
||||
p.InternalName == UpstreamInternalName && p.IsLoaded
|
||||
);
|
||||
|
||||
if (conflict is null)
|
||||
return;
|
||||
|
||||
var message =
|
||||
HellionStrings.ChatTwoConflictTitle
|
||||
+ "\n\n"
|
||||
+ HellionStrings.ChatTwoConflictBody
|
||||
+ "\n\n"
|
||||
+ HellionStrings.ChatTwoConflictAction;
|
||||
|
||||
throw new System.InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using ChatTwo.Code;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using HellionChat.Code;
|
||||
using MessagePack;
|
||||
|
||||
namespace ChatTwo;
|
||||
namespace HellionChat;
|
||||
|
||||
[Union(0, typeof(TextChunk))]
|
||||
[Union(1, typeof(IconChunk))]
|
||||
@@ -25,24 +25,23 @@ public abstract class Chunk
|
||||
Link = link;
|
||||
}
|
||||
|
||||
internal SeString? GetSeString() => Source switch
|
||||
{
|
||||
ChunkSource.None => null,
|
||||
ChunkSource.Sender => Message?.SenderSource,
|
||||
ChunkSource.Content => Message?.ContentSource,
|
||||
_ => null,
|
||||
};
|
||||
internal SeString? GetSeString() =>
|
||||
Source switch
|
||||
{
|
||||
ChunkSource.None => null,
|
||||
ChunkSource.Sender => Message?.SenderSource,
|
||||
ChunkSource.Content => Message?.ContentSource,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get some basic text for use in generating hashes.
|
||||
/// </summary>
|
||||
// Returns basic text for hashing (content for TextChunk, icon name for IconChunk)
|
||||
internal string StringValue()
|
||||
{
|
||||
return this switch
|
||||
{
|
||||
TextChunk text => text.Content,
|
||||
IconChunk icon => icon.Icon.ToString(),
|
||||
_ => ""
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -57,18 +56,29 @@ public enum ChunkSource
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public class TextChunk : Chunk
|
||||
{
|
||||
[Key(2)] public ChatType? FallbackColour;
|
||||
[Key(3)] public uint? Foreground;
|
||||
[Key(4)] public uint? Glow;
|
||||
[Key(5)] public bool Italic;
|
||||
[Key(6)] public string Content;
|
||||
[Key(2)]
|
||||
public ChatType? FallbackColour;
|
||||
|
||||
private TextChunk(Chunk chunk, string content) : base(chunk.Source, chunk.Link)
|
||||
[Key(3)]
|
||||
public uint? Foreground;
|
||||
|
||||
[Key(4)]
|
||||
public uint? Glow;
|
||||
|
||||
[Key(5)]
|
||||
public bool Italic;
|
||||
|
||||
[Key(6)]
|
||||
public string Content;
|
||||
|
||||
private TextChunk(Chunk chunk, string content)
|
||||
: base(chunk.Source, chunk.Link)
|
||||
{
|
||||
Content = content;
|
||||
}
|
||||
|
||||
internal TextChunk(ChunkSource source, Payload? link, string content) : base(source, link)
|
||||
internal TextChunk(ChunkSource source, Payload? link, string content)
|
||||
: base(source, link)
|
||||
{
|
||||
// This has been null in the past, and it broke rendering code.
|
||||
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
|
||||
@@ -76,7 +86,16 @@ public class TextChunk : Chunk
|
||||
}
|
||||
|
||||
// ReSharper disable once UnusedMember.Global // Used by MessagePack
|
||||
public TextChunk(ChunkSource source, Payload? link, ChatType? fallbackColour, uint? foreground, uint? glow, bool italic, string content) : base(source, link)
|
||||
public TextChunk(
|
||||
ChunkSource source,
|
||||
Payload? link,
|
||||
ChatType? fallbackColour,
|
||||
uint? foreground,
|
||||
uint? glow,
|
||||
bool italic,
|
||||
string content
|
||||
)
|
||||
: base(source, link)
|
||||
{
|
||||
FallbackColour = fallbackColour;
|
||||
Foreground = foreground;
|
||||
@@ -87,9 +106,6 @@ public class TextChunk : Chunk
|
||||
Content = content ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TextChunk with identical styling to this one.
|
||||
/// </summary>
|
||||
public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content)
|
||||
{
|
||||
return new TextChunk(source, link, content)
|
||||
@@ -101,9 +117,6 @@ public class TextChunk : Chunk
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TextChunk with identical styling to this one.
|
||||
/// </summary>
|
||||
public TextChunk NewWithStyle(Chunk chunk, string content)
|
||||
{
|
||||
return new TextChunk(chunk, content)
|
||||
@@ -122,7 +135,8 @@ public class IconChunk : Chunk
|
||||
[Key(2)]
|
||||
public BitmapFontIcon Icon { get; set; }
|
||||
|
||||
public IconChunk(ChunkSource source, Payload? link, BitmapFontIcon icon) : base(source, link)
|
||||
public IconChunk(ChunkSource source, Payload? link, BitmapFontIcon icon)
|
||||
: base(source, link)
|
||||
{
|
||||
Icon = icon;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Dalamud.Game.Text;
|
||||
|
||||
namespace ChatTwo.Code;
|
||||
namespace HellionChat.Code;
|
||||
|
||||
public class ChatCode
|
||||
{
|
||||
@@ -16,7 +16,7 @@ public class ChatCode
|
||||
}
|
||||
|
||||
public ChatCode(byte type, byte source, byte target)
|
||||
: this((XivChatType)type, (XivChatRelationKind)source, (XivChatRelationKind)target) {}
|
||||
: this((XivChatType)type, (XivChatRelationKind)source, (XivChatRelationKind)target) { }
|
||||
|
||||
public bool IsBattle()
|
||||
{
|
||||
@@ -91,13 +91,10 @@ public class ChatCode
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj == null)
|
||||
return false;
|
||||
|
||||
if (obj is not ChatCode code)
|
||||
return false;
|
||||
|
||||
return GetHashCode() == code.GetHashCode();
|
||||
return Type == code.Type && Source == code.Source && Target == code.Target;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
using Dalamud.Game.Text;
|
||||
|
||||
namespace HellionChat.Code;
|
||||
|
||||
[Flags]
|
||||
public enum ChatSource : ushort
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// The player controlled by this client
|
||||
LocalPlayer = 1 << XivChatRelationKind.LocalPlayer,
|
||||
|
||||
// Member of the local party
|
||||
PartyMember = 1 << XivChatRelationKind.PartyMember,
|
||||
|
||||
// Member of the alliance
|
||||
AllianceMember = 1 << XivChatRelationKind.AllianceMember,
|
||||
|
||||
// Other player
|
||||
OtherPlayer = 1 << XivChatRelationKind.OtherPlayer,
|
||||
|
||||
// Enemy in combat
|
||||
EngagedEnemy = 1 << XivChatRelationKind.EngagedEnemy,
|
||||
|
||||
// Enemy out of combat
|
||||
UnengagedEnemy = 1 << XivChatRelationKind.UnengagedEnemy,
|
||||
|
||||
// Friendly NPC
|
||||
FriendlyNpc = 1 << XivChatRelationKind.FriendlyNpc,
|
||||
|
||||
// Own pet or companion
|
||||
PetOrCompanion = 1 << XivChatRelationKind.PetOrCompanion,
|
||||
|
||||
// Pet or companion of party members
|
||||
PetOrCompanionParty = 1 << XivChatRelationKind.PetOrCompanionParty,
|
||||
|
||||
// Pet or companion of alliance members
|
||||
PetOrCompanionAlliance = 1 << XivChatRelationKind.PetOrCompanionAlliance,
|
||||
|
||||
// Pet or companion of other players
|
||||
PetOrCompanionOther = 1 << XivChatRelationKind.PetOrCompanionOther,
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
using HellionChat.Resources;
|
||||
|
||||
namespace HellionChat.Code;
|
||||
|
||||
internal static class ChatSourceExt
|
||||
{
|
||||
internal const ChatSource All =
|
||||
ChatSource.LocalPlayer
|
||||
| ChatSource.PartyMember
|
||||
| ChatSource.AllianceMember
|
||||
| ChatSource.OtherPlayer
|
||||
| ChatSource.EngagedEnemy
|
||||
| ChatSource.UnengagedEnemy
|
||||
| ChatSource.FriendlyNpc
|
||||
| ChatSource.PetOrCompanion
|
||||
| ChatSource.PetOrCompanionParty
|
||||
| ChatSource.PetOrCompanionAlliance
|
||||
| ChatSource.PetOrCompanionOther;
|
||||
|
||||
internal static string Name(this ChatSource source) =>
|
||||
source switch
|
||||
{
|
||||
ChatSource.LocalPlayer => Language.ChatSource_Self,
|
||||
ChatSource.PartyMember => Language.ChatSource_PartyMember,
|
||||
ChatSource.AllianceMember => Language.ChatSource_AllianceMember,
|
||||
ChatSource.OtherPlayer => Language.ChatSource_Other,
|
||||
ChatSource.EngagedEnemy => Language.ChatSource_EngagedEnemy,
|
||||
ChatSource.UnengagedEnemy => Language.ChatSource_UnengagedEnemy,
|
||||
ChatSource.FriendlyNpc => Language.ChatSource_FriendlyNpc,
|
||||
ChatSource.PetOrCompanion => Language.ChatSource_SelfPet,
|
||||
ChatSource.PetOrCompanionParty => Language.ChatSource_PartyPet,
|
||||
ChatSource.PetOrCompanionAlliance => Language.ChatSource_AlliancePet,
|
||||
ChatSource.PetOrCompanionOther => Language.ChatSource_OtherPet,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), source, null),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ChatTwo.Code;
|
||||
namespace HellionChat.Code;
|
||||
|
||||
public enum ChatType : ushort
|
||||
{
|
||||
Executable
+499
@@ -0,0 +1,499 @@
|
||||
using Dalamud.Game.Config;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Code;
|
||||
|
||||
internal static class ChatTypeExt
|
||||
{
|
||||
internal static IEnumerable<(string, ChatType[])> SortOrder =>
|
||||
[
|
||||
(
|
||||
Language.Options_Tabs_ChannelTypes_Special,
|
||||
[ChatType.Debug, ChatType.Urgent, ChatType.Notice]
|
||||
),
|
||||
(
|
||||
Language.Options_Tabs_ChannelTypes_Chat,
|
||||
[
|
||||
ChatType.Say,
|
||||
ChatType.Yell,
|
||||
ChatType.Shout,
|
||||
ChatType.TellIncoming,
|
||||
ChatType.TellOutgoing,
|
||||
ChatType.Party,
|
||||
ChatType.CrossParty,
|
||||
ChatType.Alliance,
|
||||
ChatType.FreeCompany,
|
||||
ChatType.PvpTeam,
|
||||
ChatType.CrossLinkshell1,
|
||||
ChatType.CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8,
|
||||
ChatType.Linkshell1,
|
||||
ChatType.Linkshell2,
|
||||
ChatType.Linkshell3,
|
||||
ChatType.Linkshell4,
|
||||
ChatType.Linkshell5,
|
||||
ChatType.Linkshell6,
|
||||
ChatType.Linkshell7,
|
||||
ChatType.Linkshell8,
|
||||
ChatType.NoviceNetwork,
|
||||
ChatType.StandardEmote,
|
||||
ChatType.CustomEmote,
|
||||
]
|
||||
),
|
||||
(
|
||||
Language.Options_Tabs_ChannelTypes_Battle,
|
||||
[
|
||||
ChatType.Damage,
|
||||
ChatType.Miss,
|
||||
ChatType.Action,
|
||||
ChatType.Item,
|
||||
ChatType.Healing,
|
||||
ChatType.GainBuff,
|
||||
ChatType.LoseBuff,
|
||||
ChatType.GainDebuff,
|
||||
ChatType.LoseDebuff,
|
||||
]
|
||||
),
|
||||
(
|
||||
Language.Options_Tabs_ChannelTypes_Announcements,
|
||||
[
|
||||
ChatType.System,
|
||||
ChatType.BattleSystem,
|
||||
ChatType.GatheringSystem,
|
||||
ChatType.Error,
|
||||
ChatType.Echo,
|
||||
ChatType.NoviceNetworkSystem,
|
||||
ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.PvpTeamAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout,
|
||||
ChatType.PvpTeamLoginLogout,
|
||||
ChatType.RetainerSale,
|
||||
ChatType.NpcDialogue,
|
||||
ChatType.NpcAnnouncement,
|
||||
ChatType.LootNotice,
|
||||
ChatType.Progress,
|
||||
ChatType.LootRoll,
|
||||
ChatType.Crafting,
|
||||
ChatType.Gathering,
|
||||
ChatType.PeriodicRecruitmentNotification,
|
||||
ChatType.Sign,
|
||||
ChatType.RandomNumber,
|
||||
ChatType.Orchestrion,
|
||||
ChatType.MessageBook,
|
||||
ChatType.Alarm,
|
||||
ChatType.GlamourNotifications,
|
||||
]
|
||||
),
|
||||
// Note: ExtraChat linkshells are handled separately in the tab settings
|
||||
// UI.
|
||||
];
|
||||
|
||||
internal static string Name(this ChatType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ChatType.Debug => Language.ChatType_Debug,
|
||||
ChatType.Urgent => Language.ChatType_Urgent,
|
||||
ChatType.Notice => Language.ChatType_Notice,
|
||||
ChatType.Say => Language.ChatType_Say,
|
||||
ChatType.Shout => Language.ChatType_Shout,
|
||||
ChatType.TellOutgoing => Language.ChatType_TellOutgoing,
|
||||
ChatType.TellIncoming => Language.ChatType_TellIncoming,
|
||||
ChatType.Party => Language.ChatType_Party,
|
||||
ChatType.Alliance => Language.ChatType_Alliance,
|
||||
ChatType.Linkshell1 => Language.ChatType_Linkshell1,
|
||||
ChatType.Linkshell2 => Language.ChatType_Linkshell2,
|
||||
ChatType.Linkshell3 => Language.ChatType_Linkshell3,
|
||||
ChatType.Linkshell4 => Language.ChatType_Linkshell4,
|
||||
ChatType.Linkshell5 => Language.ChatType_Linkshell5,
|
||||
ChatType.Linkshell6 => Language.ChatType_Linkshell6,
|
||||
ChatType.Linkshell7 => Language.ChatType_Linkshell7,
|
||||
ChatType.Linkshell8 => Language.ChatType_Linkshell8,
|
||||
ChatType.FreeCompany => Language.ChatType_FreeCompany,
|
||||
ChatType.NoviceNetwork => Language.ChatType_NoviceNetwork,
|
||||
ChatType.CustomEmote => Language.ChatType_CustomEmotes,
|
||||
ChatType.StandardEmote => Language.ChatType_StandardEmotes,
|
||||
ChatType.Yell => Language.ChatType_Yell,
|
||||
ChatType.CrossParty => Language.ChatType_CrossWorldParty,
|
||||
ChatType.PvpTeam => Language.ChatType_PvpTeam,
|
||||
ChatType.CrossLinkshell1 => Language.ChatType_CrossLinkshell1,
|
||||
ChatType.Damage => Language.ChatType_Damage,
|
||||
ChatType.Miss => Language.ChatType_Miss,
|
||||
ChatType.Action => Language.ChatType_Action,
|
||||
ChatType.Item => Language.ChatType_Item,
|
||||
ChatType.Healing => Language.ChatType_Healing,
|
||||
ChatType.GainBuff => Language.ChatType_GainBuff,
|
||||
ChatType.GainDebuff => Language.ChatType_GainDebuff,
|
||||
ChatType.LoseBuff => Language.ChatType_LoseBuff,
|
||||
ChatType.LoseDebuff => Language.ChatType_LoseDebuff,
|
||||
ChatType.Alarm => Language.ChatType_Alarm,
|
||||
ChatType.GlamourNotifications => Language.ChatType_Glamour,
|
||||
ChatType.Echo => Language.ChatType_Echo,
|
||||
ChatType.System => Language.ChatType_System,
|
||||
ChatType.BattleSystem => Language.ChatType_BattleSystem,
|
||||
ChatType.GatheringSystem => Language.ChatType_GatheringSystem,
|
||||
ChatType.Error => Language.ChatType_Error,
|
||||
ChatType.NpcDialogue => Language.ChatType_NpcDialogue,
|
||||
ChatType.LootNotice => Language.ChatType_LootNotice,
|
||||
ChatType.Progress => Language.ChatType_Progress,
|
||||
ChatType.LootRoll => Language.ChatType_LootRoll,
|
||||
ChatType.Crafting => Language.ChatType_Crafting,
|
||||
ChatType.Gathering => Language.ChatType_Gathering,
|
||||
ChatType.NpcAnnouncement => Language.ChatType_NpcAnnouncement,
|
||||
ChatType.FreeCompanyAnnouncement => Language.ChatType_FreeCompanyAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout => Language.ChatType_FreeCompanyLoginLogout,
|
||||
ChatType.RetainerSale => Language.ChatType_RetainerSale,
|
||||
ChatType.PeriodicRecruitmentNotification =>
|
||||
Language.ChatType_PeriodicRecruitmentNotification,
|
||||
ChatType.Sign => Language.ChatType_Sign,
|
||||
ChatType.RandomNumber => Language.ChatType_RandomNumber,
|
||||
ChatType.NoviceNetworkSystem => Language.ChatType_NoviceNetworkSystem,
|
||||
ChatType.Orchestrion => Language.ChatType_Orchestrion,
|
||||
ChatType.PvpTeamAnnouncement => Language.ChatType_PvpTeamAnnouncement,
|
||||
ChatType.PvpTeamLoginLogout => Language.ChatType_PvpTeamLoginLogout,
|
||||
ChatType.MessageBook => Language.ChatType_MessageBook,
|
||||
ChatType.GmTell => Language.ChatType_GmTell,
|
||||
ChatType.GmSay => Language.ChatType_GmSay,
|
||||
ChatType.GmShout => Language.ChatType_GmShout,
|
||||
ChatType.GmYell => Language.ChatType_GmYell,
|
||||
ChatType.GmParty => Language.ChatType_GmParty,
|
||||
ChatType.GmFreeCompany => Language.ChatType_GmFreeCompany,
|
||||
ChatType.GmLinkshell1 => Language.ChatType_GmLinkshell1,
|
||||
ChatType.GmLinkshell2 => Language.ChatType_GmLinkshell2,
|
||||
ChatType.GmLinkshell3 => Language.ChatType_GmLinkshell3,
|
||||
ChatType.GmLinkshell4 => Language.ChatType_GmLinkshell4,
|
||||
ChatType.GmLinkshell5 => Language.ChatType_GmLinkshell5,
|
||||
ChatType.GmLinkshell6 => Language.ChatType_GmLinkshell6,
|
||||
ChatType.GmLinkshell7 => Language.ChatType_GmLinkshell7,
|
||||
ChatType.GmLinkshell8 => Language.ChatType_GmLinkshell8,
|
||||
ChatType.GmNoviceNetwork => Language.ChatType_GmNoviceNetwork,
|
||||
ChatType.CrossLinkshell2 => Language.ChatType_CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3 => Language.ChatType_CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4 => Language.ChatType_CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5 => Language.ChatType_CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6 => Language.ChatType_CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7 => Language.ChatType_CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8 => Language.ChatType_CrossLinkshell8,
|
||||
ChatType.ExtraChatLinkshell1 => Language.ChatType_ExtraChatLinkshell1,
|
||||
ChatType.ExtraChatLinkshell2 => Language.ChatType_ExtraChatLinkshell2,
|
||||
ChatType.ExtraChatLinkshell3 => Language.ChatType_ExtraChatLinkshell3,
|
||||
ChatType.ExtraChatLinkshell4 => Language.ChatType_ExtraChatLinkshell4,
|
||||
ChatType.ExtraChatLinkshell5 => Language.ChatType_ExtraChatLinkshell5,
|
||||
ChatType.ExtraChatLinkshell6 => Language.ChatType_ExtraChatLinkshell6,
|
||||
ChatType.ExtraChatLinkshell7 => Language.ChatType_ExtraChatLinkshell7,
|
||||
ChatType.ExtraChatLinkshell8 => Language.ChatType_ExtraChatLinkshell8,
|
||||
_ => type.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
internal static uint? DefaultColor(this ChatType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ChatType.Debug:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.Urgent:
|
||||
return ColourUtil.ComponentsToRgba(255, 127, 127);
|
||||
case ChatType.Notice:
|
||||
return ColourUtil.ComponentsToRgba(179, 140, 255);
|
||||
|
||||
case ChatType.Say:
|
||||
case ChatType.GmSay:
|
||||
return ColourUtil.ComponentsToRgba(247, 247, 247);
|
||||
case ChatType.Shout:
|
||||
case ChatType.GmShout:
|
||||
return ColourUtil.ComponentsToRgba(255, 166, 102);
|
||||
case ChatType.TellIncoming:
|
||||
case ChatType.TellOutgoing:
|
||||
case ChatType.GmTell:
|
||||
return ColourUtil.ComponentsToRgba(255, 184, 222);
|
||||
case ChatType.Party:
|
||||
case ChatType.CrossParty:
|
||||
case ChatType.GmParty:
|
||||
return ColourUtil.ComponentsToRgba(102, 229, 255);
|
||||
case ChatType.Alliance:
|
||||
return ColourUtil.ComponentsToRgba(255, 127, 0);
|
||||
case ChatType.NoviceNetwork:
|
||||
case ChatType.NoviceNetworkSystem:
|
||||
case ChatType.GmNoviceNetwork:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.Linkshell1:
|
||||
case ChatType.Linkshell2:
|
||||
case ChatType.Linkshell3:
|
||||
case ChatType.Linkshell4:
|
||||
case ChatType.Linkshell5:
|
||||
case ChatType.Linkshell6:
|
||||
case ChatType.Linkshell7:
|
||||
case ChatType.Linkshell8:
|
||||
case ChatType.CrossLinkshell1:
|
||||
case ChatType.CrossLinkshell2:
|
||||
case ChatType.CrossLinkshell3:
|
||||
case ChatType.CrossLinkshell4:
|
||||
case ChatType.CrossLinkshell5:
|
||||
case ChatType.CrossLinkshell6:
|
||||
case ChatType.CrossLinkshell7:
|
||||
case ChatType.CrossLinkshell8:
|
||||
case ChatType.GmLinkshell1:
|
||||
case ChatType.GmLinkshell2:
|
||||
case ChatType.GmLinkshell3:
|
||||
case ChatType.GmLinkshell4:
|
||||
case ChatType.GmLinkshell5:
|
||||
case ChatType.GmLinkshell6:
|
||||
case ChatType.GmLinkshell7:
|
||||
case ChatType.GmLinkshell8:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.StandardEmote:
|
||||
return ColourUtil.ComponentsToRgba(186, 255, 240);
|
||||
case ChatType.CustomEmote:
|
||||
return ColourUtil.ComponentsToRgba(186, 255, 240);
|
||||
case ChatType.Yell:
|
||||
case ChatType.GmYell:
|
||||
return ColourUtil.ComponentsToRgba(255, 255, 0);
|
||||
case ChatType.Echo:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.System:
|
||||
case ChatType.GatheringSystem:
|
||||
case ChatType.PeriodicRecruitmentNotification:
|
||||
case ChatType.Orchestrion:
|
||||
case ChatType.Alarm:
|
||||
case ChatType.GlamourNotifications:
|
||||
case ChatType.RetainerSale:
|
||||
case ChatType.Sign:
|
||||
case ChatType.MessageBook:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.NpcAnnouncement:
|
||||
case ChatType.NpcDialogue:
|
||||
return ColourUtil.ComponentsToRgba(171, 214, 71);
|
||||
case ChatType.Error:
|
||||
return ColourUtil.ComponentsToRgba(255, 74, 74);
|
||||
case ChatType.FreeCompany:
|
||||
case ChatType.FreeCompanyAnnouncement:
|
||||
case ChatType.FreeCompanyLoginLogout:
|
||||
case ChatType.GmFreeCompany:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.PvpTeam:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.PvpTeamAnnouncement:
|
||||
case ChatType.PvpTeamLoginLogout:
|
||||
return ColourUtil.ComponentsToRgba(171, 219, 229);
|
||||
case ChatType.Action:
|
||||
case ChatType.Item:
|
||||
case ChatType.LootNotice:
|
||||
return ColourUtil.ComponentsToRgba(255, 255, 176);
|
||||
case ChatType.Progress:
|
||||
return ColourUtil.ComponentsToRgba(255, 222, 115);
|
||||
case ChatType.LootRoll:
|
||||
case ChatType.RandomNumber:
|
||||
return ColourUtil.ComponentsToRgba(199, 191, 158);
|
||||
case ChatType.Crafting:
|
||||
case ChatType.Gathering:
|
||||
return ColourUtil.ComponentsToRgba(222, 191, 247);
|
||||
case ChatType.Damage:
|
||||
return ColourUtil.ComponentsToRgba(255, 125, 125);
|
||||
case ChatType.Miss:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
case ChatType.Healing:
|
||||
return ColourUtil.ComponentsToRgba(212, 255, 125);
|
||||
case ChatType.GainBuff:
|
||||
case ChatType.LoseBuff:
|
||||
return ColourUtil.ComponentsToRgba(148, 191, 255);
|
||||
case ChatType.GainDebuff:
|
||||
case ChatType.LoseDebuff:
|
||||
return ColourUtil.ComponentsToRgba(255, 138, 196);
|
||||
case ChatType.BattleSystem:
|
||||
return ColourUtil.ComponentsToRgba(204, 204, 204);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static InputChannel? ToInputChannel(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
ChatType.TellOutgoing => InputChannel.Tell,
|
||||
ChatType.Say => InputChannel.Say,
|
||||
ChatType.Party => InputChannel.Party,
|
||||
ChatType.Alliance => InputChannel.Alliance,
|
||||
ChatType.Yell => InputChannel.Yell,
|
||||
ChatType.Shout => InputChannel.Shout,
|
||||
ChatType.FreeCompany => InputChannel.FreeCompany,
|
||||
ChatType.PvpTeam => InputChannel.PvpTeam,
|
||||
ChatType.NoviceNetwork => InputChannel.NoviceNetwork,
|
||||
ChatType.CrossLinkshell1 => InputChannel.CrossLinkshell1,
|
||||
ChatType.CrossLinkshell2 => InputChannel.CrossLinkshell2,
|
||||
ChatType.CrossLinkshell3 => InputChannel.CrossLinkshell3,
|
||||
ChatType.CrossLinkshell4 => InputChannel.CrossLinkshell4,
|
||||
ChatType.CrossLinkshell5 => InputChannel.CrossLinkshell5,
|
||||
ChatType.CrossLinkshell6 => InputChannel.CrossLinkshell6,
|
||||
ChatType.CrossLinkshell7 => InputChannel.CrossLinkshell7,
|
||||
ChatType.CrossLinkshell8 => InputChannel.CrossLinkshell8,
|
||||
ChatType.Linkshell1 => InputChannel.Linkshell1,
|
||||
ChatType.Linkshell2 => InputChannel.Linkshell2,
|
||||
ChatType.Linkshell3 => InputChannel.Linkshell3,
|
||||
ChatType.Linkshell4 => InputChannel.Linkshell4,
|
||||
ChatType.Linkshell5 => InputChannel.Linkshell5,
|
||||
ChatType.Linkshell6 => InputChannel.Linkshell6,
|
||||
ChatType.Linkshell7 => InputChannel.Linkshell7,
|
||||
ChatType.Linkshell8 => InputChannel.Linkshell8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
internal static bool IsGm(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
ChatType.GmTell => true,
|
||||
ChatType.GmSay => true,
|
||||
ChatType.GmShout => true,
|
||||
ChatType.GmYell => true,
|
||||
ChatType.GmParty => true,
|
||||
ChatType.GmFreeCompany => true,
|
||||
ChatType.GmLinkshell1 => true,
|
||||
ChatType.GmLinkshell2 => true,
|
||||
ChatType.GmLinkshell3 => true,
|
||||
ChatType.GmLinkshell4 => true,
|
||||
ChatType.GmLinkshell5 => true,
|
||||
ChatType.GmLinkshell6 => true,
|
||||
ChatType.GmLinkshell7 => true,
|
||||
ChatType.GmLinkshell8 => true,
|
||||
ChatType.GmNoviceNetwork => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsExtraChatLinkshell(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
ChatType.ExtraChatLinkshell1 => true,
|
||||
ChatType.ExtraChatLinkshell2 => true,
|
||||
ChatType.ExtraChatLinkshell3 => true,
|
||||
ChatType.ExtraChatLinkshell4 => true,
|
||||
ChatType.ExtraChatLinkshell5 => true,
|
||||
ChatType.ExtraChatLinkshell6 => true,
|
||||
ChatType.ExtraChatLinkshell7 => true,
|
||||
ChatType.ExtraChatLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public static UiConfigOption ToConfigEntry(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
ChatType.Say => UiConfigOption.ColorSay,
|
||||
ChatType.Shout => UiConfigOption.ColorShout,
|
||||
ChatType.TellOutgoing => UiConfigOption.ColorTell,
|
||||
ChatType.Party => UiConfigOption.ColorParty,
|
||||
ChatType.Linkshell1 => UiConfigOption.ColorLS1,
|
||||
ChatType.Linkshell2 => UiConfigOption.ColorLS2,
|
||||
ChatType.Linkshell3 => UiConfigOption.ColorLS3,
|
||||
ChatType.Linkshell4 => UiConfigOption.ColorLS4,
|
||||
ChatType.Linkshell5 => UiConfigOption.ColorLS5,
|
||||
ChatType.Linkshell6 => UiConfigOption.ColorLS6,
|
||||
ChatType.Linkshell7 => UiConfigOption.ColorLS7,
|
||||
ChatType.Linkshell8 => UiConfigOption.ColorLS8,
|
||||
ChatType.FreeCompany => UiConfigOption.ColorFCompany,
|
||||
ChatType.NoviceNetwork => UiConfigOption.ColorBeginner,
|
||||
ChatType.CustomEmote => UiConfigOption.ColorEmoteUser,
|
||||
ChatType.StandardEmote => UiConfigOption.ColorEmote,
|
||||
ChatType.Yell => UiConfigOption.ColorYell,
|
||||
ChatType.GainBuff => UiConfigOption.ColorBuffGive,
|
||||
ChatType.GainDebuff => UiConfigOption.ColorDebuffGive,
|
||||
ChatType.System => UiConfigOption.ColorSysMsg,
|
||||
ChatType.NpcDialogue => UiConfigOption.ColorNpcSay,
|
||||
ChatType.LootRoll => UiConfigOption.ColorLoot,
|
||||
ChatType.FreeCompanyAnnouncement => UiConfigOption.ColorFCAnnounce,
|
||||
ChatType.PvpTeamAnnouncement => UiConfigOption.ColorPvPGroupAnnounce,
|
||||
_ => UiConfigOption.ColorSay,
|
||||
};
|
||||
|
||||
internal static bool HasSource(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
// Battle
|
||||
ChatType.Damage => true,
|
||||
ChatType.Miss => true,
|
||||
ChatType.Action => true,
|
||||
ChatType.Item => true,
|
||||
ChatType.Healing => true,
|
||||
ChatType.GainBuff => true,
|
||||
ChatType.LoseBuff => true,
|
||||
ChatType.GainDebuff => true,
|
||||
ChatType.LoseDebuff => true,
|
||||
|
||||
// Announcements
|
||||
ChatType.System => true,
|
||||
ChatType.BattleSystem => true,
|
||||
ChatType.Error => true,
|
||||
ChatType.LootNotice => true,
|
||||
ChatType.Progress => true,
|
||||
ChatType.LootRoll => true,
|
||||
ChatType.Crafting => true,
|
||||
ChatType.Gathering => true,
|
||||
ChatType.FreeCompanyLoginLogout => true,
|
||||
ChatType.PvpTeamLoginLogout => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static ChatType Parent(this ChatType type) =>
|
||||
type switch
|
||||
{
|
||||
ChatType.Say => ChatType.Say,
|
||||
ChatType.GmSay => ChatType.Say,
|
||||
ChatType.Shout => ChatType.Shout,
|
||||
ChatType.GmShout => ChatType.Shout,
|
||||
ChatType.TellOutgoing => ChatType.TellOutgoing,
|
||||
ChatType.TellIncoming => ChatType.TellOutgoing,
|
||||
ChatType.GmTell => ChatType.TellOutgoing,
|
||||
ChatType.Party => ChatType.Party,
|
||||
ChatType.CrossParty => ChatType.Party,
|
||||
ChatType.GmParty => ChatType.Party,
|
||||
ChatType.Linkshell1 => ChatType.Linkshell1,
|
||||
ChatType.GmLinkshell1 => ChatType.Linkshell1,
|
||||
ChatType.Linkshell2 => ChatType.Linkshell2,
|
||||
ChatType.GmLinkshell2 => ChatType.Linkshell2,
|
||||
ChatType.Linkshell3 => ChatType.Linkshell3,
|
||||
ChatType.GmLinkshell3 => ChatType.Linkshell3,
|
||||
ChatType.Linkshell4 => ChatType.Linkshell4,
|
||||
ChatType.GmLinkshell4 => ChatType.Linkshell4,
|
||||
ChatType.Linkshell5 => ChatType.Linkshell5,
|
||||
ChatType.GmLinkshell5 => ChatType.Linkshell5,
|
||||
ChatType.Linkshell6 => ChatType.Linkshell6,
|
||||
ChatType.GmLinkshell6 => ChatType.Linkshell6,
|
||||
ChatType.Linkshell7 => ChatType.Linkshell7,
|
||||
ChatType.GmLinkshell7 => ChatType.Linkshell7,
|
||||
ChatType.Linkshell8 => ChatType.Linkshell8,
|
||||
ChatType.GmLinkshell8 => ChatType.Linkshell8,
|
||||
ChatType.FreeCompany => ChatType.FreeCompany,
|
||||
ChatType.GmFreeCompany => ChatType.FreeCompany,
|
||||
ChatType.NoviceNetwork => ChatType.NoviceNetwork,
|
||||
ChatType.GmNoviceNetwork => ChatType.NoviceNetwork,
|
||||
ChatType.CustomEmote => ChatType.CustomEmote,
|
||||
ChatType.StandardEmote => ChatType.StandardEmote,
|
||||
ChatType.Yell => ChatType.Yell,
|
||||
ChatType.GmYell => ChatType.Yell,
|
||||
ChatType.GainBuff => ChatType.GainBuff,
|
||||
ChatType.LoseBuff => ChatType.GainBuff,
|
||||
ChatType.GainDebuff => ChatType.GainDebuff,
|
||||
ChatType.LoseDebuff => ChatType.GainDebuff,
|
||||
ChatType.System => ChatType.System,
|
||||
ChatType.Alarm => ChatType.System,
|
||||
ChatType.GlamourNotifications => ChatType.System,
|
||||
ChatType.RetainerSale => ChatType.System,
|
||||
ChatType.PeriodicRecruitmentNotification => ChatType.System,
|
||||
ChatType.Sign => ChatType.System,
|
||||
ChatType.Orchestrion => ChatType.System,
|
||||
ChatType.MessageBook => ChatType.System,
|
||||
ChatType.NpcDialogue => ChatType.NpcDialogue,
|
||||
ChatType.NpcAnnouncement => ChatType.NpcDialogue,
|
||||
ChatType.LootRoll => ChatType.LootRoll,
|
||||
ChatType.RandomNumber => ChatType.LootRoll,
|
||||
ChatType.FreeCompanyAnnouncement => ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.FreeCompanyLoginLogout => ChatType.FreeCompanyAnnouncement,
|
||||
ChatType.PvpTeamAnnouncement => ChatType.PvpTeamAnnouncement,
|
||||
ChatType.PvpTeamLoginLogout => ChatType.PvpTeamAnnouncement,
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ChatTwo.Code;
|
||||
namespace HellionChat.Code;
|
||||
|
||||
public enum InputChannel : uint
|
||||
{
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
using Lumina.Excel.Sheets;
|
||||
|
||||
namespace HellionChat.Code;
|
||||
|
||||
internal static class InputChannelExt
|
||||
{
|
||||
internal static ChatType ToChatType(this InputChannel input) =>
|
||||
input switch
|
||||
{
|
||||
InputChannel.Tell => ChatType.TellOutgoing,
|
||||
InputChannel.Say => ChatType.Say,
|
||||
InputChannel.Party => ChatType.Party,
|
||||
InputChannel.Alliance => ChatType.Alliance,
|
||||
InputChannel.Yell => ChatType.Yell,
|
||||
InputChannel.Shout => ChatType.Shout,
|
||||
InputChannel.FreeCompany => ChatType.FreeCompany,
|
||||
InputChannel.PvpTeam => ChatType.PvpTeam,
|
||||
InputChannel.NoviceNetwork => ChatType.NoviceNetwork,
|
||||
InputChannel.CrossLinkshell1 => ChatType.CrossLinkshell1,
|
||||
InputChannel.CrossLinkshell2 => ChatType.CrossLinkshell2,
|
||||
InputChannel.CrossLinkshell3 => ChatType.CrossLinkshell3,
|
||||
InputChannel.CrossLinkshell4 => ChatType.CrossLinkshell4,
|
||||
InputChannel.CrossLinkshell5 => ChatType.CrossLinkshell5,
|
||||
InputChannel.CrossLinkshell6 => ChatType.CrossLinkshell6,
|
||||
InputChannel.CrossLinkshell7 => ChatType.CrossLinkshell7,
|
||||
InputChannel.CrossLinkshell8 => ChatType.CrossLinkshell8,
|
||||
InputChannel.Linkshell1 => ChatType.Linkshell1,
|
||||
InputChannel.Linkshell2 => ChatType.Linkshell2,
|
||||
InputChannel.Linkshell3 => ChatType.Linkshell3,
|
||||
InputChannel.Linkshell4 => ChatType.Linkshell4,
|
||||
InputChannel.Linkshell5 => ChatType.Linkshell5,
|
||||
InputChannel.Linkshell6 => ChatType.Linkshell6,
|
||||
InputChannel.Linkshell7 => ChatType.Linkshell7,
|
||||
InputChannel.Linkshell8 => ChatType.Linkshell8,
|
||||
InputChannel.ExtraChatLinkshell1 => ChatType.ExtraChatLinkshell1,
|
||||
InputChannel.ExtraChatLinkshell2 => ChatType.ExtraChatLinkshell2,
|
||||
InputChannel.ExtraChatLinkshell3 => ChatType.ExtraChatLinkshell3,
|
||||
InputChannel.ExtraChatLinkshell4 => ChatType.ExtraChatLinkshell4,
|
||||
InputChannel.ExtraChatLinkshell5 => ChatType.ExtraChatLinkshell5,
|
||||
InputChannel.ExtraChatLinkshell6 => ChatType.ExtraChatLinkshell6,
|
||||
InputChannel.ExtraChatLinkshell7 => ChatType.ExtraChatLinkshell7,
|
||||
InputChannel.ExtraChatLinkshell8 => ChatType.ExtraChatLinkshell8,
|
||||
InputChannel.Invalid => ChatType.Echo,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(input), input, null),
|
||||
};
|
||||
|
||||
public static uint LinkshellIndex(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.Linkshell1 => 0,
|
||||
InputChannel.Linkshell2 => 1,
|
||||
InputChannel.Linkshell3 => 2,
|
||||
InputChannel.Linkshell4 => 3,
|
||||
InputChannel.Linkshell5 => 4,
|
||||
InputChannel.Linkshell6 => 5,
|
||||
InputChannel.Linkshell7 => 6,
|
||||
InputChannel.Linkshell8 => 7,
|
||||
InputChannel.CrossLinkshell1 => 0,
|
||||
InputChannel.CrossLinkshell2 => 1,
|
||||
InputChannel.CrossLinkshell3 => 2,
|
||||
InputChannel.CrossLinkshell4 => 3,
|
||||
InputChannel.CrossLinkshell5 => 4,
|
||||
InputChannel.CrossLinkshell6 => 5,
|
||||
InputChannel.CrossLinkshell7 => 6,
|
||||
InputChannel.CrossLinkshell8 => 7,
|
||||
InputChannel.ExtraChatLinkshell1 => 0,
|
||||
InputChannel.ExtraChatLinkshell2 => 1,
|
||||
InputChannel.ExtraChatLinkshell3 => 2,
|
||||
InputChannel.ExtraChatLinkshell4 => 3,
|
||||
InputChannel.ExtraChatLinkshell5 => 4,
|
||||
InputChannel.ExtraChatLinkshell6 => 5,
|
||||
InputChannel.ExtraChatLinkshell7 => 6,
|
||||
InputChannel.ExtraChatLinkshell8 => 7,
|
||||
_ => uint.MaxValue,
|
||||
};
|
||||
|
||||
public static string Prefix(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.Tell => "/t",
|
||||
InputChannel.Say => "/s",
|
||||
InputChannel.Party => "/p",
|
||||
InputChannel.Alliance => "/a",
|
||||
InputChannel.Yell => "/y",
|
||||
InputChannel.Shout => "/sh",
|
||||
InputChannel.FreeCompany => "/fc",
|
||||
InputChannel.PvpTeam => "/pt",
|
||||
InputChannel.NoviceNetwork => "/b",
|
||||
InputChannel.CrossLinkshell1 => "/cwl1",
|
||||
InputChannel.CrossLinkshell2 => "/cwl2",
|
||||
InputChannel.CrossLinkshell3 => "/cwl3",
|
||||
InputChannel.CrossLinkshell4 => "/cwl4",
|
||||
InputChannel.CrossLinkshell5 => "/cwl5",
|
||||
InputChannel.CrossLinkshell6 => "/cwl6",
|
||||
InputChannel.CrossLinkshell7 => "/cwl7",
|
||||
InputChannel.CrossLinkshell8 => "/cwl8",
|
||||
InputChannel.Linkshell1 => "/l1",
|
||||
InputChannel.Linkshell2 => "/l2",
|
||||
InputChannel.Linkshell3 => "/l3",
|
||||
InputChannel.Linkshell4 => "/l4",
|
||||
InputChannel.Linkshell5 => "/l5",
|
||||
InputChannel.Linkshell6 => "/l6",
|
||||
InputChannel.Linkshell7 => "/l7",
|
||||
InputChannel.Linkshell8 => "/l8",
|
||||
InputChannel.ExtraChatLinkshell1 => "/ecl1",
|
||||
InputChannel.ExtraChatLinkshell2 => "/ecl2",
|
||||
InputChannel.ExtraChatLinkshell3 => "/ecl3",
|
||||
InputChannel.ExtraChatLinkshell4 => "/ecl4",
|
||||
InputChannel.ExtraChatLinkshell5 => "/ecl5",
|
||||
InputChannel.ExtraChatLinkshell6 => "/ecl6",
|
||||
InputChannel.ExtraChatLinkshell7 => "/ecl7",
|
||||
InputChannel.ExtraChatLinkshell8 => "/ecl8",
|
||||
_ => "/e",
|
||||
};
|
||||
|
||||
public static IEnumerable<TextCommand>? TextCommands(this InputChannel channel)
|
||||
{
|
||||
uint[] ids = channel switch
|
||||
{
|
||||
InputChannel.Tell => [104, 118],
|
||||
InputChannel.Say => [102],
|
||||
InputChannel.Party => [105],
|
||||
InputChannel.Alliance => [119],
|
||||
InputChannel.Yell => [117],
|
||||
InputChannel.Shout => [103],
|
||||
InputChannel.FreeCompany => [115],
|
||||
InputChannel.PvpTeam => [91],
|
||||
InputChannel.NoviceNetwork => [101],
|
||||
InputChannel.CrossLinkshell1 => [13],
|
||||
InputChannel.CrossLinkshell2 => [14],
|
||||
InputChannel.CrossLinkshell3 => [15],
|
||||
InputChannel.CrossLinkshell4 => [16],
|
||||
InputChannel.CrossLinkshell5 => [17],
|
||||
InputChannel.CrossLinkshell6 => [18],
|
||||
InputChannel.CrossLinkshell7 => [19],
|
||||
InputChannel.CrossLinkshell8 => [20],
|
||||
InputChannel.Linkshell1 => [107],
|
||||
InputChannel.Linkshell2 => [108],
|
||||
InputChannel.Linkshell3 => [109],
|
||||
InputChannel.Linkshell4 => [110],
|
||||
InputChannel.Linkshell5 => [111],
|
||||
InputChannel.Linkshell6 => [112],
|
||||
InputChannel.Linkshell7 => [113],
|
||||
InputChannel.Linkshell8 => [114],
|
||||
_ => [],
|
||||
};
|
||||
|
||||
if (ids.Length == 0)
|
||||
return null;
|
||||
|
||||
return ids.Where(id => Sheets.TextCommandSheet.HasRow(id))
|
||||
.Select(id => Sheets.TextCommandSheet.GetRow(id));
|
||||
}
|
||||
|
||||
internal static bool IsLinkshell(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.Linkshell1 => true,
|
||||
InputChannel.Linkshell2 => true,
|
||||
InputChannel.Linkshell3 => true,
|
||||
InputChannel.Linkshell4 => true,
|
||||
InputChannel.Linkshell5 => true,
|
||||
InputChannel.Linkshell6 => true,
|
||||
InputChannel.Linkshell7 => true,
|
||||
InputChannel.Linkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsCrossLinkshell(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.CrossLinkshell1 => true,
|
||||
InputChannel.CrossLinkshell2 => true,
|
||||
InputChannel.CrossLinkshell3 => true,
|
||||
InputChannel.CrossLinkshell4 => true,
|
||||
InputChannel.CrossLinkshell5 => true,
|
||||
InputChannel.CrossLinkshell6 => true,
|
||||
InputChannel.CrossLinkshell7 => true,
|
||||
InputChannel.CrossLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsExtraChatLinkshell(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.ExtraChatLinkshell1 => true,
|
||||
InputChannel.ExtraChatLinkshell2 => true,
|
||||
InputChannel.ExtraChatLinkshell3 => true,
|
||||
InputChannel.ExtraChatLinkshell4 => true,
|
||||
InputChannel.ExtraChatLinkshell5 => true,
|
||||
InputChannel.ExtraChatLinkshell6 => true,
|
||||
InputChannel.ExtraChatLinkshell7 => true,
|
||||
InputChannel.ExtraChatLinkshell8 => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool IsValid(this InputChannel channel) =>
|
||||
channel switch
|
||||
{
|
||||
InputChannel.Invalid => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Dalamud.Game.Command;
|
||||
|
||||
namespace ChatTwo;
|
||||
namespace HellionChat;
|
||||
|
||||
internal sealed class Commands : IDisposable
|
||||
{
|
||||
@@ -16,15 +16,22 @@ internal sealed class Commands : IDisposable
|
||||
{
|
||||
foreach (var wrapper in Registered.Values)
|
||||
{
|
||||
Plugin.CommandManager.AddHandler(wrapper.Name, new CommandInfo(Invoke)
|
||||
{
|
||||
HelpMessage = wrapper.Description ?? string.Empty,
|
||||
ShowInHelp = wrapper.ShowInHelp,
|
||||
});
|
||||
Plugin.CommandManager.AddHandler(
|
||||
wrapper.Name,
|
||||
new CommandInfo(Invoke)
|
||||
{
|
||||
HelpMessage = wrapper.Description ?? string.Empty,
|
||||
ShowInHelp = wrapper.ShowInHelp,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal CommandWrapper Register(string name, string? description = null, bool? showInHelp = null)
|
||||
internal CommandWrapper Register(
|
||||
string name,
|
||||
string? description = null,
|
||||
bool? showInHelp = null
|
||||
)
|
||||
{
|
||||
if (Registered.TryGetValue(name, out var wrapper))
|
||||
{
|
||||
@@ -45,7 +52,7 @@ internal sealed class Commands : IDisposable
|
||||
{
|
||||
if (!Registered.TryGetValue(command, out var wrapper))
|
||||
{
|
||||
Plugin.Log.Warning($"Missing registration for command {command}");
|
||||
Plugin.LogProxy.Warning($"Missing registration for command {command}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +62,7 @@ internal sealed class Commands : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, $"Error while executing command {command}");
|
||||
Plugin.LogProxy.Error(ex, $"Error while executing command {command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Collections;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.GameFunctions.Types;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Configuration;
|
||||
using Dalamud.Game.ClientState.Keys;
|
||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||
using Dalamud.Interface.FontIdentifier;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace ChatTwo;
|
||||
namespace HellionChat;
|
||||
|
||||
[Serializable]
|
||||
public class ConfigKeyBind
|
||||
@@ -27,24 +27,48 @@ public class ConfigKeyBind
|
||||
modString += Language.Keybind_Modifier_Shift + " + ";
|
||||
if (Modifier.HasFlag(ModifierFlag.Alt))
|
||||
modString += Language.Keybind_Modifier_Alt + " + ";
|
||||
return modString+Key.GetFancyName();
|
||||
return modString + Key.GetFancyName();
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class Configuration : IPluginConfiguration
|
||||
{
|
||||
private const int LatestVersion = 10;
|
||||
private const int LatestVersion = 17;
|
||||
|
||||
public int Version { get; set; } = LatestVersion;
|
||||
|
||||
// Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default).
|
||||
// Master-switch defaults to true; set false to restore upstream behavior.
|
||||
// Slug-based; ThemeRegistry resolves the object at runtime.
|
||||
public string Theme = "hellion-arctic";
|
||||
|
||||
// Global window opacity, applied across all themes.
|
||||
public float WindowOpacity = 0.85f;
|
||||
|
||||
// Reserved for future UI toggles; pre-declared to avoid a migration later.
|
||||
public bool ReduceMotion;
|
||||
|
||||
// v1.2.1: default flipped false → true. Compact single-line layout is
|
||||
// more readable than the card-rows layout introduced in v1.2.0.
|
||||
public bool UseCompactDensity = true;
|
||||
|
||||
// Privacy by Default master switch. Set false to restore upstream behaviour.
|
||||
public bool PrivacyFilterEnabled = true;
|
||||
|
||||
// Empty set means the migration has not run yet — see Plugin.cs v6→v7.
|
||||
public HashSet<ChatType> PrivacyPersistChannels = [];
|
||||
// Failsafe for ChatTypes added by future FFXIV patches we don't know about.
|
||||
public bool PrivacyPersistUnknownChannels;
|
||||
|
||||
// Failsafe for ChatTypes added by future FFXIV patches. New configs default
|
||||
// to the failsafe via PrivacyDefaults; existing configs keep their saved
|
||||
// choice because the deserializer overrides this initializer.
|
||||
public bool PrivacyPersistUnknownChannels = Privacy
|
||||
.PrivacyDefaults
|
||||
.DefaultPersistUnknownChannels;
|
||||
|
||||
// F3.2: dedup unknown-ChatType warnings so a chatty filter doesn't spam
|
||||
// the log every frame. NonSerialized so the warning fires once per
|
||||
// runtime, not once-ever-per-install.
|
||||
[NonSerialized]
|
||||
private readonly HashSet<ChatType> _warnedUnknownChannels = new();
|
||||
|
||||
public bool IsAllowedForStorage(ChatType type)
|
||||
{
|
||||
@@ -52,54 +76,53 @@ public class Configuration : IPluginConfiguration
|
||||
return true;
|
||||
if (PrivacyPersistChannels.Contains(type))
|
||||
return true;
|
||||
|
||||
// F3.2: log first occurrence of a ChatType the running build doesn't
|
||||
// recognise — i.e. one a future FFXIV patch may have added. Known
|
||||
// types the user opted out of are routed through the failsafe
|
||||
// silently, like before.
|
||||
if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type))
|
||||
{
|
||||
Plugin.LogProxy.Warning(
|
||||
"PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
|
||||
type,
|
||||
PrivacyPersistUnknownChannels
|
||||
);
|
||||
}
|
||||
|
||||
return PrivacyPersistUnknownChannels;
|
||||
}
|
||||
|
||||
// Hellion Chat — Message retention (GDPR data minimization, time axis).
|
||||
// Master switch defaults to false; the plugin will not delete history
|
||||
// until the user explicitly opts in.
|
||||
// Retention master switch defaults to false — plugin will not delete
|
||||
// history until the user explicitly opts in.
|
||||
public bool RetentionEnabled;
|
||||
public int RetentionDefaultDays = 30;
|
||||
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
|
||||
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
|
||||
|
||||
// Hellion Chat first-run wizard — opens once on a fresh install. Existing
|
||||
// ChatTwo users skip it because the v6→v7 migration sets the flag.
|
||||
public bool FirstRunCompleted;
|
||||
|
||||
// Hellion Chat global ImGui theme — applied to every plugin window in
|
||||
// Plugin.Draw. Default ON; users who prefer the upstream Dalamud look
|
||||
// can flip this off in the Privacy tab.
|
||||
public bool HellionThemeEnabled = true;
|
||||
|
||||
// Window background opacity, 0.5–1.0. Lower values make the plugin
|
||||
// panes more glass-like so the game shines through. Default ~92%.
|
||||
public float HellionThemeWindowOpacity = 0.92f;
|
||||
|
||||
// Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font
|
||||
// instead of whatever GlobalFontV2.FontId points at. Default ON so a
|
||||
// fresh install gets the Hellion typography out-of-the-box; flip OFF
|
||||
// to fall back to the user's chosen system or Dalamud font.
|
||||
public bool UseHellionFont = true;
|
||||
public bool ShowHonorificTitleInHeader = true;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs. When enabled, an incoming or outgoing
|
||||
// /tell spawns a session-only tab dedicated to that conversation
|
||||
// partner. See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian).
|
||||
// v1.4.7 opt-in: renders the Honorific glow outline when the title carries
|
||||
// a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
|
||||
// who don't care, and dodges the per-frame DrawList overhead on low-end
|
||||
// hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
|
||||
// as the primary Color until a later cycle ports the animation.
|
||||
public bool ShowHonorificGlow;
|
||||
public bool EnableAutoTellTabs = true;
|
||||
// Hard cap on simultaneously open auto tell tabs. Range enforced by the
|
||||
// settings slider (1–50). LRU drop favors greeted tabs first.
|
||||
public int AutoTellTabsLimit = 15;
|
||||
// When true the sidebar shows only a thin separator before the temp
|
||||
// tabs; when false a section header "Active Tells (n)" is rendered.
|
||||
public bool AutoTellTabsCompactDisplay;
|
||||
// Number of prior tells to preload from the message store when an
|
||||
// auto tell tab is spawned. Range 0–100; 0 disables preload.
|
||||
public int AutoTellTabsHistoryPreload = 20;
|
||||
// Show the greeter "marked-as-greeted" toggle button next to each
|
||||
// temp tab and dim the tab name when set. Off by default because the
|
||||
// workflow is specific to club-greeter use cases — most users just
|
||||
// want the auto tabs themselves without the extra UI affordance.
|
||||
|
||||
// Sidebar width in pixels. Default 44 mirrors the icon-only layout from
|
||||
// v1.2.0; users can widen up to 160 to fit a section-header line like
|
||||
// "Active Tells (3)" without truncation.
|
||||
public int SidebarWidth = 44;
|
||||
public bool AutoTellTabsShowGreetedToggle;
|
||||
public bool SeenPopOutInputHint;
|
||||
public bool PopOutInputEnabled = true;
|
||||
public bool SeenPopOutHeaderHint;
|
||||
public bool AutoTellTabsOpenAsPopout;
|
||||
|
||||
public int GetRetentionDays(ChatType type)
|
||||
{
|
||||
@@ -116,6 +139,9 @@ public class Configuration : IPluginConfiguration
|
||||
public bool HideWhenUiHidden = true;
|
||||
public bool HideInLoadingScreens;
|
||||
public bool HideInBattle;
|
||||
|
||||
// v1.2.1: default flipped false → true for consistency with other hide defaults.
|
||||
public bool HideInNewGamePlusMenu = true;
|
||||
public bool HideWhenInactive;
|
||||
public int InactivityHideTimeout = 10;
|
||||
public bool InactivityHideActiveDuringBattle = true;
|
||||
@@ -130,13 +156,8 @@ public class Configuration : IPluginConfiguration
|
||||
public bool NativeItemTooltips = true;
|
||||
public bool PrettierTimestamps = true;
|
||||
public bool MoreCompactPretty;
|
||||
public bool HideSameTimestamps;
|
||||
public bool HideSameTimestamps = true;
|
||||
public bool ShowNoviceNetwork;
|
||||
// Hellion Chat — vertical sidebar tab layout reads better than the
|
||||
// horizontal tab strip in the company of Auto-Tell-Tabs (a club
|
||||
// greeter typically tracks 5–15 simultaneous conversations). Bestand
|
||||
// users keep their saved value untouched — only fresh installs pick
|
||||
// up the new default.
|
||||
public bool SidebarTabView = true;
|
||||
public bool PrintChangelog = true;
|
||||
public bool OnlyPreviewIf;
|
||||
@@ -157,12 +178,10 @@ public class Configuration : IPluginConfiguration
|
||||
public bool CollapseKeepUniqueLinks;
|
||||
public bool PlaySounds = true;
|
||||
public bool KeepInputFocus = true;
|
||||
public int MaxLinesToRender = 5_000; // 1-10000
|
||||
public bool Use24HourClock;
|
||||
|
||||
public int MaxLinesToRender = 2_500; // 1-10000
|
||||
public bool Use24HourClock = true;
|
||||
public bool ShowEmotes = true;
|
||||
public HashSet<string> BlockedEmotes = [];
|
||||
|
||||
public bool FontsEnabled = true;
|
||||
public ExtraGlyphRanges ExtraGlyphRanges = 0;
|
||||
public float FontSizeV2 = 12.75f;
|
||||
@@ -186,12 +205,21 @@ public class Configuration : IPluginConfiguration
|
||||
};
|
||||
|
||||
public float TooltipOffset;
|
||||
public float WindowAlpha = 100f;
|
||||
public Dictionary<ChatType, uint> ChatColours = new();
|
||||
public List<Tab> Tabs = [];
|
||||
|
||||
public bool OverrideStyle;
|
||||
public string? ChosenStyle;
|
||||
public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();
|
||||
|
||||
private static Dictionary<ChatType, uint> BuildDefaultChatColours()
|
||||
{
|
||||
var defaults = new Dictionary<ChatType, uint>();
|
||||
foreach (
|
||||
var (channel, colour) in HellionChat.Resources.ChatColourPresets.All["Hellion"].Colours
|
||||
)
|
||||
defaults[channel] = colour;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public bool ColorSelectedInputChannelButton = true;
|
||||
public List<Tab> Tabs = [];
|
||||
|
||||
public ConfigKeyBind? ChatTabForward;
|
||||
public ConfigKeyBind? ChatTabBackward;
|
||||
@@ -208,10 +236,14 @@ public class Configuration : IPluginConfiguration
|
||||
HideWhenUiHidden = other.HideWhenUiHidden;
|
||||
HideInLoadingScreens = other.HideInLoadingScreens;
|
||||
HideInBattle = other.HideInBattle;
|
||||
HideInNewGamePlusMenu = other.HideInNewGamePlusMenu;
|
||||
HideWhenInactive = other.HideWhenInactive;
|
||||
InactivityHideTimeout = other.InactivityHideTimeout;
|
||||
InactivityHideActiveDuringBattle = other.InactivityHideActiveDuringBattle;
|
||||
InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary(pair => pair.Key, pair => pair.Value);
|
||||
InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value
|
||||
);
|
||||
InactivityHideExtraChatAll = other.InactivityHideExtraChatAll;
|
||||
InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet();
|
||||
ShowHideButton = other.ShowHideButton;
|
||||
@@ -243,7 +275,8 @@ public class Configuration : IPluginConfiguration
|
||||
MaxLinesToRender = other.MaxLinesToRender;
|
||||
Use24HourClock = other.Use24HourClock;
|
||||
ShowEmotes = other.ShowEmotes;
|
||||
BlockedEmotes = other.BlockedEmotes;
|
||||
// Deep-copy so settings window edits don't leak into live config before Save.
|
||||
BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
|
||||
FontsEnabled = other.FontsEnabled;
|
||||
ItalicEnabled = other.ItalicEnabled;
|
||||
ExtraGlyphRanges = other.ExtraGlyphRanges;
|
||||
@@ -253,43 +286,75 @@ public class Configuration : IPluginConfiguration
|
||||
ItalicFontV2 = other.ItalicFontV2;
|
||||
SymbolsFontSizeV2 = other.SymbolsFontSizeV2;
|
||||
TooltipOffset = other.TooltipOffset;
|
||||
WindowAlpha = other.WindowAlpha;
|
||||
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
|
||||
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs are session-only and therefore
|
||||
// never present in a disk-loaded copy. Keep the live temp tabs of
|
||||
// *this* configuration alive across an UpdateFrom so a settings
|
||||
// save (or sidebar-mode toggle) does not silently destroy the
|
||||
// user's open tell conversations. Persistent tabs from `other`
|
||||
// still get the regular clone-replace treatment.
|
||||
var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
|
||||
Tabs = other.Tabs.Where(t => !t.IsTempTab).Select(t => t.Clone()).ToList();
|
||||
Tabs.AddRange(liveTempTabs);
|
||||
// Keep live temp tabs alive across UpdateFrom — a settings save must
|
||||
// not destroy open tell conversations. Pinned TempTabs are persistent
|
||||
// and come through `other` like regular tabs; unpinned TempTabs are
|
||||
// session-only and held from the local state. For persistent tabs
|
||||
// (incl. pinned), capture live runtime state by Identifier and restore
|
||||
// it onto the freshly cloned tabs — CurrentChannel is critical because
|
||||
// the user may have switched channel in-game between settings-open
|
||||
// and settings-save, and we'd otherwise overwrite that with the
|
||||
// settings-time snapshot.
|
||||
var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
|
||||
var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
|
||||
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel));
|
||||
|
||||
Tabs = other
|
||||
.Tabs.Where(t => !t.IsTempTab || t.IsPinned)
|
||||
.Select(t =>
|
||||
{
|
||||
var clone = t.Clone();
|
||||
if (livePersistentSession.TryGetValue(clone.Identifier, out var live))
|
||||
{
|
||||
clone.Messages = live.Messages;
|
||||
clone.LastSendUnread = live.LastSendUnread;
|
||||
clone.CurrentChannel = live.CurrentChannel;
|
||||
}
|
||||
return clone;
|
||||
})
|
||||
.ToList();
|
||||
Tabs.AddRange(liveUnpinnedTempTabs);
|
||||
|
||||
OverrideStyle = other.OverrideStyle;
|
||||
ChosenStyle = other.ChosenStyle;
|
||||
ChatTabForward = other.ChatTabForward;
|
||||
ChatTabBackward = other.ChatTabBackward;
|
||||
|
||||
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
||||
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
||||
PrivacyPersistChannels = [.. other.PrivacyPersistChannels];
|
||||
PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;
|
||||
|
||||
RetentionEnabled = other.RetentionEnabled;
|
||||
RetentionDefaultDays = other.RetentionDefaultDays;
|
||||
RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(p => p.Key, p => p.Value);
|
||||
RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(
|
||||
p => p.Key,
|
||||
p => p.Value
|
||||
);
|
||||
RetentionLastRunAt = other.RetentionLastRunAt;
|
||||
|
||||
FirstRunCompleted = other.FirstRunCompleted;
|
||||
HellionThemeEnabled = other.HellionThemeEnabled;
|
||||
HellionThemeWindowOpacity = other.HellionThemeWindowOpacity;
|
||||
UseHellionFont = other.UseHellionFont;
|
||||
ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
|
||||
ShowHonorificGlow = other.ShowHonorificGlow;
|
||||
|
||||
// v1.1.0 theme engine fields
|
||||
Theme = other.Theme;
|
||||
WindowOpacity = other.WindowOpacity;
|
||||
ReduceMotion = other.ReduceMotion;
|
||||
UseCompactDensity = other.UseCompactDensity;
|
||||
|
||||
EnableAutoTellTabs = other.EnableAutoTellTabs;
|
||||
AutoTellTabsLimit = other.AutoTellTabsLimit;
|
||||
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
|
||||
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
|
||||
SidebarWidth = other.SidebarWidth;
|
||||
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
|
||||
|
||||
SeenPopOutInputHint = other.SeenPopOutInputHint;
|
||||
PopOutInputEnabled = other.PopOutInputEnabled;
|
||||
SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
|
||||
AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,21 +368,23 @@ public enum UnreadMode
|
||||
|
||||
public static class UnreadModeExt
|
||||
{
|
||||
internal static string Name(this UnreadMode mode) => mode switch
|
||||
{
|
||||
UnreadMode.All => Language.UnreadMode_All,
|
||||
UnreadMode.Unseen => Language.UnreadMode_Unseen,
|
||||
UnreadMode.None => Language.UnreadMode_None,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
internal static string Name(this UnreadMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
UnreadMode.All => Language.UnreadMode_All,
|
||||
UnreadMode.Unseen => Language.UnreadMode_Unseen,
|
||||
UnreadMode.None => Language.UnreadMode_None,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
|
||||
internal static string? Tooltip(this UnreadMode mode) => mode switch
|
||||
{
|
||||
UnreadMode.All => Language.UnreadMode_All_Tooltip,
|
||||
UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
|
||||
UnreadMode.None => Language.UnreadMode_None_Tooltip,
|
||||
_ => null,
|
||||
};
|
||||
internal static string? Tooltip(this UnreadMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
UnreadMode.All => Language.UnreadMode_All_Tooltip,
|
||||
UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
|
||||
UnreadMode.None => Language.UnreadMode_None_Tooltip,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -325,6 +392,9 @@ public class Tab
|
||||
{
|
||||
public string Name = Language.Tab_DefaultName;
|
||||
|
||||
// Optional FontAwesome glyph name; null falls back to TabIconMapping default.
|
||||
public string? Icon = null;
|
||||
|
||||
[Obsolete("Removed in favor of SelectedChannels")]
|
||||
public Dictionary<ChatType, ChatSource> ChatCodes = new();
|
||||
|
||||
@@ -353,37 +423,65 @@ public class Tab
|
||||
public bool HideWhenInactive;
|
||||
|
||||
public bool IsTempTab;
|
||||
|
||||
// Pinned TempTabs survive plugin reload and logout — tester feedback from
|
||||
// Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
|
||||
// separate from the AutoTellTabsLimit bucket.
|
||||
public bool IsPinned;
|
||||
public bool AllSenderMessages;
|
||||
public TellTarget TellTarget = TellTarget.Empty();
|
||||
|
||||
[NonSerialized] public uint Unread;
|
||||
[NonSerialized] public uint LastSendUnread;
|
||||
[NonSerialized] public long LastActivity;
|
||||
[NonSerialized] public MessageList Messages = new();
|
||||
[NonSerialized]
|
||||
public uint Unread;
|
||||
|
||||
[NonSerialized] public UsedChannel CurrentChannel = new();
|
||||
[NonSerialized]
|
||||
public uint LastSendUnread;
|
||||
|
||||
[NonSerialized] public Guid Identifier = Guid.NewGuid();
|
||||
[NonSerialized]
|
||||
public long LastActivity;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the
|
||||
// sidebar to mark a tell partner as already greeted in the current
|
||||
// session. NonSerialized because the temp tab itself is session-only.
|
||||
[NonSerialized] public bool IsGreeted;
|
||||
[NonSerialized]
|
||||
public MessageList Messages = new();
|
||||
|
||||
[NonSerialized]
|
||||
public UsedChannel CurrentChannel = new();
|
||||
|
||||
[NonSerialized]
|
||||
public Guid Identifier = Guid.NewGuid();
|
||||
|
||||
// Session-only greeted flag for club-greeter workflows.
|
||||
[NonSerialized]
|
||||
public bool IsGreeted;
|
||||
|
||||
// Separate validation keys per cache so TellTarget changes don't
|
||||
// cause GetTint and GetIcon to strand each other with stale entries.
|
||||
[NonSerialized]
|
||||
internal string? _cachedTintTellName;
|
||||
|
||||
[NonSerialized]
|
||||
internal uint _cachedTintTellWorld;
|
||||
|
||||
[NonSerialized]
|
||||
internal uint _cachedTellTint;
|
||||
|
||||
[NonSerialized]
|
||||
internal string? _cachedIconTellName;
|
||||
|
||||
[NonSerialized]
|
||||
internal uint _cachedIconTellWorld;
|
||||
|
||||
[NonSerialized]
|
||||
internal string? _cachedTellIcon;
|
||||
|
||||
public bool Matches(Message message)
|
||||
{
|
||||
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-tell temp tabs are bound to a single conversation partner;
|
||||
// every other tell that matches the channel filter must NOT land
|
||||
// here, otherwise all temp tabs would mirror "Tell Exclusive".
|
||||
// Temp tabs are bound to a single conversation partner — other tells
|
||||
// matching the channel filter must not land here.
|
||||
if (IsTempTab && TellTarget?.IsSet() == true)
|
||||
{
|
||||
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -395,12 +493,17 @@ public class Tab
|
||||
return;
|
||||
|
||||
Unread += 1;
|
||||
if (message.Matches(Plugin.Config.InactivityHideChannelsV2, Plugin.Config.InactivityHideExtraChatAll, Plugin.Config.InactivityHideExtraChatChannels))
|
||||
if (
|
||||
message.Matches(
|
||||
Plugin.Config.InactivityHideChannelsV2,
|
||||
Plugin.Config.InactivityHideExtraChatAll,
|
||||
Plugin.Config.InactivityHideExtraChatChannels
|
||||
)
|
||||
)
|
||||
LastActivity = Environment.TickCount64;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
=> Messages.Clear();
|
||||
public void Clear() => Messages.Clear();
|
||||
|
||||
public Tab Clone()
|
||||
{
|
||||
@@ -421,7 +524,7 @@ public class Tab
|
||||
Opacity = Opacity,
|
||||
Identifier = Identifier,
|
||||
InputDisabled = InputDisabled,
|
||||
CurrentChannel = CurrentChannel,
|
||||
CurrentChannel = CurrentChannel.Clone(),
|
||||
CanMove = CanMove,
|
||||
CanResize = CanResize,
|
||||
IndependentHide = IndependentHide,
|
||||
@@ -432,16 +535,14 @@ public class Tab
|
||||
HideInBattle = HideInBattle,
|
||||
HideWhenInactive = HideWhenInactive,
|
||||
IsTempTab = IsTempTab,
|
||||
IsPinned = IsPinned,
|
||||
AllSenderMessages = AllSenderMessages,
|
||||
TellTarget = TellTarget.From(TellTarget),
|
||||
TellTarget = TellTarget.Clone(),
|
||||
IsGreeted = IsGreeted,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MessageList provides an ordered list of messages with duplicate ID
|
||||
/// tracking, sorting and mutex protection.
|
||||
/// </summary>
|
||||
/// Ordered message list with duplicate ID tracking, sorting and mutex protection.
|
||||
public class MessageList
|
||||
{
|
||||
private readonly SemaphoreSlim LockSlim = new(1, 1);
|
||||
@@ -529,9 +630,24 @@ public class Tab
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an array copy of the message list for usage outside of main thread
|
||||
/// </summary>
|
||||
/// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
LockSlim.Wait(-1);
|
||||
try
|
||||
{
|
||||
return Messages.Count;
|
||||
}
|
||||
finally
|
||||
{
|
||||
LockSlim.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an array copy of the message list for usage outside of main thread.
|
||||
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
|
||||
{
|
||||
await LockSlim.WaitAsync(millisecondsTimeout);
|
||||
@@ -545,17 +661,16 @@ public class Tab
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GetReadOnly returns a read-only list of messages while holding a
|
||||
/// reader lock. The list should be used with a using statement.
|
||||
/// </summary>
|
||||
/// Returns a read-only list while holding a reader lock. Use with a using statement.
|
||||
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
|
||||
{
|
||||
LockSlim.Wait(millisecondsTimeout);
|
||||
return new RLockedMessageList(LockSlim, Messages);
|
||||
}
|
||||
|
||||
public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages) : IReadOnlyList<Message>, IDisposable
|
||||
public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages)
|
||||
: IReadOnlyList<Message>,
|
||||
IDisposable
|
||||
{
|
||||
public IEnumerator<Message> GetEnumerator()
|
||||
{
|
||||
@@ -600,6 +715,29 @@ public class UsedChannel
|
||||
{
|
||||
Channel = channel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
|
||||
// - Deep-clone the UsedChannel so Tab.Clone() no longer shares
|
||||
// channel state (incl. TellTarget) with its origin Tab. Previously
|
||||
// a reference copy: PopOut and Temp tabs mutated each other.
|
||||
// - Name is intentionally a reference copy (matches upstream); it
|
||||
// gets reassigned on every channel switch anyway.
|
||||
// TEST-MIRROR: ../../Hellion Build test/_Helpers/UsedChannelCloneTests.cs
|
||||
// ---------------------------------------------------------------
|
||||
public UsedChannel Clone()
|
||||
{
|
||||
return new UsedChannel
|
||||
{
|
||||
Channel = Channel,
|
||||
Name = Name,
|
||||
TellTarget = TellTarget?.Clone(),
|
||||
|
||||
UseTempChannel = UseTempChannel,
|
||||
TempChannel = TempChannel,
|
||||
TempTellTarget = TempTellTarget?.Clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -614,15 +752,16 @@ public enum PreviewPosition
|
||||
|
||||
public static class PreviewPositionExt
|
||||
{
|
||||
public static string Name(this PreviewPosition position) => position switch
|
||||
{
|
||||
PreviewPosition.None => Language.Options_Preview_None,
|
||||
PreviewPosition.Inside => Language.Options_Preview_Inside,
|
||||
PreviewPosition.Top => Language.Options_Preview_Top,
|
||||
PreviewPosition.Bottom => Language.Options_Preview_Bottom,
|
||||
PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
|
||||
};
|
||||
public static string Name(this PreviewPosition position) =>
|
||||
position switch
|
||||
{
|
||||
PreviewPosition.None => Language.Options_Preview_None,
|
||||
PreviewPosition.Inside => Language.Options_Preview_Inside,
|
||||
PreviewPosition.Top => Language.Options_Preview_Top,
|
||||
PreviewPosition.Bottom => Language.Options_Preview_Bottom,
|
||||
PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -635,13 +774,14 @@ public enum CommandHelpSide
|
||||
|
||||
public static class CommandHelpSideExt
|
||||
{
|
||||
public static string Name(this CommandHelpSide side) => side switch
|
||||
{
|
||||
CommandHelpSide.None => Language.CommandHelpSide_None,
|
||||
CommandHelpSide.Left => Language.CommandHelpSide_Left,
|
||||
CommandHelpSide.Right => Language.CommandHelpSide_Right,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
|
||||
};
|
||||
public static string Name(this CommandHelpSide side) =>
|
||||
side switch
|
||||
{
|
||||
CommandHelpSide.None => Language.CommandHelpSide_None,
|
||||
CommandHelpSide.Left => Language.CommandHelpSide_Left,
|
||||
CommandHelpSide.Right => Language.CommandHelpSide_Right,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -653,19 +793,21 @@ public enum KeybindMode
|
||||
|
||||
public static class KeybindModeExt
|
||||
{
|
||||
public static string Name(this KeybindMode mode) => mode switch
|
||||
{
|
||||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
|
||||
KeybindMode.Strict => Language.KeybindMode_Strict_Name,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
public static string Name(this KeybindMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
|
||||
KeybindMode.Strict => Language.KeybindMode_Strict_Name,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
|
||||
public static string? Tooltip(this KeybindMode mode) => mode switch
|
||||
{
|
||||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
|
||||
KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
|
||||
_ => null,
|
||||
};
|
||||
public static string? Tooltip(this KeybindMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
|
||||
KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -694,49 +836,51 @@ public enum LanguageOverride
|
||||
|
||||
public static class LanguageOverrideExt
|
||||
{
|
||||
public static string Name(this LanguageOverride mode) => mode switch
|
||||
{
|
||||
LanguageOverride.None => Language.LanguageOverride_None,
|
||||
LanguageOverride.ChineseSimplified => "简体中文",
|
||||
LanguageOverride.ChineseTraditional => "繁體中文",
|
||||
LanguageOverride.Dutch => "Nederlands",
|
||||
LanguageOverride.English => "English",
|
||||
LanguageOverride.French => "Français",
|
||||
LanguageOverride.German => "Deutsch",
|
||||
LanguageOverride.Greek => "Ελληνικά",
|
||||
// LanguageOverride.Italian => "Italiano",
|
||||
LanguageOverride.Japanese => "日本語",
|
||||
// LanguageOverride.Korean => "한국어 (Korean)",
|
||||
// LanguageOverride.Norwegian => "Norsk",
|
||||
LanguageOverride.PortugueseBrazil => "Português do Brasil",
|
||||
LanguageOverride.Romanian => "Română",
|
||||
LanguageOverride.Russian => "Русский",
|
||||
LanguageOverride.Spanish => "Español",
|
||||
LanguageOverride.Swedish => "Svenska",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
public static string Name(this LanguageOverride mode) =>
|
||||
mode switch
|
||||
{
|
||||
LanguageOverride.None => Language.LanguageOverride_None,
|
||||
LanguageOverride.ChineseSimplified => "简体中文",
|
||||
LanguageOverride.ChineseTraditional => "繁體中文",
|
||||
LanguageOverride.Dutch => "Nederlands",
|
||||
LanguageOverride.English => "English",
|
||||
LanguageOverride.French => "Français",
|
||||
LanguageOverride.German => "Deutsch",
|
||||
LanguageOverride.Greek => "Ελληνικά",
|
||||
// LanguageOverride.Italian => "Italiano",
|
||||
LanguageOverride.Japanese => "日本語",
|
||||
// LanguageOverride.Korean => "한국어 (Korean)",
|
||||
// LanguageOverride.Norwegian => "Norsk",
|
||||
LanguageOverride.PortugueseBrazil => "Português do Brasil",
|
||||
LanguageOverride.Romanian => "Română",
|
||||
LanguageOverride.Russian => "Русский",
|
||||
LanguageOverride.Spanish => "Español",
|
||||
LanguageOverride.Swedish => "Svenska",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
|
||||
public static string Code(this LanguageOverride mode) => mode switch
|
||||
{
|
||||
LanguageOverride.None => "",
|
||||
LanguageOverride.ChineseSimplified => "zh-hans",
|
||||
LanguageOverride.ChineseTraditional => "zh-hant",
|
||||
LanguageOverride.Dutch => "nl",
|
||||
LanguageOverride.English => "en",
|
||||
LanguageOverride.French => "fr",
|
||||
LanguageOverride.German => "de",
|
||||
LanguageOverride.Greek => "el",
|
||||
// LanguageOverride.Italian => "it",
|
||||
LanguageOverride.Japanese => "ja",
|
||||
// LanguageOverride.Korean => "ko",
|
||||
// LanguageOverride.Norwegian => "no",
|
||||
LanguageOverride.PortugueseBrazil => "pt-br",
|
||||
LanguageOverride.Romanian => "ro",
|
||||
LanguageOverride.Russian => "ru",
|
||||
LanguageOverride.Spanish => "es",
|
||||
LanguageOverride.Swedish => "sv",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
public static string Code(this LanguageOverride mode) =>
|
||||
mode switch
|
||||
{
|
||||
LanguageOverride.None => "",
|
||||
LanguageOverride.ChineseSimplified => "zh-hans",
|
||||
LanguageOverride.ChineseTraditional => "zh-hant",
|
||||
LanguageOverride.Dutch => "nl",
|
||||
LanguageOverride.English => "en",
|
||||
LanguageOverride.French => "fr",
|
||||
LanguageOverride.German => "de",
|
||||
LanguageOverride.Greek => "el",
|
||||
// LanguageOverride.Italian => "it",
|
||||
LanguageOverride.Japanese => "ja",
|
||||
// LanguageOverride.Korean => "ko",
|
||||
// LanguageOverride.Norwegian => "no",
|
||||
LanguageOverride.PortugueseBrazil => "pt-br",
|
||||
LanguageOverride.Romanian => "ro",
|
||||
LanguageOverride.Russian => "ru",
|
||||
LanguageOverride.Spanish => "es",
|
||||
LanguageOverride.Swedish => "sv",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -754,27 +898,31 @@ public enum ExtraGlyphRanges
|
||||
|
||||
public static class ExtraGlyphRangesExt
|
||||
{
|
||||
public static string Name(this ExtraGlyphRanges ranges) => ranges switch
|
||||
{
|
||||
ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
|
||||
ExtraGlyphRanges.ChineseSimplifiedCommon => Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
|
||||
ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
|
||||
ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
|
||||
ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
|
||||
ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
|
||||
ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||||
};
|
||||
public static string Name(this ExtraGlyphRanges ranges) =>
|
||||
ranges switch
|
||||
{
|
||||
ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
|
||||
ExtraGlyphRanges.ChineseSimplifiedCommon =>
|
||||
Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
|
||||
ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
|
||||
ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
|
||||
ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
|
||||
ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
|
||||
ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||||
};
|
||||
|
||||
public static unsafe nint Range(this ExtraGlyphRanges ranges) => ranges switch
|
||||
{
|
||||
ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
|
||||
ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
|
||||
ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
|
||||
ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
|
||||
ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
|
||||
ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
|
||||
ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||||
};
|
||||
public static unsafe nint Range(this ExtraGlyphRanges ranges) =>
|
||||
ranges switch
|
||||
{
|
||||
ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
|
||||
ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)
|
||||
ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
|
||||
ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
|
||||
ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
|
||||
ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
|
||||
ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
|
||||
ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,45 @@
|
||||
using System.Numerics;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Textures;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Utility;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace ChatTwo;
|
||||
namespace HellionChat;
|
||||
|
||||
public static class EmoteCache
|
||||
{
|
||||
private static readonly string[] NotWorking =
|
||||
[
|
||||
":tf:", "(ditto)", "c!", "h!", "l!", "M&Mjc", "LUL3D", "p!",
|
||||
"POLICE2", "r!", "Pussy", "s!", "v!", "w!", "x0r6ztGiggle",
|
||||
"z!", "xar2EDM", "iron95Pls", "Clap2", "AlienPls3", "Life",
|
||||
"peepoPogClimbingTreeHard4House", "monkaGIGAftRobertDowneyJr",
|
||||
"DogLookingSussyAndCold", "DICKS"
|
||||
":tf:",
|
||||
"(ditto)",
|
||||
"c!",
|
||||
"h!",
|
||||
"l!",
|
||||
"M&Mjc",
|
||||
"LUL3D",
|
||||
"p!",
|
||||
"POLICE2",
|
||||
"r!",
|
||||
"Pussy",
|
||||
"s!",
|
||||
"v!",
|
||||
"w!",
|
||||
"x0r6ztGiggle",
|
||||
"z!",
|
||||
"xar2EDM",
|
||||
"iron95Pls",
|
||||
"Clap2",
|
||||
"AlienPls3",
|
||||
"Life",
|
||||
"peepoPogClimbingTreeHard4House",
|
||||
"monkaGIGAftRobertDowneyJr",
|
||||
"DogLookingSussyAndCold",
|
||||
"DICKS",
|
||||
];
|
||||
|
||||
private static readonly HttpClient Client = new();
|
||||
@@ -35,30 +56,30 @@ public static class EmoteCache
|
||||
public Emote Emote { get; set; }
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
public required string Id { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Emote()
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
public required string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; }
|
||||
public required string Code { get; set; }
|
||||
|
||||
[JsonPropertyName("imageType")]
|
||||
public string ImageType { get; set; }
|
||||
public required string ImageType { get; set; }
|
||||
}
|
||||
|
||||
public enum LoadingState
|
||||
{
|
||||
Unloaded,
|
||||
Loading,
|
||||
Done
|
||||
Done,
|
||||
}
|
||||
|
||||
// All of this data is uninitalized while State is not `LoadingState.Done`
|
||||
// All fields below are uninitialised while State != Done.
|
||||
public static LoadingState State = LoadingState.Unloaded;
|
||||
|
||||
private static readonly Dictionary<string, Emote> Cache = new();
|
||||
@@ -66,16 +87,45 @@ public static class EmoteCache
|
||||
|
||||
public static string[] SortedCodeArray = [];
|
||||
|
||||
public static async void LoadData()
|
||||
// Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
|
||||
private static CancellationTokenSource Cts = new();
|
||||
internal static CancellationToken Token => Cts.Token;
|
||||
|
||||
// Tracks in-flight loads so Dispose can drain them before teardown.
|
||||
private static readonly ConcurrentBag<Task> PendingLoads = new();
|
||||
|
||||
internal static void TrackLoad(Task loadTask, string emoteCode)
|
||||
{
|
||||
PendingLoads.Add(
|
||||
loadTask.ContinueWith(
|
||||
t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
Plugin.LogProxy.Error(
|
||||
t.Exception!,
|
||||
$"EmoteCache load failed for {emoteCode}"
|
||||
);
|
||||
},
|
||||
TaskScheduler.Default
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static async Task LoadData()
|
||||
{
|
||||
if (State is not LoadingState.Unloaded)
|
||||
return;
|
||||
|
||||
// Reset CTS if Dispose was called and the plugin is being re-enabled.
|
||||
if (Cts.IsCancellationRequested)
|
||||
Cts = new CancellationTokenSource();
|
||||
|
||||
State = LoadingState.Loading;
|
||||
var ct = Cts.Token;
|
||||
try
|
||||
{
|
||||
var global = await Client.GetAsync(GlobalEmotes);
|
||||
var globalList = await global.Content.ReadAsStringAsync();
|
||||
var global = await Client.GetAsync(GlobalEmotes, ct);
|
||||
var globalList = await global.Content.ReadAsStringAsync(ct);
|
||||
|
||||
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
|
||||
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
|
||||
@@ -84,17 +134,17 @@ public static class EmoteCache
|
||||
var lastId = string.Empty;
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId));
|
||||
var topList = await top.Content.ReadAsStringAsync();
|
||||
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
|
||||
var topList = await top.Content.ReadAsStringAsync(ct);
|
||||
|
||||
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
|
||||
// BetterTTV occasionally returns entries with a null Code; the
|
||||
// upstream code passed those straight into Dictionary.TryAdd
|
||||
// and tripped ArgumentNullException, killing the whole emote
|
||||
// load. Skip them defensively so a single bad row no longer
|
||||
// breaks the cache for everyone else.
|
||||
// BetterTTV occasionally returns entries with a null Code;
|
||||
// skip them so a single bad row doesn't break the whole cache.
|
||||
foreach (var emote in jsonList)
|
||||
if (!string.IsNullOrEmpty(emote.Emote.Code) && !NotWorking.Contains(emote.Emote.Code))
|
||||
if (
|
||||
!string.IsNullOrEmpty(emote.Emote.Code)
|
||||
&& !NotWorking.Contains(emote.Emote.Code)
|
||||
)
|
||||
Cache.TryAdd(emote.Emote.Code, emote.Emote);
|
||||
|
||||
lastId = jsonList.Last().Id;
|
||||
@@ -103,19 +153,34 @@ public static class EmoteCache
|
||||
SortedCodeArray = Cache.Keys.Order().ToArray();
|
||||
State = LoadingState.Done;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Plugin disposed mid-load; State stays on Loading so re-enable can retry.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Reset to Unloaded so a later trigger (e.g. the user reopening
|
||||
// the Emotes tab after the network recovers) can retry. Without
|
||||
// this the State stays on Loading and the early-out at the top
|
||||
// of LoadData blocks every further attempt until plugin reload.
|
||||
// Reset to Unloaded so a later trigger can retry without a plugin reload.
|
||||
State = LoadingState.Unloaded;
|
||||
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
||||
Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Dispose()
|
||||
{
|
||||
Cts.Cancel();
|
||||
|
||||
// 5s upper bound; anything still running gets abandoned.
|
||||
try
|
||||
{
|
||||
Task.WaitAll(PendingLoads.ToArray(), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// Faults already logged in TrackLoad.
|
||||
}
|
||||
|
||||
while (PendingLoads.TryTake(out _)) { }
|
||||
|
||||
foreach (var emote in EmoteImages.Values)
|
||||
emote.InnerDispose();
|
||||
}
|
||||
@@ -152,7 +217,7 @@ public static class EmoteCache
|
||||
}
|
||||
catch
|
||||
{
|
||||
Plugin.Log.Error("Failed to convert");
|
||||
Plugin.LogProxy.Error("Failed to convert");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -171,32 +236,40 @@ public static class EmoteCache
|
||||
ImGui.Image(Texture!.Handle, size);
|
||||
}
|
||||
|
||||
internal async Task<byte[]> LoadAsync(Emote emote)
|
||||
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
||||
{
|
||||
// BetterTTV-supplied Id and ImageType are interpolated straight
|
||||
// into the filename. HTTPS protects the wire, but a compromised
|
||||
// upstream could still hand us "../foo" and write into the
|
||||
// pluginConfigs root (or worse). Resolve the candidate path and
|
||||
// refuse anything that escapes the cache directory.
|
||||
var dir = Path.GetFullPath(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1"));
|
||||
// Path-traversal guard: resolve and verify the candidate path stays
|
||||
// inside the cache directory before reading or writing.
|
||||
var dir = Path.GetFullPath(
|
||||
Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
|
||||
);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar;
|
||||
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar)
|
||||
? dir
|
||||
: dir + Path.DirectorySeparatorChar;
|
||||
var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
|
||||
if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}");
|
||||
throw new InvalidOperationException(
|
||||
$"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}"
|
||||
);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
RawData = await File.ReadAllBytesAsync(filePath);
|
||||
RawData = await File.ReadAllBytesAsync(filePath, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
|
||||
RawData = await content.Content.ReadAsByteArrayAsync();
|
||||
var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
|
||||
RawData = await content.Content.ReadAsByteArrayAsync(ct);
|
||||
|
||||
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
stream.Write(RawData, 0, RawData.Length);
|
||||
await using var stream = new FileStream(
|
||||
filePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read
|
||||
);
|
||||
await stream.WriteAsync(RawData, ct);
|
||||
}
|
||||
|
||||
return RawData;
|
||||
@@ -209,25 +282,32 @@ public static class EmoteCache
|
||||
{
|
||||
public ImGuiEmote Prepare(Emote emote)
|
||||
{
|
||||
Task.Run(() => Load(emote));
|
||||
var ct = EmoteCache.Token;
|
||||
// Task.Run keeps the sync prefix off the ImGui render thread.
|
||||
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
||||
return this;
|
||||
}
|
||||
|
||||
private async void Load(Emote emote)
|
||||
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var image = await LoadAsync(emote);
|
||||
var image = await LoadAsync(emote, ct);
|
||||
if (image.Length <= 0)
|
||||
return;
|
||||
|
||||
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Texture = await Plugin.TextureProvider.CreateFromImageAsync(
|
||||
image,
|
||||
cancellationToken: ct
|
||||
);
|
||||
IsLoaded = true;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Failed = true;
|
||||
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
||||
Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +359,16 @@ public static class EmoteCache
|
||||
|
||||
public ImGuiGif Prepare(Emote emote)
|
||||
{
|
||||
Task.Run(() => Load(emote));
|
||||
var ct = EmoteCache.Token;
|
||||
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
||||
return this;
|
||||
}
|
||||
|
||||
private async void Load(Emote emote)
|
||||
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var image = await LoadAsync(emote);
|
||||
var image = await LoadAsync(emote, ct);
|
||||
if (image.Length <= 0)
|
||||
return;
|
||||
|
||||
@@ -299,26 +380,39 @@ public static class EmoteCache
|
||||
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
|
||||
foreach (var frame in img.Frames)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
||||
|
||||
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
|
||||
// Match browser behaviour: anything under 20ms rounds up to 100ms.
|
||||
if (delay < 0.02f)
|
||||
delay = 0.1f;
|
||||
|
||||
var buffer = new byte[4 * frame.Width * frame.Height];
|
||||
frame.CopyPixelDataTo(buffer);
|
||||
var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer);
|
||||
var tex = await Plugin.TextureProvider.CreateFromRawAsync(
|
||||
RawImageSpecification.Rgba32(frame.Width, frame.Height),
|
||||
buffer,
|
||||
cancellationToken: ct
|
||||
);
|
||||
frames.Add((tex, delay));
|
||||
}
|
||||
|
||||
Frames = frames;
|
||||
IsLoaded = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Plugin disposed mid-load; release any partial frames.
|
||||
foreach (var f in Frames)
|
||||
f.Texture.Dispose();
|
||||
Frames = [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Failed = true;
|
||||
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
||||
Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ChatTwo.Code;
|
||||
using HellionChat.Code;
|
||||
|
||||
namespace ChatTwo.Export;
|
||||
namespace HellionChat.Export;
|
||||
|
||||
internal enum ExportFormat
|
||||
{
|
||||
@@ -13,42 +13,42 @@ internal enum ExportFormat
|
||||
|
||||
internal static class ExportFormatExt
|
||||
{
|
||||
internal static string Extension(this ExportFormat fmt) => fmt switch
|
||||
{
|
||||
ExportFormat.Markdown => "md",
|
||||
ExportFormat.Json => "json",
|
||||
ExportFormat.Csv => "csv",
|
||||
_ => "txt",
|
||||
};
|
||||
internal static string Extension(this ExportFormat fmt) =>
|
||||
fmt switch
|
||||
{
|
||||
ExportFormat.Markdown => "md",
|
||||
ExportFormat.Json => "json",
|
||||
ExportFormat.Csv => "csv",
|
||||
_ => "txt",
|
||||
};
|
||||
|
||||
internal static string Filter(this ExportFormat fmt) => fmt switch
|
||||
{
|
||||
ExportFormat.Markdown => ".md",
|
||||
ExportFormat.Json => ".json",
|
||||
ExportFormat.Csv => ".csv",
|
||||
_ => ".txt",
|
||||
};
|
||||
internal static string Filter(this ExportFormat fmt) =>
|
||||
fmt switch
|
||||
{
|
||||
ExportFormat.Markdown => ".md",
|
||||
ExportFormat.Json => ".json",
|
||||
ExportFormat.Csv => ".csv",
|
||||
_ => ".txt",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes message snapshots into Markdown, JSON, or CSV. The caller is
|
||||
/// expected to filter the input enumerable; this class only handles
|
||||
/// formatting and writes to the supplied path. Sender substring filtering
|
||||
/// happens here because it requires deserialized SeString.TextValue.
|
||||
/// </summary>
|
||||
// Serializes message snapshots to Markdown, JSON, or CSV.
|
||||
// Caller handles pre-filtering except sender substring, which requires deserialized SeString.TextValue.
|
||||
internal static class MessageExporter
|
||||
{
|
||||
internal record FilterDescription(
|
||||
IReadOnlyCollection<int>? ChatTypes,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To,
|
||||
string? SenderSubstring);
|
||||
string? SenderSubstring
|
||||
);
|
||||
|
||||
internal static int ExportToFile(
|
||||
string path,
|
||||
ExportFormat format,
|
||||
IEnumerable<Message> messages,
|
||||
FilterDescription filter)
|
||||
FilterDescription filter
|
||||
)
|
||||
{
|
||||
var matching = filter.SenderSubstring is { Length: > 0 } needle
|
||||
? messages.Where(m => MatchesSender(m, needle))
|
||||
@@ -64,10 +64,14 @@ internal static class MessageExporter
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesSender(Message m, string needle)
|
||||
=> m.SenderSource.TextValue.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
private static bool MatchesSender(Message m, string needle) =>
|
||||
m.SenderSource.TextValue.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static int WriteMarkdown(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
|
||||
private static int WriteMarkdown(
|
||||
StreamWriter w,
|
||||
IEnumerable<Message> messages,
|
||||
FilterDescription filter
|
||||
)
|
||||
{
|
||||
w.WriteLine("# Hellion Chat Export");
|
||||
w.WriteLine();
|
||||
@@ -92,6 +96,7 @@ internal static class MessageExporter
|
||||
var chatType = (ChatType)(ushort)m.Code.Type;
|
||||
var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim();
|
||||
var content = m.ContentSource.TextValue;
|
||||
|
||||
if (string.IsNullOrEmpty(sender))
|
||||
w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}");
|
||||
else
|
||||
@@ -107,7 +112,9 @@ internal static class MessageExporter
|
||||
private static void WriteFilterSummaryMarkdown(StreamWriter w, FilterDescription filter)
|
||||
{
|
||||
if (filter.ChatTypes is { Count: > 0 })
|
||||
w.WriteLine($"ChatTypes: {string.Join(", ", filter.ChatTypes.Select(t => $"{(ChatType)(ushort)t}({t})"))}");
|
||||
w.WriteLine(
|
||||
$"ChatTypes: {string.Join(", ", filter.ChatTypes.Select(t => $"{(ChatType)(ushort)t}({t})"))}"
|
||||
);
|
||||
if (filter.From is not null)
|
||||
w.WriteLine($"From: {filter.From.Value.ToLocalTime():yyyy-MM-dd HH:mm}");
|
||||
if (filter.To is not null)
|
||||
@@ -116,10 +123,13 @@ internal static class MessageExporter
|
||||
w.WriteLine($"Sender contains: \"{filter.SenderSubstring}\"");
|
||||
}
|
||||
|
||||
private static int WriteJson(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
|
||||
private static int WriteJson(
|
||||
StreamWriter w,
|
||||
IEnumerable<Message> messages,
|
||||
FilterDescription filter
|
||||
)
|
||||
{
|
||||
// Manual JSON to avoid pulling in System.Text.Json policy choices.
|
||||
// Output is a single object with metadata and an array of messages.
|
||||
// Manual JSON to avoid System.Text.Json policy coupling.
|
||||
w.Write("{\n \"exported_at\": \"");
|
||||
w.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.Write("\",\n \"plugin\": \"Hellion Chat\",\n");
|
||||
@@ -130,9 +140,17 @@ internal static class MessageExporter
|
||||
else
|
||||
w.Write("null");
|
||||
w.Write(",\n \"from\": ");
|
||||
w.Write(filter.From is null ? "null" : "\"" + filter.From.Value.ToString("O", CultureInfo.InvariantCulture) + "\"");
|
||||
w.Write(
|
||||
filter.From is null
|
||||
? "null"
|
||||
: "\"" + filter.From.Value.ToString("O", CultureInfo.InvariantCulture) + "\""
|
||||
);
|
||||
w.Write(",\n \"to\": ");
|
||||
w.Write(filter.To is null ? "null" : "\"" + filter.To.Value.ToString("O", CultureInfo.InvariantCulture) + "\"");
|
||||
w.Write(
|
||||
filter.To is null
|
||||
? "null"
|
||||
: "\"" + filter.To.Value.ToString("O", CultureInfo.InvariantCulture) + "\""
|
||||
);
|
||||
w.Write(",\n \"sender_substring\": ");
|
||||
w.Write(filter.SenderSubstring is null ? "null" : JsonString(filter.SenderSubstring));
|
||||
w.Write("\n },\n \"messages\": [\n");
|
||||
@@ -166,9 +184,13 @@ internal static class MessageExporter
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int WriteCsv(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
|
||||
private static int WriteCsv(
|
||||
StreamWriter w,
|
||||
IEnumerable<Message> messages,
|
||||
FilterDescription filter
|
||||
)
|
||||
{
|
||||
// Header line always written so empty exports are still importable.
|
||||
// Header always written so empty exports remain importable.
|
||||
w.WriteLine("Date,ChatType,ChatTypeName,Sender,Content,Receiver,ContentId");
|
||||
var count = 0;
|
||||
foreach (var m in messages)
|
||||
@@ -201,13 +223,27 @@ internal static class MessageExporter
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\b': sb.Append("\\b"); break;
|
||||
case '\f': sb.Append("\\f"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
case '"':
|
||||
sb.Append("\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
sb.Append("\\\\");
|
||||
break;
|
||||
case '\b':
|
||||
sb.Append("\\b");
|
||||
break;
|
||||
case '\f':
|
||||
sb.Append("\\f");
|
||||
break;
|
||||
case '\n':
|
||||
sb.Append("\\n");
|
||||
break;
|
||||
case '\r':
|
||||
sb.Append("\\r");
|
||||
break;
|
||||
case '\t':
|
||||
sb.Append("\\t");
|
||||
break;
|
||||
default:
|
||||
if (c < 0x20)
|
||||
sb.Append($"\\u{(int)c:x4}");
|
||||
@@ -0,0 +1,258 @@
|
||||
using Dalamud;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.FontIdentifier;
|
||||
using Dalamud.Interface.GameFonts;
|
||||
using Dalamud.Interface.ManagedFontAtlas;
|
||||
using Dalamud.Interface.Utility;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
public class FontManager
|
||||
{
|
||||
internal IFontHandle Axis = null!;
|
||||
internal IFontHandle AxisItalic = null!;
|
||||
|
||||
internal IFontHandle RegularFont = null!;
|
||||
internal IFontHandle? ItalicFont;
|
||||
|
||||
internal IFontHandle FontAwesome = null!;
|
||||
|
||||
private ushort[] Ranges = [];
|
||||
private ushort[] JpRange = [];
|
||||
|
||||
public static readonly HashSet<float> AxisFontSizeList =
|
||||
[
|
||||
9.6f,
|
||||
10f,
|
||||
12f,
|
||||
14f,
|
||||
16f,
|
||||
18f,
|
||||
18.4f,
|
||||
20f,
|
||||
23f,
|
||||
34f,
|
||||
36f,
|
||||
40f,
|
||||
45f,
|
||||
46f,
|
||||
68f,
|
||||
90f,
|
||||
];
|
||||
|
||||
// Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources
|
||||
private static byte[]? HellionFontBytes;
|
||||
|
||||
// Returns null when the embedded font resource is missing. Should never
|
||||
// happen on a signed release build, but a broken csproj or hand-rolled
|
||||
// dev build can land here. Caller falls back to the system font path so
|
||||
// the plugin still loads instead of crashing the whole UiBuilder.
|
||||
private static byte[]? TryGetHellionFontBytes()
|
||||
{
|
||||
if (HellionFontBytes is not null)
|
||||
return HellionFontBytes;
|
||||
|
||||
using var stream = typeof(FontManager).Assembly.GetManifestResourceStream(
|
||||
"HellionFont.ttf"
|
||||
);
|
||||
if (stream is null)
|
||||
{
|
||||
Plugin.LogProxy.Warning(
|
||||
"Hellion font resource missing — falling back to system default font."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
HellionFontBytes = ms.ToArray();
|
||||
return HellionFontBytes;
|
||||
}
|
||||
|
||||
private unsafe void SetUpRanges()
|
||||
{
|
||||
ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges)
|
||||
{
|
||||
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
|
||||
foreach (var range in ranges)
|
||||
builder.AddRanges((ushort*)range);
|
||||
|
||||
if (chars != null)
|
||||
{
|
||||
for (var i = 0; i < chars.Count; i += 2)
|
||||
{
|
||||
if (chars[i] == 0)
|
||||
break;
|
||||
|
||||
for (var j = (uint)chars[i]; j <= chars[i + 1]; j++)
|
||||
builder.AddChar((ushort)j);
|
||||
}
|
||||
}
|
||||
|
||||
// Ingame supported ranges
|
||||
var reader = new FdtReader(Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data);
|
||||
foreach (var c in reader.Glyphs)
|
||||
builder.AddChar(c.Char);
|
||||
|
||||
// various symbols
|
||||
// French
|
||||
// Romanian
|
||||
// builder.AddText("←→↑↓《》■※☀★★☆♥♡ヅツッシ☀☁☂℃℉°♀♂♠♣♦♣♧®©™€$£♯♭♪✓√◎◆◇♦■□〇●△▽▼▲‹›≤≥<«“”─\~");
|
||||
builder.AddText("Œœ");
|
||||
builder.AddText("ĂăÂâÎîȘșȚț");
|
||||
|
||||
// "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460
|
||||
for (var i = 0x2460; i <= 0x24B5; i++)
|
||||
builder.AddChar((char)i);
|
||||
|
||||
builder.AddChar('⓪');
|
||||
return builder.BuildRangesToArray();
|
||||
}
|
||||
|
||||
var ranges = new List<nint> { (nint)ImGui.GetIO().Fonts.GetGlyphRangesDefault() };
|
||||
foreach (var extraRange in Enum.GetValues<ExtraGlyphRanges>())
|
||||
if (Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange))
|
||||
ranges.Add(extraRange.Range());
|
||||
|
||||
Ranges = BuildRange(null, ranges.ToArray());
|
||||
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
||||
}
|
||||
|
||||
// CPU-bound build offloaded to Task.Run; runs parallel with theme init
|
||||
public async Task BuildFontsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Run(BuildFonts, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void BuildFonts()
|
||||
{
|
||||
SetUpRanges();
|
||||
|
||||
Axis = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(
|
||||
new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2))
|
||||
);
|
||||
AxisItalic = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(
|
||||
new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2))
|
||||
{
|
||||
SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6,
|
||||
}
|
||||
);
|
||||
|
||||
FontAwesome = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
{
|
||||
e.OnPreBuild(tk =>
|
||||
tk.AddFontAwesomeIconFont(new SafeFontConfig { SizePx = GetFontSize() })
|
||||
);
|
||||
e.OnPostBuild(tk => tk.FitRatio(tk.Font));
|
||||
});
|
||||
|
||||
RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
e.OnPreBuild(tk =>
|
||||
{
|
||||
// v1.2.0: UseHellionFont controls font size selection
|
||||
var basePt = Plugin.Config.UseHellionFont
|
||||
? Plugin.Config.FontSizeV2
|
||||
: Plugin.Config.GlobalFontV2.SizePt;
|
||||
var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges };
|
||||
// F10.2: if the embedded font is missing, drop to the system font
|
||||
// path rather than letting the UiBuilder throw.
|
||||
var hellionBytes = Plugin.Config.UseHellionFont ? TryGetHellionFontBytes() : null;
|
||||
config.MergeFont = hellionBytes is not null
|
||||
? tk.AddFontFromMemory(hellionBytes, config, "Hellion-Exo2")
|
||||
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global");
|
||||
|
||||
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
|
||||
config.GlyphRanges = JpRange;
|
||||
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
})
|
||||
);
|
||||
|
||||
if (Plugin.Config.ItalicEnabled)
|
||||
{
|
||||
ItalicFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
e.OnPreBuild(tk =>
|
||||
{
|
||||
var config = new SafeFontConfig
|
||||
{
|
||||
SizePt = Plugin.Config.ItalicFontV2.SizePt,
|
||||
GlyphRanges = Ranges,
|
||||
};
|
||||
config.MergeFont = AddFontWithFallback(
|
||||
tk,
|
||||
Plugin.Config.ItalicFontV2.FontId,
|
||||
config,
|
||||
"italic"
|
||||
);
|
||||
|
||||
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
|
||||
config.GlyphRanges = JpRange;
|
||||
AddFontWithFallback(
|
||||
tk,
|
||||
Plugin.Config.JapaneseFontV2.FontId,
|
||||
config,
|
||||
"japanese"
|
||||
);
|
||||
|
||||
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
|
||||
tk.AddGameSymbol(config);
|
||||
|
||||
tk.Font = config.MergeFont;
|
||||
})
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
ItalicFont = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add font with fallback to NotoSansCjkRegular if unavailable
|
||||
private static ImFontPtr AddFontWithFallback(
|
||||
IFontAtlasBuildToolkitPreBuild tk,
|
||||
IFontId fontId,
|
||||
SafeFontConfig config,
|
||||
string slot
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
return fontId.AddToBuildToolkit(tk, config);
|
||||
}
|
||||
catch (Exception e)
|
||||
when (e
|
||||
is FileNotFoundException
|
||||
or DirectoryNotFoundException
|
||||
or IOException
|
||||
or InvalidOperationException
|
||||
or ArgumentException
|
||||
)
|
||||
{
|
||||
// Atlas-toolkit throws span IO and validation failures; routing the
|
||||
// wider set through the fallback keeps a corrupt font config from
|
||||
// taking down the whole atlas build.
|
||||
Plugin.LogProxy.Warning(
|
||||
e,
|
||||
$"Configured {slot} font failed to load ({e.GetType().Name}), "
|
||||
+ "falling back to NotoSansCjkRegular"
|
||||
);
|
||||
var fallback = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular);
|
||||
return fallback.AddToBuildToolkit(tk, config);
|
||||
}
|
||||
}
|
||||
|
||||
public static float SizeInPt(float px) => (float)(px * 3.0 / 4.0);
|
||||
|
||||
public static float SizeInPx(float pt) => (float)(pt * 4.0 / 3.0);
|
||||
|
||||
public static float GetFontSize() =>
|
||||
Plugin.Config.FontsEnabled
|
||||
? Plugin.Config.GlobalFontV2.SizePx
|
||||
: SizeInPx(Plugin.Config.FontSizeV2);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
using System.Text;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.GameFunctions.Types;
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game.Config;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Hooking;
|
||||
@@ -17,31 +13,69 @@ using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
using InteropGenerator.Runtime;
|
||||
using Lumina.Text.ReadOnly;
|
||||
|
||||
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
// Functions
|
||||
[Signature("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 48 8D B9 ?? ?? ?? ?? 33 C0")]
|
||||
private readonly delegate* unmanaged<RaptureLogModule*, ushort, Utf8String*, Utf8String*, ulong, ulong, ushort, byte, int, byte, void> PrintTellNative = null!;
|
||||
private readonly delegate* unmanaged<
|
||||
RaptureLogModule*,
|
||||
ushort,
|
||||
Utf8String*,
|
||||
Utf8String*,
|
||||
ulong,
|
||||
ulong,
|
||||
ushort,
|
||||
byte,
|
||||
int,
|
||||
byte,
|
||||
void> PrintTellNative = null!;
|
||||
|
||||
[Signature("E8 ?? ?? ?? ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 48 8D 8C 24 ?? ?? ?? ?? E8 ?? ?? ?? ?? B0 ?? 48 8B 8C 24")]
|
||||
private readonly delegate* unmanaged<NetworkModule*, ulong, ushort, Utf8String*, Utf8String*, ushort, ushort, byte> SendTellNative = null!;
|
||||
[Signature(
|
||||
"E8 ?? ?? ?? ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 48 8D 8C 24 ?? ?? ?? ?? E8 ?? ?? ?? ?? B0 ?? 48 8B 8C 24"
|
||||
)]
|
||||
private readonly delegate* unmanaged<
|
||||
NetworkModule*,
|
||||
ulong,
|
||||
ushort,
|
||||
Utf8String*,
|
||||
Utf8String*,
|
||||
ushort,
|
||||
ushort,
|
||||
byte> SendTellNative = null!;
|
||||
|
||||
// Client::UI::AddonChatLog.OnRefresh
|
||||
[Signature("40 53 57 41 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 4D 8B F8", DetourName = nameof(ChatLogRefreshDetour))]
|
||||
[Signature(
|
||||
"40 53 57 41 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 4D 8B F8",
|
||||
DetourName = nameof(ChatLogRefreshDetour)
|
||||
)]
|
||||
private Hook<ChatLogRefreshDelegate>? ChatLogRefreshHook = null!;
|
||||
private delegate byte ChatLogRefreshDelegate(nint log, ushort eventId, AtkValue* value);
|
||||
|
||||
// Replace with CS version later
|
||||
[Signature("48 89 5C 24 ?? 55 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 83 B9", DetourName = nameof(ContextMenuTellInForayDetour))]
|
||||
[Signature(
|
||||
"48 89 5C 24 ?? 55 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 83 B9",
|
||||
DetourName = nameof(ContextMenuTellInForayDetour)
|
||||
)]
|
||||
private Hook<ContextMenuTellInForayDelegate>? ContextMenuTellInForayHook = null!;
|
||||
private delegate void ContextMenuTellInForayDelegate(RaptureShellModule* module, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason);
|
||||
private delegate void ContextMenuTellInForayDelegate(
|
||||
RaptureShellModule* module,
|
||||
Utf8String* playerName,
|
||||
Utf8String* worldName,
|
||||
ushort worldId,
|
||||
ulong accountId,
|
||||
ulong contentId,
|
||||
ushort reason
|
||||
);
|
||||
|
||||
private readonly Hook<AgentChatLog.Delegates.ChangeChannelName>? ChangeChannelNameHook;
|
||||
private readonly Hook<RaptureShellModule.Delegates.ReplyInSelectedChatMode>? ReplyInSelectedChatModeHook;
|
||||
@@ -58,7 +92,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
FullName = 0,
|
||||
SurnameAbbreviated = 1,
|
||||
ForenameAbbreviated = 2,
|
||||
Initials = 3
|
||||
Initials = 3,
|
||||
}
|
||||
|
||||
private long LastPlayerNameDisplayTypeRefresh;
|
||||
@@ -72,13 +106,25 @@ internal sealed unsafe class Chat : IDisposable
|
||||
ChatLogRefreshHook?.Enable();
|
||||
ContextMenuTellInForayHook?.Enable();
|
||||
|
||||
ChangeChannelNameHook = Plugin.GameInteropProvider.HookFromAddress<AgentChatLog.Delegates.ChangeChannelName>(AgentChatLog.MemberFunctionPointers.ChangeChannelName, ChangeChannelNameDetour);
|
||||
ChangeChannelNameHook =
|
||||
Plugin.GameInteropProvider.HookFromAddress<AgentChatLog.Delegates.ChangeChannelName>(
|
||||
AgentChatLog.MemberFunctionPointers.ChangeChannelName,
|
||||
ChangeChannelNameDetour
|
||||
);
|
||||
ChangeChannelNameHook.Enable();
|
||||
|
||||
ReplyInSelectedChatModeHook = Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.ReplyInSelectedChatMode>(RaptureShellModule.MemberFunctionPointers.ReplyInSelectedChatMode, ReplyInSelectedChatModeDetour);
|
||||
ReplyInSelectedChatModeHook =
|
||||
Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.ReplyInSelectedChatMode>(
|
||||
RaptureShellModule.MemberFunctionPointers.ReplyInSelectedChatMode,
|
||||
ReplyInSelectedChatModeDetour
|
||||
);
|
||||
ReplyInSelectedChatModeHook.Enable();
|
||||
|
||||
SetChatLogTellTargetHook = Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.SetContextTellTarget>(RaptureShellModule.MemberFunctionPointers.SetContextTellTarget, SetContextTellTarget);
|
||||
SetChatLogTellTargetHook =
|
||||
Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.SetContextTellTarget>(
|
||||
RaptureShellModule.MemberFunctionPointers.SetContextTellTarget,
|
||||
SetContextTellTarget
|
||||
);
|
||||
SetChatLogTellTargetHook.Enable();
|
||||
|
||||
Plugin.ClientState.Login += Login;
|
||||
@@ -108,12 +154,13 @@ internal sealed unsafe class Chat : IDisposable
|
||||
return utf == null ? null : utf->ToString();
|
||||
}
|
||||
|
||||
private static int GetRotateIdx(RotateMode mode) => mode switch
|
||||
{
|
||||
RotateMode.Forward => 1,
|
||||
RotateMode.Reverse => -1,
|
||||
_ => 0,
|
||||
};
|
||||
private static int GetRotateIdx(RotateMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
RotateMode.Forward => 1,
|
||||
RotateMode.Reverse => -1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
internal static void RotateLinkshellHistory(RotateMode mode)
|
||||
{
|
||||
@@ -127,8 +174,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
internal static void RotateCrossLinkshellHistory(RotateMode mode) =>
|
||||
UIModule.Instance()->RotateCrossLinkshellHistory(GetRotateIdx(mode));
|
||||
|
||||
// This function looks up a channel's user-defined color.
|
||||
// If this function ever returns 0, it returns null instead.
|
||||
// Look up a channel's user-defined color, returns null if 0
|
||||
internal uint? GetChannelColor(ChatType type)
|
||||
{
|
||||
var parent = type.Parent();
|
||||
@@ -168,13 +214,12 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
if (Plugin.Functions.KeybindManager.DirectChat && LastTypedCharacter != null)
|
||||
{
|
||||
// FIXME: this whole system sucks
|
||||
// FIXME v2: I hate everything about this, but it works
|
||||
// Capture the just-typed character input
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
string? input = null;
|
||||
|
||||
var utf8Bytes = MemoryHelper.ReadRaw((nint)LastTypedCharacter+0x4, 2);
|
||||
var utf8Bytes = MemoryHelper.ReadRaw((nint)LastTypedCharacter + 0x4, 2);
|
||||
var chars = Encoding.UTF8.GetString(utf8Bytes).ToCharArray();
|
||||
if (chars.Length == 0)
|
||||
return;
|
||||
@@ -185,11 +230,13 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(null)) { Input = input, });
|
||||
Plugin.ChatLogWindow.Activated(
|
||||
new ChatActivatedArgs(new ChannelSwitchInfo(null)) { Input = input }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in chat Activated event");
|
||||
Plugin.LogProxy.Error(ex, "Error in chat Activated event");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -197,7 +244,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
string? addIfNotPresent = null;
|
||||
|
||||
var str = value + 2;
|
||||
if (str != null && ((int) str->Type & 0xF) == (int) ValueType.String && str->String.HasValue)
|
||||
if (str != null && ((int)str->Type & 0xF) == (int)ValueType.String && str->String.HasValue)
|
||||
{
|
||||
var add = str->String.ToString();
|
||||
if (add.Length > 0)
|
||||
@@ -206,23 +253,23 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// We already called this function once, so we skip the duplicated call
|
||||
// Also return the original value here so that vanilla chat receives all information
|
||||
// Prevent duplicate calls
|
||||
if (Plugin.ChatLogWindow.TellSpecial)
|
||||
{
|
||||
Plugin.Log.Information("Return early to prevent duplicated call...");
|
||||
return ChatLogRefreshHook!.Original(log, eventId, value);
|
||||
}
|
||||
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(null)) { AddIfNotPresent = addIfNotPresent, });
|
||||
Plugin.ChatLogWindow.Activated(
|
||||
new ChatActivatedArgs(new ChannelSwitchInfo(null))
|
||||
{
|
||||
AddIfNotPresent = addIfNotPresent,
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in chat Activated event");
|
||||
Plugin.LogProxy.Error(ex, "Error in chat Activated event");
|
||||
}
|
||||
|
||||
// prevent the game from focusing the chat log
|
||||
return 1;
|
||||
return 1; // Prevent vanilla chat log from gaining focus
|
||||
}
|
||||
|
||||
private CStringPointer ChangeChannelNameDetour(AgentChatLog* agent)
|
||||
@@ -231,9 +278,9 @@ internal sealed unsafe class Chat : IDisposable
|
||||
if (agent == null)
|
||||
return ret;
|
||||
|
||||
var channel = (uint) RaptureShellModule.Instance()->ChatType;
|
||||
var channel = (uint)RaptureShellModule.Instance()->ChatType;
|
||||
if (channel is 17 or 18)
|
||||
channel = (uint) InputChannel.Tell;
|
||||
channel = (uint)InputChannel.Tell;
|
||||
|
||||
var name = SeString.Parse(agent->ChannelLabel);
|
||||
if (name.Payloads.Count == 0)
|
||||
@@ -248,18 +295,18 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
string? playerName = null;
|
||||
ushort worldId = 0;
|
||||
if (channel == (uint) InputChannel.Tell)
|
||||
if (channel == (uint)InputChannel.Tell)
|
||||
{
|
||||
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
|
||||
worldId = agent->TellWorldId;
|
||||
Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}");
|
||||
Plugin.LogProxy.Debug($"Detected tell target '[redacted]'@{worldId}");
|
||||
}
|
||||
|
||||
Plugin.CurrentTab.CurrentChannel = new UsedChannel
|
||||
{
|
||||
Channel = (InputChannel) channel,
|
||||
Channel = (InputChannel)channel,
|
||||
Name = nameChunks,
|
||||
TellTarget = playerName != null ? new TellTarget(playerName, worldId, 0, 0) : null
|
||||
TellTarget = playerName != null ? new TellTarget(playerName, worldId, 0, 0) : null,
|
||||
};
|
||||
|
||||
return ret;
|
||||
@@ -274,33 +321,68 @@ internal sealed unsafe class Chat : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
SetChannel((InputChannel) replyMode);
|
||||
SetChannel((InputChannel)replyMode);
|
||||
ReplyInSelectedChatModeHook!.Original(agent);
|
||||
}
|
||||
|
||||
private bool SetContextTellTarget(RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason, bool setChatType)
|
||||
private bool SetContextTellTarget(
|
||||
RaptureShellModule* a1,
|
||||
Utf8String* playerName,
|
||||
Utf8String* worldName,
|
||||
ushort worldId,
|
||||
ulong accountId,
|
||||
ulong contentId,
|
||||
ushort reason,
|
||||
bool setChatType
|
||||
)
|
||||
{
|
||||
if (playerName != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = new TellTarget(playerName->ToString(), worldId, contentId, (TellReason) reason);
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell, permanent: setChatType))
|
||||
{
|
||||
TellReason = (TellReason) reason,
|
||||
TellTarget = target,
|
||||
});
|
||||
var target = new TellTarget(
|
||||
playerName->ToString(),
|
||||
worldId,
|
||||
contentId,
|
||||
(TellReason)reason
|
||||
);
|
||||
Plugin.ChatLogWindow.Activated(
|
||||
new ChatActivatedArgs(
|
||||
new ChannelSwitchInfo(InputChannel.Tell, permanent: setChatType)
|
||||
)
|
||||
{
|
||||
TellReason = (TellReason)reason,
|
||||
TellTarget = target,
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in chat Activated event");
|
||||
Plugin.LogProxy.Error(ex, "Error in chat Activated event");
|
||||
}
|
||||
}
|
||||
|
||||
return SetChatLogTellTargetHook!.Original(a1, playerName, worldName, worldId, accountId, contentId, reason, setChatType);
|
||||
return SetChatLogTellTargetHook!.Original(
|
||||
a1,
|
||||
playerName,
|
||||
worldName,
|
||||
worldId,
|
||||
accountId,
|
||||
contentId,
|
||||
reason,
|
||||
setChatType
|
||||
);
|
||||
}
|
||||
|
||||
private void ContextMenuTellInForayDetour(RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason)
|
||||
private void ContextMenuTellInForayDetour(
|
||||
RaptureShellModule* a1,
|
||||
Utf8String* playerName,
|
||||
Utf8String* worldName,
|
||||
ushort worldId,
|
||||
ulong accountId,
|
||||
ulong contentId,
|
||||
ushort reason
|
||||
)
|
||||
{
|
||||
if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel)
|
||||
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
|
||||
@@ -309,36 +391,56 @@ internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = new TellTarget(playerName->ToString(), worldId, contentId, (TellReason) reason);
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell))
|
||||
{
|
||||
TellReason = (TellReason) reason,
|
||||
TellTarget = target,
|
||||
TellSpecial = Sheets.IsInForay(), // Handle Eureka/Bozja special
|
||||
});
|
||||
var target = new TellTarget(
|
||||
playerName->ToString(),
|
||||
worldId,
|
||||
contentId,
|
||||
(TellReason)reason
|
||||
);
|
||||
Plugin.ChatLogWindow.Activated(
|
||||
new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell))
|
||||
{
|
||||
TellReason = (TellReason)reason,
|
||||
TellTarget = target,
|
||||
TellSpecial = Sheets.IsInForay(), // Handle Eureka/Bozja special
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in chat Activated event");
|
||||
Plugin.LogProxy.Error(ex, "Error in chat Activated event");
|
||||
}
|
||||
}
|
||||
|
||||
ContextMenuTellInForayHook!.Original(a1, playerName, worldName, worldId, accountId, contentId, reason);
|
||||
ContextMenuTellInForayHook!.Original(
|
||||
a1,
|
||||
playerName,
|
||||
worldName,
|
||||
worldId,
|
||||
accountId,
|
||||
contentId,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the channel is any non-linkshell channel, or if the
|
||||
/// linkshell actually exists.
|
||||
/// </summary>
|
||||
internal static bool ValidAnyLinkshell(InputChannel channel)
|
||||
// ---------------------------------------------------------------
|
||||
// Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
|
||||
// - Renamed ValidAnyLinkshell -> IsChannelOrExistingLinkshell. The
|
||||
// name now states intent: returns true for any non-linkshell
|
||||
// channel, or a linkshell index that actually exists.
|
||||
// ---------------------------------------------------------------
|
||||
internal static bool IsChannelOrExistingLinkshell(InputChannel channel)
|
||||
{
|
||||
var idx = channel.LinkshellIndex();
|
||||
if (idx == uint.MaxValue || channel.IsExtraChatLinkshell())
|
||||
return true;
|
||||
if (channel.IsLinkshell() && ValidLinkshell(idx))
|
||||
return true;
|
||||
if (channel.IsCrossLinkshell() && ValidCrossLinkshell(idx))
|
||||
return true;
|
||||
|
||||
if (channel.IsLinkshell())
|
||||
return ValidLinkshell(idx);
|
||||
|
||||
if (channel.IsCrossLinkshell())
|
||||
return ValidCrossLinkshell(idx);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,17 +448,22 @@ internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
if (idx > 7)
|
||||
return false;
|
||||
return InfoProxyLinkshell.Instance()->LinkShells[(int) idx].Id != 0;
|
||||
return InfoProxyLinkshell.Instance()->LinkShells[(int)idx].Id != 0;
|
||||
}
|
||||
|
||||
internal static bool ValidCrossLinkshell(uint idx)
|
||||
{
|
||||
if (idx > 7)
|
||||
return false;
|
||||
return InfoProxyCrossWorldLinkshell.Instance()->CrossWorldLinkshells[(int) idx].Name.Length > 0;
|
||||
return InfoProxyCrossWorldLinkshell.Instance()->CrossWorldLinkshells[(int)idx].Name.Length
|
||||
> 0;
|
||||
}
|
||||
|
||||
private static uint? RotateLinkshell(uint currentIndex, RotateMode rotate, Func<uint, bool> validFn)
|
||||
private static uint? RotateLinkshell(
|
||||
uint currentIndex,
|
||||
RotateMode rotate,
|
||||
Func<uint, bool> validFn
|
||||
)
|
||||
{
|
||||
if (rotate == RotateMode.None)
|
||||
return null;
|
||||
@@ -365,13 +472,12 @@ internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
RotateMode.Forward => 1,
|
||||
RotateMode.Reverse => -1,
|
||||
_ => 1
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
// Iterate up to 8 times to find a valid linkshell.
|
||||
for (var i = 0; i < 8; i++)
|
||||
for (var i = 0; i < 8; i++) // Find valid linkshell within 8 iterations
|
||||
{
|
||||
currentIndex = (uint) ((8 + currentIndex + delta) % 8);
|
||||
currentIndex = (uint)((8 + currentIndex + delta) % 8);
|
||||
if (validFn(currentIndex))
|
||||
return currentIndex;
|
||||
}
|
||||
@@ -379,28 +485,43 @@ internal sealed unsafe class Chat : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static InputChannel? ResolveTempInputChannel(InputChannel? currentTempChannel, InputChannel channel, RotateMode rotate)
|
||||
internal static InputChannel? ResolveTempInputChannel(
|
||||
InputChannel? currentTempChannel,
|
||||
InputChannel channel,
|
||||
RotateMode rotate
|
||||
)
|
||||
{
|
||||
switch (channel)
|
||||
{
|
||||
case InputChannel.Linkshell1 or InputChannel.CrossLinkshell1 when rotate != RotateMode.None:
|
||||
case InputChannel.Linkshell1
|
||||
or InputChannel.CrossLinkshell1 when rotate != RotateMode.None:
|
||||
{
|
||||
var module = UIModule.Instance();
|
||||
|
||||
var currentIndex = channel is InputChannel.Linkshell1 ? (uint) module->LinkshellCycle : (uint) module->CrossWorldLinkshellCycle;
|
||||
var currentIndex =
|
||||
channel is InputChannel.Linkshell1
|
||||
? (uint)module->LinkshellCycle
|
||||
: (uint)module->CrossWorldLinkshellCycle;
|
||||
if (currentTempChannel != null)
|
||||
{
|
||||
switch (channel)
|
||||
{
|
||||
case InputChannel.Linkshell1 when currentTempChannel.Value.IsLinkshell():
|
||||
case InputChannel.CrossLinkshell1 when currentTempChannel.Value.IsCrossLinkshell():
|
||||
case InputChannel.CrossLinkshell1
|
||||
when currentTempChannel.Value.IsCrossLinkshell():
|
||||
currentIndex = currentTempChannel.Value.LinkshellIndex();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell);
|
||||
return channel + idx;
|
||||
var idx = RotateLinkshell(
|
||||
currentIndex,
|
||||
rotate,
|
||||
channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell
|
||||
);
|
||||
// RotateLinkshell returns null when no valid linkshell is found within 8 iterations.
|
||||
// Forward the null so the caller can keep the existing channel instead of crashing on nullable arithmetic.
|
||||
return idx is null ? null : channel + idx.Value; // null if not found, otherwise new channel
|
||||
}
|
||||
default:
|
||||
return channel;
|
||||
@@ -409,11 +530,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
internal void SetChannel(InputChannel channel, TellTarget? tellTarget = null)
|
||||
{
|
||||
// ExtraChat linkshells aren't supported in game so we never want to
|
||||
// call the ChangeChatChannel function with them.
|
||||
//
|
||||
// Callers should call ChatLogWindow.SetChannel() which handles
|
||||
// ExtraChat channels
|
||||
// Ignore ExtraChat linkshells (use ChatLogWindow.SetChannel() instead)
|
||||
if (channel.IsExtraChatLinkshell())
|
||||
return;
|
||||
|
||||
@@ -422,18 +539,30 @@ internal sealed unsafe class Chat : IDisposable
|
||||
if (idx == uint.MaxValue)
|
||||
idx = 0;
|
||||
|
||||
if (!ValidAnyLinkshell(channel))
|
||||
return;
|
||||
// ---------------------------------------------------------------
|
||||
// Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
|
||||
// - Wrap ChangeChatChannel in the validity check instead of
|
||||
// early-returning. The previous early return skipped Dtor and
|
||||
// leaked the native Utf8String allocated a few lines above.
|
||||
// ---------------------------------------------------------------
|
||||
if (IsChannelOrExistingLinkshell(channel))
|
||||
RaptureShellModule
|
||||
.Instance()
|
||||
->ChangeChatChannel(tellTarget != null ? 17 : (int)channel, idx, target, true);
|
||||
|
||||
RaptureShellModule.Instance()->ChangeChatChannel(tellTarget != null ? 17 : (int)channel, idx, target, true);
|
||||
target->Dtor(true);
|
||||
}
|
||||
|
||||
internal void SetEurekaTellChannel(string name, string worldName, ushort worldId, ulong accountId, ulong objectId, ushort reason, bool setChatType)
|
||||
internal void SetEurekaTellChannel(
|
||||
string name,
|
||||
string worldName,
|
||||
ushort worldId,
|
||||
ulong accountId,
|
||||
ulong objectId,
|
||||
ushort reason,
|
||||
bool setChatType
|
||||
)
|
||||
{
|
||||
// param6 is 0 for contentId and 1 for objectId
|
||||
// param7 is always 0 ?
|
||||
|
||||
if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel)
|
||||
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
|
||||
|
||||
@@ -444,7 +573,17 @@ internal sealed unsafe class Chat : IDisposable
|
||||
var utfName = Utf8String.FromString(name);
|
||||
var utfWorld = Utf8String.FromString(worldName);
|
||||
|
||||
RaptureShellModule.Instance()->SetTellTargetInForay(utfName, utfWorld, worldId, accountId, objectId, reason, setChatType);
|
||||
RaptureShellModule
|
||||
.Instance()
|
||||
->SetTellTargetInForay(
|
||||
utfName,
|
||||
utfWorld,
|
||||
worldId,
|
||||
accountId,
|
||||
objectId,
|
||||
reason,
|
||||
setChatType
|
||||
);
|
||||
|
||||
utfName->Dtor(true);
|
||||
utfWorld->Dtor(true);
|
||||
@@ -473,19 +612,30 @@ internal sealed unsafe class Chat : IDisposable
|
||||
mes->Dtor(true);
|
||||
}
|
||||
|
||||
internal void SendTell(TellReason reason, ulong contentId, string name, ushort homeWorld, byte[] message, string rawText)
|
||||
internal void SendTell(
|
||||
TellReason reason,
|
||||
ulong contentId,
|
||||
string name,
|
||||
ushort homeWorld,
|
||||
byte[] message,
|
||||
string rawText
|
||||
)
|
||||
{
|
||||
if (contentId == 0)
|
||||
{
|
||||
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
|
||||
Plugin.Log.Warning("Tried to send a tell with ContentId being 0, sorry this is an internal error.");
|
||||
Plugin.LogProxy.Warning(
|
||||
"Tried to send a tell with ContentId being 0, sorry this is an internal error."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var uName = Utf8String.FromString(name);
|
||||
var uMessage = Utf8String.FromSequence(message.NullTerminate());
|
||||
|
||||
var encoded = Utf8String.FromUtf8String(PronounModule.Instance()->ProcessString(uMessage, true));
|
||||
var encoded = Utf8String.FromUtf8String(
|
||||
PronounModule.Instance()->ProcessString(uMessage, true)
|
||||
);
|
||||
var decoded = EncodeMessage(rawText);
|
||||
AutoTranslate.ReplaceWithPayload(ref decoded);
|
||||
|
||||
@@ -498,9 +648,28 @@ internal sealed unsafe class Chat : IDisposable
|
||||
if (reason == TellReason.Direct)
|
||||
reason = TellReason.Friend;
|
||||
|
||||
var ok = SendTellNative(networkModule, contentId, homeWorld, uName, encoded, (ushort) reason, homeWorld);
|
||||
var ok = SendTellNative(
|
||||
networkModule,
|
||||
contentId,
|
||||
homeWorld,
|
||||
uName,
|
||||
encoded,
|
||||
(ushort)reason,
|
||||
homeWorld
|
||||
);
|
||||
if (ok == 1)
|
||||
PrintTellNative(logModule, 33, uName, &decodedUtf8String, 0, contentId, homeWorld, 255, 0, 0);
|
||||
PrintTellNative(
|
||||
logModule,
|
||||
33,
|
||||
uName,
|
||||
&decodedUtf8String,
|
||||
0,
|
||||
contentId,
|
||||
homeWorld,
|
||||
255,
|
||||
0,
|
||||
0
|
||||
);
|
||||
else
|
||||
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
|
||||
|
||||
@@ -509,7 +678,8 @@ internal sealed unsafe class Chat : IDisposable
|
||||
uMessage->Dtor(true);
|
||||
}
|
||||
|
||||
private static byte[] EncodeMessage(string str) {
|
||||
private static byte[] EncodeMessage(string str)
|
||||
{
|
||||
using var input = new Utf8String(str);
|
||||
using var output = new Utf8String();
|
||||
|
||||
@@ -522,7 +692,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
var uC = Utf8String.FromString(c.ToString());
|
||||
|
||||
uC->SanitizeString((AllowedEntities) 0x27F);
|
||||
uC->SanitizeString((AllowedEntities)0x27F);
|
||||
var wasValid = uC->ToString().Length > 0;
|
||||
|
||||
uC->Dtor(true);
|
||||
@@ -535,7 +705,7 @@ internal sealed unsafe class Chat : IDisposable
|
||||
var ok = Plugin.GameConfig.TryGet(UiConfigOption.LogNameType, out uint type);
|
||||
if (!ok || !Enum.IsDefined(typeof(PlayerNameDisplayType), type))
|
||||
return PlayerNameDisplayType.FullName;
|
||||
return (PlayerNameDisplayType) type;
|
||||
return (PlayerNameDisplayType)type;
|
||||
}
|
||||
|
||||
internal string AbbreviatePlayerName(string playerName)
|
||||
@@ -555,20 +725,21 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
return CurrentPlayerNameDisplayType switch
|
||||
{
|
||||
PlayerNameDisplayType.SurnameAbbreviated => $"{split.First()} {split.Last().FirstOrDefault('A')}.",
|
||||
PlayerNameDisplayType.ForenameAbbreviated => $"{split.First().FirstOrDefault('A')}. {split.Last()}",
|
||||
PlayerNameDisplayType.Initials => $"{split.First().FirstOrDefault('A')}. {split.Last().FirstOrDefault('A')}.",
|
||||
_ => playerName
|
||||
PlayerNameDisplayType.SurnameAbbreviated =>
|
||||
$"{split.First()} {split.Last().FirstOrDefault('A')}.",
|
||||
PlayerNameDisplayType.ForenameAbbreviated =>
|
||||
$"{split.First().FirstOrDefault('A')}. {split.Last()}",
|
||||
PlayerNameDisplayType.Initials =>
|
||||
$"{split.First().FirstOrDefault('A')}. {split.Last().FirstOrDefault('A')}.",
|
||||
_ => playerName,
|
||||
};
|
||||
}
|
||||
|
||||
internal bool CheckHideFlags()
|
||||
{
|
||||
// Only hide the chat in a cutscene when the vanilla chat would've
|
||||
// also been hidden. This prevents Chat 2 from hiding for a split
|
||||
// second before the cutscene actually starts, because the game sets
|
||||
// the cutscene conditions before processing the skip.
|
||||
// Only hide chat in cutscene when vanilla chat would also be hidden
|
||||
var raptureAtkUnitManager = RaptureAtkUnitManager.Instance();
|
||||
return raptureAtkUnitManager == null || raptureAtkUnitManager->UiFlags.HasFlag(UiFlags.Chat);
|
||||
return raptureAtkUnitManager == null
|
||||
|| raptureAtkUnitManager->UiFlags.HasFlag(UiFlags.Chat);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text;
|
||||
using ChatTwo.Resources;
|
||||
using Dalamud.Memory;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using HellionChat.Resources;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
public unsafe class ChatBox
|
||||
{
|
||||
@@ -15,7 +15,15 @@ public unsafe class ChatBox
|
||||
mes->Dtor(true);
|
||||
}
|
||||
|
||||
public static void SendMessage(string message)
|
||||
public static void SendMessage(string message) => SendMessageUnsafe(ValidateMessage(message));
|
||||
|
||||
// sanitiserOverride allows xUnit to bypass Utf8String->SanitizeString (game memory only).
|
||||
// Returns encoded bytes so SendMessage avoids a second GetBytes call.
|
||||
// TEST-MIRROR: ../../../Hellion Build test/GameFunctions/ChatBoxTests.cs
|
||||
internal static byte[] ValidateMessage(
|
||||
string message,
|
||||
Func<string, string>? sanitiserOverride = null
|
||||
)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
if (bytes.Length == 0)
|
||||
@@ -24,20 +32,19 @@ public unsafe class ChatBox
|
||||
if (bytes.Length > 500)
|
||||
throw new ArgumentException(Language.ChatBox_Error_Too_Long, nameof(message));
|
||||
|
||||
if (message.Length != SanitiseText(message).Length)
|
||||
var sanitiser = sanitiserOverride ?? SanitiseText;
|
||||
if (message.Length != sanitiser(message).Length)
|
||||
throw new ArgumentException(Language.ChatBox_Error_Invalid, nameof(message));
|
||||
|
||||
SendMessageUnsafe(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static string SanitiseText(string text)
|
||||
{
|
||||
var uText = Utf8String.FromString(text);
|
||||
|
||||
uText->SanitizeString((AllowedEntities) 0x27F);
|
||||
uText->SanitizeString((AllowedEntities)0x27F);
|
||||
var sanitised = uText->ToString();
|
||||
uText->Dtor(true);
|
||||
|
||||
return sanitised;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
using ChatTwo.Util;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
internal sealed unsafe class Context
|
||||
{
|
||||
internal static void InviteToNoviceNetwork(string name, ushort world)
|
||||
{
|
||||
// can specify content id if we have it, but there's no need
|
||||
InfoProxyNoviceNetwork.Instance()->InviteToNoviceNetwork(0, 0, world, name.ToTerminatedBytes());
|
||||
InfoProxyNoviceNetwork
|
||||
.Instance()
|
||||
->InviteToNoviceNetwork(0, 0, world, name.ToTerminatedBytes());
|
||||
}
|
||||
|
||||
internal static void TryOn(uint itemId, byte stainId)
|
||||
+71
-63
@@ -16,14 +16,24 @@ using Lumina.Excel;
|
||||
using Lumina.Excel.Sheets;
|
||||
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
internal unsafe class GameFunctions : IDisposable
|
||||
{
|
||||
internal const string NewGamePlusAddonName = "QuestRedo";
|
||||
|
||||
#region Hooks
|
||||
[Signature("E8 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? 48 8B D0 49 8D 4F", DetourName = nameof(ResolveTextCommandPlaceholderDetour))]
|
||||
[Signature(
|
||||
"E8 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? 48 8B D0 49 8D 4F",
|
||||
DetourName = nameof(ResolveTextCommandPlaceholderDetour)
|
||||
)]
|
||||
private Hook<ResolveTextCommandPlaceholderDelegate>? ResolveTextCommandPlaceholderHook = null!;
|
||||
private delegate nint ResolveTextCommandPlaceholderDelegate(nint a1, byte* placeholderText, byte a3, byte a4);
|
||||
private delegate nint ResolveTextCommandPlaceholderDelegate(
|
||||
nint a1,
|
||||
byte* placeholderText,
|
||||
byte a3,
|
||||
byte a4
|
||||
);
|
||||
#endregion
|
||||
|
||||
private Plugin Plugin { get; }
|
||||
@@ -37,7 +47,6 @@ internal unsafe class GameFunctions : IDisposable
|
||||
Chat = new Chat(Plugin);
|
||||
|
||||
Plugin.GameInteropProvider.InitializeFromAttributes(this);
|
||||
|
||||
ResolveTextCommandPlaceholderHook?.Enable();
|
||||
}
|
||||
|
||||
@@ -45,41 +54,30 @@ internal unsafe class GameFunctions : IDisposable
|
||||
{
|
||||
Chat.Dispose();
|
||||
KeybindManager.Dispose();
|
||||
|
||||
ResolveTextCommandPlaceholderHook?.Dispose();
|
||||
|
||||
Marshal.FreeHGlobal(PlaceholderNamePtr);
|
||||
}
|
||||
|
||||
internal void SendFriendRequest(string name, ushort world)
|
||||
{
|
||||
internal void SendFriendRequest(string name, ushort world) =>
|
||||
ListCommand(name, world, "friendlist");
|
||||
}
|
||||
|
||||
internal void AddToBlacklist(string name, ushort world)
|
||||
{
|
||||
ListCommand(name, world, "blist");
|
||||
}
|
||||
internal void AddToBlacklist(string name, ushort world) => ListCommand(name, world, "blist");
|
||||
|
||||
internal void AddToMuteList(ulong accountId, ulong contentId, string name, short worldId)
|
||||
{
|
||||
internal void AddToMuteList(ulong accountId, ulong contentId, string name, short worldId) =>
|
||||
AgentMutelist.Instance()->Add(accountId, contentId, name, worldId);
|
||||
}
|
||||
|
||||
internal void AddToTermsList(SeString content)
|
||||
{
|
||||
internal void AddToTermsList(SeString content) =>
|
||||
AgentTermFilter.Instance()->OpenNewFilterWindow(content.EncodeWithNullTerminator());
|
||||
}
|
||||
|
||||
private void ListCommand(string name, ushort world, string commandName)
|
||||
{
|
||||
var worldRow = Sheets.WorldSheet.GetRow(world);
|
||||
|
||||
ReplacementName = $"{name}@{worldRow.Name.ToString()}";
|
||||
ChatBox.SendMessage($"/{commandName} add {Placeholder}");
|
||||
}
|
||||
|
||||
private static T* GetAddon<T>(string name) where T : unmanaged
|
||||
private static T* GetAddon<T>(string name)
|
||||
where T : unmanaged
|
||||
{
|
||||
var addon = RaptureAtkModule.Instance()->RaptureAtkUnitManager.GetAddonByName(name);
|
||||
return addon != null && addon->IsReady ? (T*)addon : null;
|
||||
@@ -97,7 +95,6 @@ internal unsafe class GameFunctions : IDisposable
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
SetAddonInteractable($"ChatLogPanel_{i}", interactable);
|
||||
|
||||
SetAddonInteractable("ChatLog", interactable);
|
||||
}
|
||||
|
||||
@@ -113,7 +110,6 @@ internal unsafe class GameFunctions : IDisposable
|
||||
var agent = AgentItemDetail.Instance();
|
||||
var addon = GetAddon<AtkUnitBase>("ItemDetail");
|
||||
|
||||
// atkStage ain't gonna be null or we have bigger problems
|
||||
if (agent == null || addon == null)
|
||||
return;
|
||||
|
||||
@@ -122,23 +118,19 @@ internal unsafe class GameFunctions : IDisposable
|
||||
agent->Index = 0;
|
||||
agent->Flag1 &= 0xEF;
|
||||
agent->ItemId = id;
|
||||
// agent->Flag2 = 1;
|
||||
// agent->Flag3 = 0;
|
||||
// TODO: Revert whenever CS is merged
|
||||
|
||||
// TODO: Revert when CS offset lands in a release build.
|
||||
*(byte*)((nint)agent + 0x21A) = 1;
|
||||
*(byte*)((nint)agent + 0x21E) = 0;
|
||||
|
||||
// This just probably needs to be set
|
||||
agent->AddonId = addon->Id;
|
||||
|
||||
// Skips early return
|
||||
atkStage->TooltipManager.TooltipType |= 2;
|
||||
addon->Show(false, 15);
|
||||
}
|
||||
|
||||
internal static void CloseItemTooltip()
|
||||
{
|
||||
// hide addon first to prevent the "addon close" sound
|
||||
// Hide addon first to suppress the "addon close" sound.
|
||||
var addon = GetAddon<AtkUnitBase>("ItemDetail");
|
||||
if (addon != null)
|
||||
addon->Hide(true, false, 0);
|
||||
@@ -156,35 +148,31 @@ internal unsafe class GameFunctions : IDisposable
|
||||
|
||||
internal static void OpenPartyFinder()
|
||||
{
|
||||
// this whole method: 6.05: 84433A (FF 97 ?? ?? ?? ?? 41 B4 01)
|
||||
// 6.05: 84433A (FF 97 ?? ?? ?? ?? 41 B4 01)
|
||||
var lfg = AgentLookingForGroup.Instance();
|
||||
if (lfg->IsAgentActive())
|
||||
{
|
||||
var addonId = lfg->GetAddonId();
|
||||
var atkModule = RaptureAtkModule.Instance();
|
||||
var atkModuleVtbl = (void**) atkModule->AtkModule.VirtualTable;
|
||||
var vf27 = (delegate* unmanaged<RaptureAtkModule*, ulong, ulong, byte>) atkModuleVtbl[27];
|
||||
var atkModuleVtbl = (void**)atkModule->AtkModule.VirtualTable;
|
||||
var vf27 = (delegate* unmanaged<RaptureAtkModule*, ulong, ulong, byte>)
|
||||
atkModuleVtbl[27];
|
||||
vf27(atkModule, addonId, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 6.05: 8443DD
|
||||
if (*(uint*) ((nint) lfg + 0x2C20) > 0)
|
||||
if (*(uint*)((nint)lfg + 0x2C20) > 0)
|
||||
lfg->Hide();
|
||||
else
|
||||
lfg->Show();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsMentor()
|
||||
{
|
||||
return PlayerState.Instance()->IsMentor();
|
||||
}
|
||||
internal static bool IsMentor() => PlayerState.Instance()->IsMentor();
|
||||
|
||||
internal static InfoProxyCommonList.CharacterData[] GetFriends()
|
||||
{
|
||||
return InfoProxyFriendList.Instance()->CharDataSpan.ToArray();
|
||||
}
|
||||
internal static InfoProxyCommonList.CharacterData[] GetFriends() =>
|
||||
InfoProxyFriendList.Instance()->CharDataSpan.ToArray();
|
||||
|
||||
internal static void OpenQuestLog(RowRef<Quest> quest)
|
||||
{
|
||||
@@ -195,7 +183,14 @@ internal unsafe class GameFunctions : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uint.TryParse(splits[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var questId))
|
||||
if (
|
||||
!uint.TryParse(
|
||||
splits[1],
|
||||
NumberStyles.Any,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var questId
|
||||
)
|
||||
)
|
||||
{
|
||||
Plugin.ChatGui.Print("Unable to parse quest id");
|
||||
return;
|
||||
@@ -204,20 +199,12 @@ internal unsafe class GameFunctions : IDisposable
|
||||
AgentQuestJournal.Instance()->OpenForQuest(questId, 1);
|
||||
}
|
||||
|
||||
internal static void OpenPartyFinder(uint id)
|
||||
{
|
||||
internal static void OpenPartyFinder(uint id) =>
|
||||
AgentLookingForGroup.Instance()->OpenListing(id);
|
||||
}
|
||||
|
||||
internal static void OpenAchievement(uint id)
|
||||
{
|
||||
AgentAchievement.Instance()->OpenById(id);
|
||||
}
|
||||
internal static void OpenAchievement(uint id) => AgentAchievement.Instance()->OpenById(id);
|
||||
|
||||
internal static bool IsInInstance()
|
||||
{
|
||||
return Plugin.Condition[ConditionFlag.BoundByDuty56];
|
||||
}
|
||||
internal static bool IsInInstance() => Plugin.Condition[ConditionFlag.BoundByDuty56];
|
||||
|
||||
internal static bool TryOpenAdventurerPlate(ulong playerId)
|
||||
{
|
||||
@@ -228,7 +215,7 @@ internal unsafe class GameFunctions : IDisposable
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.Warning(e, "Unable to open adventurer plate");
|
||||
Plugin.LogProxy.Warning(e, "Unable to open adventurer plate");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -236,26 +223,47 @@ internal unsafe class GameFunctions : IDisposable
|
||||
internal static void ClickNoviceNetworkButton()
|
||||
{
|
||||
var agent = AgentChatLog.Instance();
|
||||
// case 3
|
||||
var value = new AtkValue { Type = ValueType.Int, Int = 3, };
|
||||
var value = new AtkValue { Type = ValueType.Int, Int = 3 }; // case 3
|
||||
var result = 0;
|
||||
var vf0 = *(delegate* unmanaged<AgentChatLog*, int*, AtkValue*, ulong, ulong, int*>*) agent->VirtualTable;
|
||||
var vf0 = *(delegate* unmanaged<AgentChatLog*, int*, AtkValue*, ulong, ulong, int*>*)
|
||||
agent->VirtualTable;
|
||||
vf0(agent, &result, &value, 0, 0);
|
||||
}
|
||||
|
||||
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(128);
|
||||
private const int PlaceholderBufferSize = 128;
|
||||
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(PlaceholderBufferSize);
|
||||
private readonly string Placeholder = $"<{Guid.NewGuid():N}>";
|
||||
private string? ReplacementName;
|
||||
|
||||
private nint ResolveTextCommandPlaceholderDetour(nint a1, byte* placeholderText, byte a3, byte a4)
|
||||
private nint ResolveTextCommandPlaceholderDetour(
|
||||
nint a1,
|
||||
byte* placeholderText,
|
||||
byte a3,
|
||||
byte a4
|
||||
)
|
||||
{
|
||||
var placeholder = MemoryHelper.ReadStringNullTerminated((nint) placeholderText);
|
||||
// Hook field is nullable due to the Signature attribute, but will never
|
||||
// be null during normal execution; guard covers the teardown race only.
|
||||
if (ResolveTextCommandPlaceholderHook is null)
|
||||
return nint.Zero;
|
||||
|
||||
var placeholder = MemoryHelper.ReadStringNullTerminated((nint)placeholderText);
|
||||
if (ReplacementName == null || placeholder != Placeholder)
|
||||
return ResolveTextCommandPlaceholderHook!.Original(a1, placeholderText, a3, a4);
|
||||
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
|
||||
|
||||
// Guard against a malformed ReplacementName overflowing the 128-byte buffer.
|
||||
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
|
||||
if (byteCount >= PlaceholderBufferSize)
|
||||
{
|
||||
Plugin.LogProxy.Warning(
|
||||
$"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original."
|
||||
);
|
||||
ReplacementName = null;
|
||||
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
|
||||
}
|
||||
|
||||
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
|
||||
ReplacementName = null;
|
||||
|
||||
return PlaceholderNamePtr;
|
||||
}
|
||||
}
|
||||
+159
-95
@@ -1,23 +1,25 @@
|
||||
using System.Numerics;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.GameFunctions.Types;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Game.ClientState.Keys;
|
||||
using Dalamud.Game.Config;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using ModifierFlag = ChatTwo.GameFunctions.Types.ModifierFlag;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.GameFunctions.Types;
|
||||
using HellionChat.Util;
|
||||
using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
internal enum KeyboardSource {
|
||||
internal enum KeyboardSource
|
||||
{
|
||||
Game,
|
||||
ImGui
|
||||
ImGui,
|
||||
}
|
||||
|
||||
internal unsafe class KeybindManager : IDisposable {
|
||||
internal unsafe class KeybindManager : IDisposable
|
||||
{
|
||||
private Plugin Plugin { get; }
|
||||
|
||||
internal bool DirectChat;
|
||||
@@ -26,70 +28,79 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
private bool VanillaTextInputHasFocus;
|
||||
|
||||
private readonly Dictionary<string, Keybind> Keybinds = new();
|
||||
private static readonly IReadOnlyDictionary<string, ChannelSwitchInfo> KeybindsToIntercept = new Dictionary<string, ChannelSwitchInfo>
|
||||
{
|
||||
["CMD_CHAT"] = new(null),
|
||||
["CMD_COMMAND"] = new(null, text: "/"),
|
||||
["CMD_REPLY"] = new(InputChannel.Tell, rotate: RotateMode.Forward),
|
||||
["CMD_REPLY_REV"] = new(InputChannel.Tell, rotate: RotateMode.Reverse),
|
||||
["CMD_SAY"] = new(InputChannel.Say),
|
||||
["CMD_YELL"] = new(InputChannel.Yell),
|
||||
["CMD_SHOUT"] = new(InputChannel.Shout),
|
||||
["CMD_PARTY"] = new(InputChannel.Party),
|
||||
["CMD_ALLIANCE"] = new(InputChannel.Alliance),
|
||||
["CMD_FREECOM"] = new(InputChannel.FreeCompany),
|
||||
["PVPTEAM_CHAT"] = new(InputChannel.PvpTeam),
|
||||
["CMD_CWLINKSHELL"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Forward),
|
||||
["CMD_CWLINKSHELL_REV"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Reverse),
|
||||
["CMD_CWLINKSHELL_1"] = new(InputChannel.CrossLinkshell1),
|
||||
["CMD_CWLINKSHELL_2"] = new(InputChannel.CrossLinkshell2),
|
||||
["CMD_CWLINKSHELL_3"] = new(InputChannel.CrossLinkshell3),
|
||||
["CMD_CWLINKSHELL_4"] = new(InputChannel.CrossLinkshell4),
|
||||
["CMD_CWLINKSHELL_5"] = new(InputChannel.CrossLinkshell5),
|
||||
["CMD_CWLINKSHELL_6"] = new(InputChannel.CrossLinkshell6),
|
||||
["CMD_CWLINKSHELL_7"] = new(InputChannel.CrossLinkshell7),
|
||||
["CMD_CWLINKSHELL_8"] = new(InputChannel.CrossLinkshell8),
|
||||
["CMD_LINKSHELL"] = new(InputChannel.Linkshell1, rotate: RotateMode.Forward),
|
||||
["CMD_LINKSHELL_REV"] = new(InputChannel.Linkshell1, rotate: RotateMode.Reverse),
|
||||
["CMD_LINKSHELL_1"] = new(InputChannel.Linkshell1),
|
||||
["CMD_LINKSHELL_2"] = new(InputChannel.Linkshell2),
|
||||
["CMD_LINKSHELL_3"] = new(InputChannel.Linkshell3),
|
||||
["CMD_LINKSHELL_4"] = new(InputChannel.Linkshell4),
|
||||
["CMD_LINKSHELL_5"] = new(InputChannel.Linkshell5),
|
||||
["CMD_LINKSHELL_6"] = new(InputChannel.Linkshell6),
|
||||
["CMD_LINKSHELL_7"] = new(InputChannel.Linkshell7),
|
||||
["CMD_LINKSHELL_8"] = new(InputChannel.Linkshell8),
|
||||
["CMD_BEGINNER"] = new(InputChannel.NoviceNetwork),
|
||||
["CMD_REPLY_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Forward),
|
||||
["CMD_REPLY_REV_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Reverse),
|
||||
["CMD_SAY_ALWAYS"] = new(InputChannel.Say, true),
|
||||
["CMD_YELL_ALWAYS"] = new(InputChannel.Yell, true),
|
||||
["CMD_PARTY_ALWAYS"] = new(InputChannel.Party, true),
|
||||
["CMD_ALLIANCE_ALWAYS"] = new(InputChannel.Alliance, true),
|
||||
["CMD_FREECOM_ALWAYS"] = new(InputChannel.FreeCompany, true),
|
||||
["PVPTEAM_CHAT_ALWAYS"] = new(InputChannel.PvpTeam, true),
|
||||
["CMD_CWLINKSHELL_ALWAYS"] = new(InputChannel.CrossLinkshell1, true, RotateMode.Forward),
|
||||
["CMD_CWLINKSHELL_ALWAYS_REV"] = new(InputChannel.CrossLinkshell1, true, RotateMode.Reverse),
|
||||
["CMD_CWLINKSHELL_1_ALWAYS"] = new(InputChannel.CrossLinkshell1, true),
|
||||
["CMD_CWLINKSHELL_2_ALWAYS"] = new(InputChannel.CrossLinkshell2, true),
|
||||
["CMD_CWLINKSHELL_3_ALWAYS"] = new(InputChannel.CrossLinkshell3, true),
|
||||
["CMD_CWLINKSHELL_4_ALWAYS"] = new(InputChannel.CrossLinkshell4, true),
|
||||
["CMD_CWLINKSHELL_5_ALWAYS"] = new(InputChannel.CrossLinkshell5, true),
|
||||
["CMD_CWLINKSHELL_6_ALWAYS"] = new(InputChannel.CrossLinkshell6, true),
|
||||
["CMD_CWLINKSHELL_7_ALWAYS"] = new(InputChannel.CrossLinkshell7, true),
|
||||
["CMD_CWLINKSHELL_8_ALWAYS"] = new(InputChannel.CrossLinkshell8, true),
|
||||
["CMD_LINKSHELL_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Forward),
|
||||
["CMD_LINKSHELL_REV_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Reverse),
|
||||
["CMD_LINKSHELL_1_ALWAYS"] = new(InputChannel.Linkshell1, true),
|
||||
["CMD_LINKSHELL_2_ALWAYS"] = new(InputChannel.Linkshell2, true),
|
||||
["CMD_LINKSHELL_3_ALWAYS"] = new(InputChannel.Linkshell3, true),
|
||||
["CMD_LINKSHELL_4_ALWAYS"] = new(InputChannel.Linkshell4, true),
|
||||
["CMD_LINKSHELL_5_ALWAYS"] = new(InputChannel.Linkshell5, true),
|
||||
["CMD_LINKSHELL_6_ALWAYS"] = new(InputChannel.Linkshell6, true),
|
||||
["CMD_LINKSHELL_7_ALWAYS"] = new(InputChannel.Linkshell7, true),
|
||||
["CMD_LINKSHELL_8_ALWAYS"] = new(InputChannel.Linkshell8, true),
|
||||
["CMD_BEGINNER_ALWAYS"] = new(InputChannel.NoviceNetwork, true)
|
||||
};
|
||||
private static readonly IReadOnlyDictionary<string, ChannelSwitchInfo> KeybindsToIntercept =
|
||||
new Dictionary<string, ChannelSwitchInfo>
|
||||
{
|
||||
["CMD_CHAT"] = new(null),
|
||||
["CMD_COMMAND"] = new(null, text: "/"),
|
||||
["CMD_REPLY"] = new(InputChannel.Tell, rotate: RotateMode.Forward),
|
||||
["CMD_REPLY_REV"] = new(InputChannel.Tell, rotate: RotateMode.Reverse),
|
||||
["CMD_SAY"] = new(InputChannel.Say),
|
||||
["CMD_YELL"] = new(InputChannel.Yell),
|
||||
["CMD_SHOUT"] = new(InputChannel.Shout),
|
||||
["CMD_PARTY"] = new(InputChannel.Party),
|
||||
["CMD_ALLIANCE"] = new(InputChannel.Alliance),
|
||||
["CMD_FREECOM"] = new(InputChannel.FreeCompany),
|
||||
["PVPTEAM_CHAT"] = new(InputChannel.PvpTeam),
|
||||
["CMD_CWLINKSHELL"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Forward),
|
||||
["CMD_CWLINKSHELL_REV"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Reverse),
|
||||
["CMD_CWLINKSHELL_1"] = new(InputChannel.CrossLinkshell1),
|
||||
["CMD_CWLINKSHELL_2"] = new(InputChannel.CrossLinkshell2),
|
||||
["CMD_CWLINKSHELL_3"] = new(InputChannel.CrossLinkshell3),
|
||||
["CMD_CWLINKSHELL_4"] = new(InputChannel.CrossLinkshell4),
|
||||
["CMD_CWLINKSHELL_5"] = new(InputChannel.CrossLinkshell5),
|
||||
["CMD_CWLINKSHELL_6"] = new(InputChannel.CrossLinkshell6),
|
||||
["CMD_CWLINKSHELL_7"] = new(InputChannel.CrossLinkshell7),
|
||||
["CMD_CWLINKSHELL_8"] = new(InputChannel.CrossLinkshell8),
|
||||
["CMD_LINKSHELL"] = new(InputChannel.Linkshell1, rotate: RotateMode.Forward),
|
||||
["CMD_LINKSHELL_REV"] = new(InputChannel.Linkshell1, rotate: RotateMode.Reverse),
|
||||
["CMD_LINKSHELL_1"] = new(InputChannel.Linkshell1),
|
||||
["CMD_LINKSHELL_2"] = new(InputChannel.Linkshell2),
|
||||
["CMD_LINKSHELL_3"] = new(InputChannel.Linkshell3),
|
||||
["CMD_LINKSHELL_4"] = new(InputChannel.Linkshell4),
|
||||
["CMD_LINKSHELL_5"] = new(InputChannel.Linkshell5),
|
||||
["CMD_LINKSHELL_6"] = new(InputChannel.Linkshell6),
|
||||
["CMD_LINKSHELL_7"] = new(InputChannel.Linkshell7),
|
||||
["CMD_LINKSHELL_8"] = new(InputChannel.Linkshell8),
|
||||
["CMD_BEGINNER"] = new(InputChannel.NoviceNetwork),
|
||||
["CMD_REPLY_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Forward),
|
||||
["CMD_REPLY_REV_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Reverse),
|
||||
["CMD_SAY_ALWAYS"] = new(InputChannel.Say, true),
|
||||
["CMD_YELL_ALWAYS"] = new(InputChannel.Yell, true),
|
||||
["CMD_PARTY_ALWAYS"] = new(InputChannel.Party, true),
|
||||
["CMD_ALLIANCE_ALWAYS"] = new(InputChannel.Alliance, true),
|
||||
["CMD_FREECOM_ALWAYS"] = new(InputChannel.FreeCompany, true),
|
||||
["PVPTEAM_CHAT_ALWAYS"] = new(InputChannel.PvpTeam, true),
|
||||
["CMD_CWLINKSHELL_ALWAYS"] = new(
|
||||
InputChannel.CrossLinkshell1,
|
||||
true,
|
||||
RotateMode.Forward
|
||||
),
|
||||
["CMD_CWLINKSHELL_ALWAYS_REV"] = new(
|
||||
InputChannel.CrossLinkshell1,
|
||||
true,
|
||||
RotateMode.Reverse
|
||||
),
|
||||
["CMD_CWLINKSHELL_1_ALWAYS"] = new(InputChannel.CrossLinkshell1, true),
|
||||
["CMD_CWLINKSHELL_2_ALWAYS"] = new(InputChannel.CrossLinkshell2, true),
|
||||
["CMD_CWLINKSHELL_3_ALWAYS"] = new(InputChannel.CrossLinkshell3, true),
|
||||
["CMD_CWLINKSHELL_4_ALWAYS"] = new(InputChannel.CrossLinkshell4, true),
|
||||
["CMD_CWLINKSHELL_5_ALWAYS"] = new(InputChannel.CrossLinkshell5, true),
|
||||
["CMD_CWLINKSHELL_6_ALWAYS"] = new(InputChannel.CrossLinkshell6, true),
|
||||
["CMD_CWLINKSHELL_7_ALWAYS"] = new(InputChannel.CrossLinkshell7, true),
|
||||
["CMD_CWLINKSHELL_8_ALWAYS"] = new(InputChannel.CrossLinkshell8, true),
|
||||
["CMD_LINKSHELL_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Forward),
|
||||
["CMD_LINKSHELL_REV_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Reverse),
|
||||
["CMD_LINKSHELL_1_ALWAYS"] = new(InputChannel.Linkshell1, true),
|
||||
["CMD_LINKSHELL_2_ALWAYS"] = new(InputChannel.Linkshell2, true),
|
||||
["CMD_LINKSHELL_3_ALWAYS"] = new(InputChannel.Linkshell3, true),
|
||||
["CMD_LINKSHELL_4_ALWAYS"] = new(InputChannel.Linkshell4, true),
|
||||
["CMD_LINKSHELL_5_ALWAYS"] = new(InputChannel.Linkshell5, true),
|
||||
["CMD_LINKSHELL_6_ALWAYS"] = new(InputChannel.Linkshell6, true),
|
||||
["CMD_LINKSHELL_7_ALWAYS"] = new(InputChannel.Linkshell7, true),
|
||||
["CMD_LINKSHELL_8_ALWAYS"] = new(InputChannel.Linkshell8, true),
|
||||
["CMD_BEGINNER_ALWAYS"] = new(InputChannel.NoviceNetwork, true),
|
||||
};
|
||||
|
||||
// List of keys that can be used as a part of keybinds while the chat is
|
||||
// focused WITHOUT modifiers. All other keys can only be used if their
|
||||
@@ -353,12 +364,22 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
return key.TryToImGui(out var imguiKey) && ImGui.IsKeyPressed(imguiKey);
|
||||
}
|
||||
|
||||
private static bool ComboPressed(KeyboardSource source, VirtualKey key, ModifierFlag modifier, ModifierFlag? modifierState = null, bool modifiersOnly = false)
|
||||
private static bool ComboPressed(
|
||||
KeyboardSource source,
|
||||
VirtualKey key,
|
||||
ModifierFlag modifier,
|
||||
ModifierFlag? modifierState = null,
|
||||
bool modifiersOnly = false
|
||||
)
|
||||
{
|
||||
// When we're in an input, we don't want to process any keybinds that
|
||||
// don't have a modifier (or only use shift) and are not explicitly
|
||||
// whitelisted.
|
||||
if (modifiersOnly && !ModifierlessChatKeys.Contains(key) && modifier is ModifierFlag.None or ModifierFlag.Shift)
|
||||
if (
|
||||
modifiersOnly
|
||||
&& !ModifierlessChatKeys.Contains(key)
|
||||
&& modifier is ModifierFlag.None or ModifierFlag.Shift
|
||||
)
|
||||
return false;
|
||||
|
||||
modifierState ??= GetModifiers(source);
|
||||
@@ -366,26 +387,43 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
{
|
||||
KeybindMode.Strict => modifier == modifierState.Value,
|
||||
KeybindMode.Flexible => modifierState.Value.HasFlag(modifier),
|
||||
_ => false
|
||||
_ => false,
|
||||
};
|
||||
|
||||
return KeyPressed(source, key) && modifierPressed;
|
||||
}
|
||||
|
||||
private static bool ConfigKeybindPressed(KeyboardSource source, ConfigKeyBind? bind, ModifierFlag? modifierState = null, bool modifiersOnly = false)
|
||||
private static bool ConfigKeybindPressed(
|
||||
KeyboardSource source,
|
||||
ConfigKeyBind? bind,
|
||||
ModifierFlag? modifierState = null,
|
||||
bool modifiersOnly = false
|
||||
)
|
||||
{
|
||||
return bind != null && ComboPressed(source, bind.Key, bind.Modifier, modifierState: modifierState, modifiersOnly: modifiersOnly);
|
||||
return bind != null
|
||||
&& ComboPressed(
|
||||
source,
|
||||
bind.Key,
|
||||
bind.Modifier,
|
||||
modifierState: modifierState,
|
||||
modifiersOnly: modifiersOnly
|
||||
);
|
||||
}
|
||||
|
||||
private void HandleKeybinds(IFramework _ ) => HandleKeybinds(KeyboardSource.Game);
|
||||
private void HandleKeybinds(IFramework _) => HandleKeybinds(KeyboardSource.Game);
|
||||
|
||||
internal void HandleKeybinds(KeyboardSource source, bool ignoreChatOpen = false, bool modifiersOnly = false)
|
||||
internal void HandleKeybinds(
|
||||
KeyboardSource source,
|
||||
bool ignoreChatOpen = false,
|
||||
bool modifiersOnly = false
|
||||
)
|
||||
{
|
||||
// Refresh current keybinds every 5s
|
||||
if (LastRefresh + 5 * 1000 < Environment.TickCount64)
|
||||
{
|
||||
UpdateKeybinds();
|
||||
DirectChat = Plugin.GameConfig.TryGet(UiControlOption.DirectChat, out bool option) && option;
|
||||
DirectChat =
|
||||
Plugin.GameConfig.TryGet(UiControlOption.DirectChat, out bool option) && option;
|
||||
LastRefresh = Environment.TickCount64;
|
||||
}
|
||||
|
||||
@@ -414,13 +452,13 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabForward))
|
||||
{
|
||||
Plugin.KeyState[Plugin.Config.ChatTabForward!.Key] = false;
|
||||
Plugin.ChatLogWindow.ChangeTabDelta(1);
|
||||
DispatchTabDelta(1);
|
||||
return;
|
||||
}
|
||||
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabBackward))
|
||||
{
|
||||
Plugin.KeyState[Plugin.Config.ChatTabBackward!.Key] = false;
|
||||
Plugin.ChatLogWindow.ChangeTabDelta(-1);
|
||||
DispatchTabDelta(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -433,10 +471,18 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
|
||||
void Intercept(VirtualKey vk, ModifierFlag modifier)
|
||||
{
|
||||
if (!ComboPressed(source, vk, modifier, modifierState: modifierState, modifiersOnly: modifiersOnly))
|
||||
if (
|
||||
!ComboPressed(
|
||||
source,
|
||||
vk,
|
||||
modifier,
|
||||
modifierState: modifierState,
|
||||
modifiersOnly: modifiersOnly
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
var bits = BitOperations.PopCount((uint) modifier);
|
||||
var bits = BitOperations.PopCount((uint)modifier);
|
||||
if (bits < currentBest.Item3)
|
||||
return;
|
||||
|
||||
@@ -457,14 +503,32 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
try
|
||||
{
|
||||
TellReason? reason = info.Channel == InputChannel.Tell ? TellReason.Reply : null;
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(info) { TellReason = reason, });
|
||||
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(info) { TellReason = reason });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Error in chat Activated event");
|
||||
Plugin.LogProxy.Error(ex, "Error in chat Activated event");
|
||||
}
|
||||
}
|
||||
|
||||
// v0.6.0 — central dispatch for ChatTabForward/Backward. If a pop-out
|
||||
// window currently has its compact input focused, the keybind is
|
||||
// forwarded into that pop-out's ChatInputBar so the user navigates
|
||||
// tabs in the window they are typing in. Otherwise the main window
|
||||
// handles it (= v0.5.x behavior).
|
||||
private void DispatchTabDelta(int delta)
|
||||
{
|
||||
foreach (var popout in Plugin.ChatLogWindow.ActivePopouts)
|
||||
{
|
||||
if (popout.HasFocusedInputBar && popout.InputBar != null)
|
||||
{
|
||||
popout.InputBar.HandleKeybindForward(delta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Plugin.ChatLogWindow.ChangeTabDelta(delta);
|
||||
}
|
||||
|
||||
private static Keybind GetKeybind(string id)
|
||||
{
|
||||
var outData = new FFXIVClientStructs.FFXIV.Client.System.Input.Keybind();
|
||||
@@ -476,11 +540,11 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
var key2 = outData.KeySettings[1];
|
||||
return new Keybind
|
||||
{
|
||||
Key1 = RemapInvalidVirtualKey((VirtualKey) key1.Key),
|
||||
Modifier1 = (ModifierFlag) key1.KeyModifier,
|
||||
Key1 = RemapInvalidVirtualKey((VirtualKey)key1.Key),
|
||||
Modifier1 = (ModifierFlag)key1.KeyModifier,
|
||||
|
||||
Key2 = RemapInvalidVirtualKey((VirtualKey) key2.Key),
|
||||
Modifier2 = (ModifierFlag) key2.KeyModifier,
|
||||
Key2 = RemapInvalidVirtualKey((VirtualKey)key2.Key),
|
||||
Modifier2 = (ModifierFlag)key2.KeyModifier,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,9 +552,9 @@ internal unsafe class KeybindManager : IDisposable {
|
||||
{
|
||||
return key switch
|
||||
{
|
||||
VirtualKey.F23 => VirtualKey.OEM_2, // /?
|
||||
(VirtualKey) 140 => VirtualKey.OEM_7, // '"
|
||||
_ => key
|
||||
VirtualKey.F23 => VirtualKey.OEM_2, // /?
|
||||
(VirtualKey)140 => VirtualKey.OEM_7, // '"
|
||||
_ => key,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
using ChatTwo.Resources;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||
using HellionChat.Resources;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace ChatTwo.GameFunctions;
|
||||
namespace HellionChat.GameFunctions;
|
||||
|
||||
internal static unsafe class Party
|
||||
{
|
||||
internal static void InviteSameWorld(string name, ushort world, ulong contentId)
|
||||
{
|
||||
// this only works if target is on the same world
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes())
|
||||
{
|
||||
InfoProxyPartyInvite.Instance()->InviteToParty(contentId, namePtr, world);
|
||||
}
|
||||
}
|
||||
@@ -44,14 +45,16 @@ internal static unsafe class Party
|
||||
|
||||
internal static void Kick(string name, ulong contentId)
|
||||
{
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes())
|
||||
{
|
||||
AgentPartyMember.Instance()->Kick(namePtr, 0, contentId);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Promote(string name, ulong contentId)
|
||||
{
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes())
|
||||
{
|
||||
AgentPartyMember.Instance()->Promote(namePtr, 0, contentId);
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -1,14 +1,20 @@
|
||||
using ChatTwo.Code;
|
||||
using HellionChat.Code;
|
||||
|
||||
namespace ChatTwo.GameFunctions.Types;
|
||||
namespace HellionChat.GameFunctions.Types;
|
||||
|
||||
internal class ChannelSwitchInfo {
|
||||
internal class ChannelSwitchInfo
|
||||
{
|
||||
internal InputChannel? Channel { get; }
|
||||
internal bool Permanent { get; }
|
||||
internal RotateMode Rotate { get; }
|
||||
internal string? Text { get; }
|
||||
|
||||
internal ChannelSwitchInfo(InputChannel? channel, bool permanent = false, RotateMode rotate = RotateMode.None, string? text = null)
|
||||
internal ChannelSwitchInfo(
|
||||
InputChannel? channel,
|
||||
bool permanent = false,
|
||||
RotateMode rotate = RotateMode.None,
|
||||
string? text = null
|
||||
)
|
||||
{
|
||||
Channel = channel;
|
||||
Permanent = permanent;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace ChatTwo.GameFunctions.Types;
|
||||
namespace HellionChat.GameFunctions.Types;
|
||||
|
||||
internal sealed class ChatActivatedArgs
|
||||
{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user