Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e66ae1f5b4 | |||
| 281a1e172f | |||
| 45a5035426 | |||
| e1931fc7d2 | |||
| 2201478a54 | |||
| 50963ccf1b | |||
| fde85e6d69 | |||
| c22b169b73 | |||
| 6839ccaf34 | |||
| fa108c2271 | |||
| 395a0d7c98 | |||
| b76bfb3cfc | |||
| 0512e4729c | |||
| 654f24c609 | |||
| 0e2a14197c | |||
| 52e163a472 | |||
| e086afe2a8 | |||
| c97ce7543b | |||
| cca4571470 | |||
| 444d7f8e2e | |||
| 71ae95d79c | |||
| 9a38f7f094 | |||
| c33e519bb9 | |||
| 14e585ef63 | |||
| d4aa3971c5 | |||
| e9ec587e3b | |||
| 39cd7ab801 | |||
| bb6259e14d | |||
| 757370dd53 | |||
| 3f35b76c54 | |||
| 74bdc4f927 | |||
| eb379d84ef | |||
| 7add74dbbe | |||
| e91c7a3888 | |||
| f8b0804321 | |||
| a9d4e9bd69 | |||
| 7e3e4c8b72 | |||
| 397c84be2c | |||
| 269708150d | |||
| a2977ef75b | |||
| baa4d011e8 | |||
| 4810e8b518 | |||
| 133f5c536f | |||
| 92bb368d2b | |||
| 07f47f32e3 | |||
| 141fcbf074 | |||
| 32c410e8e2 | |||
| 824037e55f | |||
| 173cb76bea | |||
| 2736551505 | |||
| 0679a0e57a | |||
| 02cbfff748 | |||
| 9c86619c9f | |||
| 6b44310e04 | |||
| 59332ce9ea | |||
| 462530dec5 | |||
| 8e964ca498 | |||
| 1f2cb000a2 | |||
| 4f25c2756b | |||
| de0d2c80cd | |||
| 2ce30383d9 | |||
| a857714064 | |||
| 705c7d3116 | |||
| bf5d03c7ea | |||
| 960ce980d3 | |||
| c09aa26ffc | |||
| c2801c4113 | |||
| 7bacd1aaba |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
# Generated files
|
# Generated files
|
||||||
ChatTwo/Resources/Language.*.resx linguist-generated=true
|
HellionChat/Resources/Language.*.resx linguist-generated=true
|
||||||
@@ -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
|
||||||
|
use the [private vulnerability advisory](https://github.com/JonKazama-Hellion/HellionChat/security/advisories/new)
|
||||||
|
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,14 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/JonKazama-Hellion/HellionChat/security/advisories/new
|
||||||
|
about: Do not open a public issue for security problems. Use the private advisory 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,55 @@
|
|||||||
|
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,72 @@
|
|||||||
|
<!--
|
||||||
|
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 use a private security
|
||||||
|
advisory instead:
|
||||||
|
https://github.com/JonKazama-Hellion/HellionChat/security/advisories/new
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- [ ] Upstream cherry-pick from Chat 2
|
||||||
|
|
||||||
|
## 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?
|
||||||
|
- Does this affect the upstream cherry-pick path? See docs/UPSTREAM_SYNC.md.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 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,26 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/repo.json`
|
||||||
|
3. Enable, save, then `/xlplugins` → search **Hellion Chat** → install
|
||||||
|
|
||||||
|
## Project documents
|
||||||
|
|
||||||
|
- [README](https://github.com/JonKazama-Hellion/HellionChat/blob/main/README.md) — features, architecture, build
|
||||||
|
- [Privacy notice](https://github.com/JonKazama-Hellion/HellionChat/blob/main/PRIVACY.md) — what the plugin stores and sends
|
||||||
|
- [Third-party notices](https://github.com/JonKazama-Hellion/HellionChat/blob/main/docs/THIRD_PARTY_NOTICES.md) — dependencies and licences
|
||||||
|
- [Security policy](https://github.com/JonKazama-Hellion/HellionChat/blob/main/SECURITY.md) — vulnerability reporting
|
||||||
|
- [Support](https://github.com/JonKazama-Hellion/HellionChat/blob/main/SUPPORT.md) — bug reports, questions, contact paths
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
[EUPL-1.2](https://github.com/JonKazama-Hellion/HellionChat/blob/main/LICENSE).
|
||||||
|
Based on [Chat 2](https://github.com/Infiziert90/ChatTwo) by Infi and Anna,
|
||||||
|
also EUPL-1.2.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
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: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup .NET 10
|
||||||
|
uses: actions/setup-dotnet@v5
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Download Dalamud staging
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$hooks = Join-Path $env:APPDATA "XIVLauncher\addon\Hooks\dev"
|
||||||
|
New-Item -ItemType Directory -Force -Path $hooks | Out-Null
|
||||||
|
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile dalamud.zip
|
||||||
|
Expand-Archive -Force -Path dalamud.zip -DestinationPath $hooks
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore HellionChat/HellionChat.csproj
|
||||||
|
|
||||||
|
- name: Build (Release)
|
||||||
|
run: dotnet build HellionChat/HellionChat.csproj --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Upload build output
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: HellionChat-build-${{ github.run_number }}
|
||||||
|
path: HellionChat/bin/Release/**/HellionChat/**
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Replaces the GitHub default-setup CodeQL scan. The default setup runs
|
||||||
|
# without resolving the Dalamud assemblies (they live in a user-AppData
|
||||||
|
# path) and reports "Low C# analysis quality" because call-target
|
||||||
|
# resolution sits at ~64%. This workflow downloads the Dalamud staging
|
||||||
|
# distribution before the build, runs a manual dotnet build, and then
|
||||||
|
# lets CodeQL analyse the fully-resolved compilation. Quality climbs
|
||||||
|
# back above the 85% thresholds.
|
||||||
|
#
|
||||||
|
# This workflow only consumes trusted inputs: the tag/branch ref via
|
||||||
|
# the standard checkout action, and the Dalamud distribution URL which
|
||||||
|
# is pinned to a goatcorp-controlled GitHub Pages target. No user-
|
||||||
|
# controlled event payload (issue title, PR body, commit message) flows
|
||||||
|
# into a run-step.
|
||||||
|
#
|
||||||
|
# Disable the default setup in the repo before this workflow lands:
|
||||||
|
# Settings -> Code security -> Code scanning -> "CodeQL analysis" tile
|
||||||
|
# -> Switch to advanced.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '17 6 * * 1'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze-csharp:
|
||||||
|
name: Analyze (csharp)
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup .NET 10
|
||||||
|
uses: actions/setup-dotnet@v5
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Download Dalamud staging
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$hooks = Join-Path $env:APPDATA "XIVLauncher\addon\Hooks\dev"
|
||||||
|
New-Item -ItemType Directory -Force -Path $hooks | Out-Null
|
||||||
|
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile dalamud.zip
|
||||||
|
Expand-Archive -Force -Path dalamud.zip -DestinationPath $hooks
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: csharp
|
||||||
|
build-mode: manual
|
||||||
|
queries: security-extended
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore HellionChat/HellionChat.csproj
|
||||||
|
|
||||||
|
- name: Build (Release)
|
||||||
|
run: dotnet build HellionChat/HellionChat.csproj --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: /language:csharp
|
||||||
|
|
||||||
|
analyze-actions:
|
||||||
|
name: Analyze (actions)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: actions
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: /language:actions
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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 GitHub 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 Get-ChildItem) or pinned URLs to
|
||||||
|
# goatcorp / GitHub. Nothing from a webhook event payload (issue/PR
|
||||||
|
# titles, commit messages, etc.) flows into a run-step.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
# Manual recovery trigger. Use when a tag was pushed but the auto-run
|
||||||
|
# was missed or failed: `gh workflow run release.yml -f tag=v0.6.1`.
|
||||||
|
# The tag input is validated against the same semver regex as the
|
||||||
|
# auto-trigger before any string interpolation happens.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Existing tag to (re)release, e.g. v0.6.1'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build and attach release ZIP
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# On push:tags, github.ref_name is the tag — checkout default works.
|
||||||
|
# On workflow_dispatch, ref defaults to the branch the action was
|
||||||
|
# invoked from; we need to explicitly check out the tag the user
|
||||||
|
# supplied so the build comes from the tagged commit, not main.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup .NET 10
|
||||||
|
uses: actions/setup-dotnet@v5
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Download Dalamud staging
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$hooks = Join-Path $env:APPDATA "XIVLauncher\addon\Hooks\dev"
|
||||||
|
New-Item -ItemType Directory -Force -Path $hooks | Out-Null
|
||||||
|
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile dalamud.zip
|
||||||
|
Expand-Archive -Force -Path dalamud.zip -DestinationPath $hooks
|
||||||
|
|
||||||
|
- name: Build (Release)
|
||||||
|
run: dotnet build HellionChat/HellionChat.csproj --configuration Release
|
||||||
|
|
||||||
|
- name: Locate latest.zip
|
||||||
|
id: locate
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$zip = Get-ChildItem -Path HellionChat\bin\Release -Recurse -Filter latest.zip | Select-Object -First 1
|
||||||
|
if (-not $zip)
|
||||||
|
{
|
||||||
|
throw "latest.zip not found under HellionChat\bin\Release"
|
||||||
|
}
|
||||||
|
Write-Host "Found: $($zip.FullName)"
|
||||||
|
"path=$($zip.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# workflow_dispatch carries the user-supplied tag in inputs.tag;
|
||||||
|
# push:tags carries it in github.ref_name. Either way the value
|
||||||
|
# is treated as a PowerShell variable (env-var pass), not as
|
||||||
|
# inline shell text, and validated against the semver regex
|
||||||
|
# below before any string interpolation.
|
||||||
|
TAG_NAME: ${{ github.event.inputs.tag || 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
|
||||||
|
# 2-space yaml indent from each line.
|
||||||
|
$afterMarker = $raw.Substring($idx + $marker.Length)
|
||||||
|
$changelogBody = (($afterMarker -split "`r?`n") | ForEach-Object {
|
||||||
|
if ($_ -match '^ ') { $_.Substring(2) } else { $_ }
|
||||||
|
}) -join "`n"
|
||||||
|
|
||||||
|
$header = "**Hellion Chat $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**Hellion Chat ", 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 "----------------------------------------"
|
||||||
|
|
||||||
|
- name: Attach to GitHub release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
# Explicit tag_name so the action targets the correct release in
|
||||||
|
# both push:tags (auto) and workflow_dispatch (manual recovery)
|
||||||
|
# modes. Without this, dispatch runs would default to the branch
|
||||||
|
# ref (main) and fail to find the release.
|
||||||
|
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
files: ${{ steps.locate.outputs.path }}
|
||||||
|
body_path: release-body.md
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
generate_release_notes: false
|
||||||
@@ -372,6 +372,11 @@ MigrationBackup/
|
|||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
#Specs und Plan datein
|
||||||
|
/.superpowers/
|
||||||
|
|
||||||
|
#Test Datein
|
||||||
|
ChatTwo.Tests
|
||||||
TestResults
|
TestResults
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# AI assistance disclosure
|
|
||||||
|
|
||||||
Per the [Dalamud Plugin AI Usage Policy](https://github.com/goatcorp/DalamudPluginsD17/),
|
|
||||||
this fork uses AI assistance at the **Pair** level. Pair means the maintainer
|
|
||||||
plans the architecture, decides what gets built, reviews each change and
|
|
||||||
tests against the running game; Claude (Anthropic) helps explain Dalamud
|
|
||||||
APIs, suggests patterns, drafts code on request, and reviews approaches.
|
|
||||||
Neither side acts autonomously: nothing ships without the maintainer's
|
|
||||||
review, and Claude can't run the game.
|
|
||||||
|
|
||||||
The level varies by area and over time. Some commits are mostly hand-written
|
|
||||||
with the AI used as a sounding board, others lean more on Claude for an API
|
|
||||||
walkthrough or a code draft that the maintainer then reads, edits and
|
|
||||||
integrates. The maintainer's commitment is to be able to explain why every
|
|
||||||
piece of Hellion code is the way it is — not "I typed every character."
|
|
||||||
|
|
||||||
## 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; the share of human vs. AI authorship varies file by file
|
|
||||||
and is expected to keep shifting toward more hand-written work as the
|
|
||||||
maintainer's plugin-dev experience grows.
|
|
||||||
|
|
||||||
## What AI is not used for
|
|
||||||
|
|
||||||
- **Visual assets.** Logos, icons, banners, screenshots are human-drawn or
|
|
||||||
taken from the running game.
|
|
||||||
- **German translations.** Written by the maintainer (native speaker).
|
|
||||||
|
|
||||||
## Tooling
|
|
||||||
|
|
||||||
- Claude (Anthropic) via Claude Code CLI as the main pair partner.
|
|
||||||
- Context7 / Microsoft Learn for current Dalamud and .NET documentation.
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
Questions about this disclosure: <https://github.com/JonKazama-Hellion/HellionChat/issues>.
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Code of conduct
|
||||||
|
|
||||||
|
HellionChat is a small hobby project. The contributor base is tiny and
|
||||||
|
the moderation overhead I can afford is equally small, so this document
|
||||||
|
is short and direct.
|
||||||
|
|
||||||
|
## What I expect from contributors
|
||||||
|
|
||||||
|
- Be respectful in issues, pull requests, discussions and any other
|
||||||
|
project space (Discord, email).
|
||||||
|
- Keep feedback focused on the code, the design or the documentation.
|
||||||
|
Critique the work, not the person.
|
||||||
|
- Assume good intent. People come from different backgrounds, time
|
||||||
|
zones and skill levels. A clarifying question is almost always a
|
||||||
|
better first move than an accusation.
|
||||||
|
- Stay on topic. This project is about a Dalamud chat plugin. Off-topic
|
||||||
|
arguments belong elsewhere.
|
||||||
|
- Respect that I maintain this in my spare time. Replies can take a
|
||||||
|
few days. Please do not escalate just because a thread is quiet.
|
||||||
|
|
||||||
|
## What is not welcome
|
||||||
|
|
||||||
|
- Personal attacks, slurs, doxxing, sustained disruption of threads.
|
||||||
|
- Unsolicited private contact after I have asked someone to stop.
|
||||||
|
- Sharing of private conversations without consent.
|
||||||
|
- Any content that would put other contributors or end users at risk.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This applies to every space the project owns or that I run on its
|
||||||
|
behalf: the GitHub repository, GitHub Discussions, project-related
|
||||||
|
Discord conversations and the maintainer email address listed in
|
||||||
|
`SECURITY.md`.
|
||||||
|
|
||||||
|
It also applies when someone is identifiably representing the project
|
||||||
|
in another space, for example posting as a HellionChat maintainer in
|
||||||
|
the Dalamud Discord.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
If something here is being broken, contact me directly. Do not open a
|
||||||
|
public issue.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
Depending on what happened and how the person responds, I will pick
|
||||||
|
the lightest measure that resolves the issue:
|
||||||
|
|
||||||
|
1. Private note asking the behaviour to stop.
|
||||||
|
2. Public correction in the affected thread.
|
||||||
|
3. Edit or removal of the offending content.
|
||||||
|
4. Temporary block from the repository or related spaces.
|
||||||
|
5. Permanent block.
|
||||||
|
|
||||||
|
Severe cases skip the lower steps. I will not negotiate over
|
||||||
|
harassment or threats.
|
||||||
|
|
||||||
|
## Acknowledgement
|
||||||
|
|
||||||
|
This document is intentionally short and project-specific rather than
|
||||||
|
a copy of a longer template. If you need a more formal reference, the
|
||||||
|
[Contributor Covenant](https://www.contributor-covenant.org/) is a
|
||||||
|
widely adopted starting point and the spirit of this document is
|
||||||
|
compatible with it.
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# Contributing to HellionChat
|
||||||
|
|
||||||
|
Thanks for taking a look. HellionChat is a small, opinionated fork of
|
||||||
|
[Chat 2](https://github.com/Infiziert90/ChatTwo) maintained by one
|
||||||
|
person in spare time. 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: this is a
|
||||||
|
privacy-focused, EUPL-1.2-licensed Dalamud plugin that intentionally
|
||||||
|
removes the upstream webinterface and ships smaller defaults.
|
||||||
|
- Read [UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md). Cherry-picks from upstream
|
||||||
|
Chat 2 are selective and conscious; not everything that lands there
|
||||||
|
belongs here.
|
||||||
|
- 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 the upstream Chat 2 strings (`Language.*.resx`) are
|
||||||
|
not handled here; they go through 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 README "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 upstream codebase.
|
||||||
|
They make selective upstream cherry-picks much harder and the
|
||||||
|
maintenance cost outweighs the benefit for a one-person project.
|
||||||
|
- AI-generated code dropped in without disclosure or human review. See
|
||||||
|
[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 step only for trivial typos.
|
||||||
|
2. Fork the repository and branch off `main`. Branch naming is
|
||||||
|
informal; something like `fix/auto-tell-history-empty` or
|
||||||
|
`feat/adblock-light-mode` is plenty.
|
||||||
|
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 big 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` for the next version. I bump the version number myself
|
||||||
|
at release time, so you do not need to.
|
||||||
|
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 development 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
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet restore
|
||||||
|
dotnet build HellionChat.sln -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are not part of the current `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 through `/xlplugins`.
|
||||||
|
|
||||||
|
## Continuous integration
|
||||||
|
|
||||||
|
Every push and every pull request runs:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
Hellion-specific strings live in `HellionChat/Resources/HellionStrings.resx`
|
||||||
|
(English source) and `HellionStrings.<lang>.resx` (per-language).
|
||||||
|
Translations are accepted as direct pull requests against those files.
|
||||||
|
|
||||||
|
The upstream Chat 2 strings in `HellionChat/Resources/Language.*.resx` are
|
||||||
|
**not** translated in this repository. They are owned by the upstream
|
||||||
|
Chat 2 project and synced in via cherry-pick. Please contribute
|
||||||
|
upstream-string translations to
|
||||||
|
[Infiziert90/ChatTwo](https://github.com/Infiziert90/ChatTwo) instead.
|
||||||
|
|
||||||
|
## A note on response times
|
||||||
|
|
||||||
|
I respond on weekdays during European business hours and I take
|
||||||
|
weekends and FFXIV patch days off. A pull request that sits for a few
|
||||||
|
days has not been ignored; I just have not gotten to it yet. Pinging
|
||||||
|
once after a week is fine; please do not ping daily.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV
|
||||||
|
|
||||||
|
Copyright (c) 2024-2025 Infiziert90 (Infi) and Anna Clemens (ascclemens)
|
||||||
|
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,
|
||||||
|
Hellion theme and font integration, German localisation and the
|
||||||
|
EUPL-1.2 fork maintenance.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Acknowledgements directed at the upstream ChatTwo authors live in
|
||||||
|
NOTICE.md. The manual upstream-sync workflow lives in UPSTREAM_SYNC.md.
|
||||||
@@ -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.
@@ -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,93 +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 — every Chat 2 feature, command
|
|
||||||
and shortcut you already know works the same. The /chat2 command, tabs,
|
|
||||||
channel filters, RGB colours, emotes, screenshot mode, IPC integration
|
|
||||||
and the chat replacement window itself are all unchanged.
|
|
||||||
|
|
||||||
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.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,23 +0,0 @@
|
|||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project in the current directory
|
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
Generated
-1573
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
|
||||||
"@sveltejs/kit": "^2.22.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
||||||
"svelte": "^5.39.2",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"sveltekit-sse": "^0.14.3",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"vite": "^7.0.4"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-23
@@ -1,23 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
interface Error {
|
|
||||||
code: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
|
|
||||||
interface Warning {
|
|
||||||
hasWarning: boolean;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Element { scrollTopMax: number } // Firefox only property
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {isChannelLocked, channelOptions} from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
let selectElement: HTMLSelectElement;
|
|
||||||
|
|
||||||
async function requestChannelSwitch(event: Event) {
|
|
||||||
if (!event.currentTarget)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let element = (event.currentTarget as HTMLSelectElement);
|
|
||||||
let requestedChannel = element.value;
|
|
||||||
|
|
||||||
console.log(element.value)
|
|
||||||
element.value = '0';
|
|
||||||
|
|
||||||
const rawResponse = await fetch('/channel', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ channel: requestedChannel })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | null = null;
|
|
||||||
function getTextWidth(text: string): number {
|
|
||||||
// re-use canvas object for better performance
|
|
||||||
if (canvas === null)
|
|
||||||
canvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
const context: CanvasRenderingContext2D | null = canvas.getContext("2d");
|
|
||||||
if (!context)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
context.font = getCanvasFont(selectElement);
|
|
||||||
const metrics = context.measureText(text);
|
|
||||||
return metrics.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCssStyle(element: Element, prop: string): string {
|
|
||||||
return window.getComputedStyle(element, null).getPropertyValue(prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvasFont(el = document.body) {
|
|
||||||
const fontWeight = getCssStyle(el, 'font-weight') || 'normal';
|
|
||||||
const fontSize = getCssStyle(el, 'font-size') || '16px';
|
|
||||||
const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman';
|
|
||||||
|
|
||||||
return `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<select
|
|
||||||
bind:this={selectElement}
|
|
||||||
id="channel-select"
|
|
||||||
style="pointer-events: {isChannelLocked.locked ? 'none' : 'inherit'}; width: {(channelOptions.length > 1 ? getTextWidth(channelOptions[0].text) : 1) + 40}px"
|
|
||||||
onchange={(e) => requestChannelSwitch(e)}>
|
|
||||||
{#each channelOptions as channelOption}
|
|
||||||
{#if channelOption.preview }
|
|
||||||
<option selected disabled hidden value={channelOption.value}>
|
|
||||||
{channelOption.text}
|
|
||||||
</option>
|
|
||||||
{:else}
|
|
||||||
<option value={channelOption.value}>
|
|
||||||
{channelOption.text}
|
|
||||||
</option>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
select {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { subscribe } from "$lib/utils.svelte";
|
|
||||||
import { chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
let textarea: HTMLTextAreaElement;
|
|
||||||
|
|
||||||
let skipNextCheck: boolean = $state(false);
|
|
||||||
let requiresResize: boolean = $state(true);
|
|
||||||
|
|
||||||
subscribe(
|
|
||||||
() => chatInput,
|
|
||||||
(v) => {
|
|
||||||
if (skipNextCheck) {
|
|
||||||
skipNextCheck = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input box has been reset to empty, so resize it back to smaller box
|
|
||||||
if (v.content === '') {
|
|
||||||
console.log("Empty chatbox, resize");
|
|
||||||
requiresResize = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove newline characters
|
|
||||||
let original = v.content;
|
|
||||||
v.content = v.content.replace(/(\r\n|\n|\r)/gm,"");
|
|
||||||
|
|
||||||
console.log(`${original.length} vs ${v.content.length}`);
|
|
||||||
let hasChanged = original.length != v.content.length;
|
|
||||||
if (hasChanged) {
|
|
||||||
skipNextCheck = true;
|
|
||||||
requiresResize = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function preventNewlines(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
// Prevent key from creating a newline
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// submit the data
|
|
||||||
const newEvent = new Event('submit', {bubbles: true, cancelable: true});
|
|
||||||
if (e.currentTarget !== null) {
|
|
||||||
(e.currentTarget as HTMLTextAreaElement).closest('form')?.dispatchEvent(newEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
if (!textarea)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const scrolledToBottom = messagesList.scrolledToBottom;
|
|
||||||
textarea.style.height = '1px';
|
|
||||||
textarea.style.height = `${textarea.scrollHeight + 10}px`; // with +10px extra padding
|
|
||||||
if (scrolledToBottom)
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
console.log(`Checking effect: ${requiresResize}`)
|
|
||||||
if (requiresResize) {
|
|
||||||
requiresResize = false;
|
|
||||||
resize();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
bind:this={textarea}
|
|
||||||
bind:value={chatInput.content}
|
|
||||||
oninput={() => resize()}
|
|
||||||
onkeydown={(e) => preventNewlines(e)}
|
|
||||||
|
|
||||||
id="chat-input"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Message"
|
|
||||||
enterkeyhint="send"
|
|
||||||
maxlength="500">
|
|
||||||
</textarea>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
textarea {
|
|
||||||
flex-grow: 0;
|
|
||||||
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-height: 2.5em;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { selectedTab, knownTabs, tabPaneState, tabPaneAnimationState, closeTabPane, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
async function selectTab(index: number) {
|
|
||||||
const rawResponse = await fetch('/tab', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ index })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
tabPaneAnimationState.noAnimation = false;
|
|
||||||
closeTabPane();
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrolledToBottom = true;
|
|
||||||
function ontransitionstart() {
|
|
||||||
scrolledToBottom = messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ontransitionend() {
|
|
||||||
if (scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
id="tabs"
|
|
||||||
class:no-animation={tabPaneAnimationState.noAnimation}
|
|
||||||
class:hidden={!tabPaneState.visible}
|
|
||||||
{ontransitionstart}
|
|
||||||
{ontransitionend}
|
|
||||||
>
|
|
||||||
<div class="inner">
|
|
||||||
<header>
|
|
||||||
<span>Tabs</span>
|
|
||||||
<button type="button" onclick={() => handleClose()}>
|
|
||||||
<!-- "chevron-left" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<ol id="tabs-list">
|
|
||||||
{#each knownTabs as tab}
|
|
||||||
<li class:active={selectedTab.index === tab.index}>
|
|
||||||
<button type="button" onclick={() => selectTab(tab.index)}>
|
|
||||||
{ tab.name } {tab.unreadCount > 0 ? `(${tab.unreadCount})`: '' }
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {tabPaneState, tabPaneAnimationState, openTabPane, knownTabs} from "$lib/shared.svelte";
|
|
||||||
|
|
||||||
function onclick() {
|
|
||||||
tabPaneAnimationState.noAnimation = false;
|
|
||||||
openTabPane();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button type="button" aria-label="Open tab pane" class:visible={!tabPaneState.visible} class:unread={knownTabs.some((tab) => tab.unreadCount > 0)} {onclick} disabled={tabPaneState.visible}>
|
|
||||||
<!-- "chevron-right" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
button {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
padding: 25px 0;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 100;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 250ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.unread svg {
|
|
||||||
stroke: var(--unread-color);
|
|
||||||
filter: drop-shadow(0 0 2px var(--unread-color));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>%sveltekit.error.message%</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>Status: %sveltekit.status%</p>
|
|
||||||
<p>Message: %sveltekit.error.message%</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import { channelOptions, isChannelLocked, selectedTab, knownTabs, chatInput, messagesList, scrollMessagesToBottom } from "$lib/shared.svelte";
|
|
||||||
import { WebPayloadType } from "$lib/payload";
|
|
||||||
import { source, type Source } from "sveltekit-sse";
|
|
||||||
|
|
||||||
interface ChatElements {
|
|
||||||
messagesContainer: Element | null,
|
|
||||||
messagesList: HTMLElement | null,
|
|
||||||
|
|
||||||
timestampWidthProbe: HTMLElement | null,
|
|
||||||
|
|
||||||
inputForm: Element | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.Messages`
|
|
||||||
interface Messages {
|
|
||||||
messages: MessageResponse[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.MessageResponse`
|
|
||||||
interface MessageResponse {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
templates: Template[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.MessageTemplate`
|
|
||||||
interface Template {
|
|
||||||
payloadType: WebPayloadType;
|
|
||||||
content: string;
|
|
||||||
iconId: number;
|
|
||||||
color: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.SwitchChannel`
|
|
||||||
interface SwitchChannel {
|
|
||||||
channelName: Template[];
|
|
||||||
channelValue: number;
|
|
||||||
channelLocked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChannelList`
|
|
||||||
interface ChannelList {
|
|
||||||
channels: {[key: string]: number};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTab`
|
|
||||||
export interface ChatTab {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTabList`
|
|
||||||
interface ChatTabList {
|
|
||||||
tabs: ChatTab[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref `DataStructure.ChatTabUnreadState`
|
|
||||||
interface ChatTabUnreadState {
|
|
||||||
index: number;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatTwoWeb {
|
|
||||||
elements!: ChatElements;
|
|
||||||
maxTimestampWidth: number = 0;
|
|
||||||
|
|
||||||
sse!: EventSource;
|
|
||||||
connection!: Source;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.setupDOMElements();
|
|
||||||
this.setupSSEConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDOMElements() {
|
|
||||||
this.elements = {
|
|
||||||
messagesContainer: document.querySelector('#messages > .scroll-container')!,
|
|
||||||
messagesList: document.getElementById('messages-list'),
|
|
||||||
|
|
||||||
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
|
|
||||||
|
|
||||||
inputForm: document.querySelector('#input > form'),
|
|
||||||
};
|
|
||||||
messagesList.element = this.elements.messagesList;
|
|
||||||
|
|
||||||
// add indicator signaling more messages below
|
|
||||||
this.elements.messagesContainer?.addEventListener('scroll', (event) => {
|
|
||||||
if (event.currentTarget === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let parentElement = (event.currentTarget as HTMLDivElement).parentElement;
|
|
||||||
if (!this.messagesAreScrolledToBottom()) {
|
|
||||||
parentElement?.classList.add('more-messages');
|
|
||||||
} else {
|
|
||||||
parentElement?.classList.remove('more-messages');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard)
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (messagesList.scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// handle message sending
|
|
||||||
this.elements.inputForm?.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (chatInput.content.length > 500) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawResponse = await fetch('/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ message: chatInput.content })
|
|
||||||
});
|
|
||||||
// const content = await rawResponse.json();
|
|
||||||
// TODO: use the response
|
|
||||||
|
|
||||||
chatInput.content = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messagesAreScrolledToBottom() {
|
|
||||||
if (this.elements.messagesContainer === null) {
|
|
||||||
return messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
messagesList.scrolledToBottom =
|
|
||||||
(
|
|
||||||
this.elements.messagesContainer.scrollHeight -
|
|
||||||
this.elements.messagesContainer.clientHeight -
|
|
||||||
this.elements.messagesContainer.scrollTop
|
|
||||||
) < 1;
|
|
||||||
|
|
||||||
return messagesList.scrolledToBottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChannelHint(channel: SwitchChannel) {
|
|
||||||
// Set storage to the current lock state
|
|
||||||
isChannelLocked.locked = channel.channelLocked;
|
|
||||||
|
|
||||||
const channelElement = this.processTemplate(channel.channelName);
|
|
||||||
if (!channelElement.firstChild)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let channelName = (channelElement.firstChild as HTMLSpanElement).innerText;
|
|
||||||
if (channel.channelLocked)
|
|
||||||
channelName = `(Locked) ${channelName}`;
|
|
||||||
|
|
||||||
channelOptions[0] = {text: channelName, value: 0, preview: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChannels(channelList: ChannelList) {
|
|
||||||
channelOptions.length = 1;
|
|
||||||
|
|
||||||
for (const [ label, channel ] of Object.entries(channelList.channels)) {
|
|
||||||
channelOptions.push( { text: label, value: channel, preview: false } )
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculate timestamp width to ensure that all timestamps have the same width.
|
|
||||||
// some typefaces have the same width across all number glyphs, others do not.
|
|
||||||
// then there's AM/PM vs 24 hour, and so on
|
|
||||||
calculateTimestampWidth(timestamp: string) {
|
|
||||||
if (this.elements.timestampWidthProbe === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.elements.timestampWidthProbe.innerText = timestamp;
|
|
||||||
if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) {
|
|
||||||
this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth;
|
|
||||||
document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(messageData: MessageResponse) {
|
|
||||||
if (this.elements.messagesList === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const scrolledToBottom = this.messagesAreScrolledToBottom();
|
|
||||||
this.calculateTimestampWidth(messageData.timestamp);
|
|
||||||
|
|
||||||
const liMessage = document.createElement('li');
|
|
||||||
const spanTimestamp = document.createElement('span');
|
|
||||||
spanTimestamp.classList.add('timestamp');
|
|
||||||
spanTimestamp.innerText = messageData.timestamp;
|
|
||||||
|
|
||||||
const spanMessage = document.createElement('span');
|
|
||||||
spanMessage.classList.add('message');
|
|
||||||
spanMessage.appendChild(this.processTemplate(messageData.templates))
|
|
||||||
|
|
||||||
liMessage.appendChild(spanTimestamp);
|
|
||||||
liMessage.appendChild(spanMessage);
|
|
||||||
this.elements.messagesList.appendChild(liMessage);
|
|
||||||
|
|
||||||
if (scrolledToBottom) {
|
|
||||||
scrollMessagesToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processTemplate(templates: Template[]) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
|
|
||||||
for( const template of templates ) {
|
|
||||||
const spanElement = document.createElement('span');
|
|
||||||
switch (template.payloadType) {
|
|
||||||
case WebPayloadType.RawText:
|
|
||||||
this.processTextTemplate(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.CustomUri:
|
|
||||||
this.processUrlTemplate(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.CustomEmote:
|
|
||||||
this.processEmote(template, spanElement);
|
|
||||||
break;
|
|
||||||
case WebPayloadType.Icon:
|
|
||||||
this.processIcon(template, spanElement);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
frag.appendChild(spanElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return frag;
|
|
||||||
}
|
|
||||||
|
|
||||||
processTextTemplate(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
spanElement.innerText = template.content;
|
|
||||||
if (template.color !== 0)
|
|
||||||
{
|
|
||||||
this.processColor(template, spanElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processUrlTemplate(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const urlElement = document.createElement('a');
|
|
||||||
let url = template.content;
|
|
||||||
if (!url.startsWith('https://')) {
|
|
||||||
url = `https://${url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
urlElement.innerText = template.content;
|
|
||||||
urlElement.href = encodeURI(url);
|
|
||||||
urlElement.target = '_blank'
|
|
||||||
|
|
||||||
if (template.color !== 0)
|
|
||||||
{
|
|
||||||
this.processColor(template, spanElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
spanElement.appendChild(urlElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// converts a RGBA uint number to components
|
|
||||||
processColor(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const r = (template.color & 0xFF000000) >>> 24;
|
|
||||||
const g = (template.color & 0xFF0000) >>> 16;
|
|
||||||
const b = (template.color & 0xFF00) >>> 8;
|
|
||||||
const a = (template.color & 0xFF) / 255.0;
|
|
||||||
|
|
||||||
spanElement.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
processEmote(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
const imgElement = document.createElement('img');
|
|
||||||
imgElement.src = `/emote/${template.content}`;
|
|
||||||
|
|
||||||
spanElement.classList.add('emote-icon');
|
|
||||||
spanElement.appendChild(imgElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
processIcon(template: Template, spanElement: HTMLSpanElement) {
|
|
||||||
spanElement.classList.add('gfd-icon');
|
|
||||||
spanElement.classList.add(`gfd-icon-hq-${template.iconId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAllMessages() {
|
|
||||||
if (this.elements.messagesList === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.elements.messagesList.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSSEConnection() {
|
|
||||||
this.connection = source('/sse')
|
|
||||||
|
|
||||||
this.connection.select('close').subscribe((data: string) => {
|
|
||||||
console.log(`close: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
console.log('Closing SSE connection.');
|
|
||||||
this.connection.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// new messages to be appended to the message list
|
|
||||||
this.connection.select('new-message').subscribe((data: string) => {
|
|
||||||
console.log(`new-message: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let message: MessageResponse = JSON.parse(data);
|
|
||||||
this.addMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// a bulk of new messages, with a clear of the message list beforehand
|
|
||||||
this.connection.select('bulk-messages').subscribe((data: string) => {
|
|
||||||
console.log(`bulk-messages: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
this.clearAllMessages();
|
|
||||||
try {
|
|
||||||
let messages: Messages = JSON.parse(data);
|
|
||||||
for (const message of messages.messages) {
|
|
||||||
this.addMessage(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connection.select('channel-switched').subscribe((data: string) => {
|
|
||||||
console.log(`channel-switched: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let channel: SwitchChannel = JSON.parse(data);
|
|
||||||
this.updateChannelHint(channel);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// list of all channels
|
|
||||||
this.connection.select('channel-list').subscribe((data: string) => {
|
|
||||||
console.log(`channel-list: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
let channelList: ChannelList = JSON.parse(data);
|
|
||||||
this.updateChannels(channelList);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// tab switched
|
|
||||||
this.connection.select('tab-switched').subscribe((data: string) => {
|
|
||||||
console.log(`tab-switched: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTab: ChatTab = JSON.parse(data);
|
|
||||||
selectedTab.index = chatTab.index;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// list of all tabs
|
|
||||||
this.connection.select('tab-list').subscribe((data: string) => {
|
|
||||||
console.log(`tab-list: ${data}`)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTabList: ChatTabList = JSON.parse(data);
|
|
||||||
knownTabs.length = 0;
|
|
||||||
for (const tab of chatTabList.tabs) {
|
|
||||||
knownTabs.push(tab);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// the unread state of a specific tab has changed
|
|
||||||
this.connection.select('tab-unread-state').subscribe((data: string) => {
|
|
||||||
console.log(`tab-unread-state`, data)
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
const chatTabUnreadState: ChatTabUnreadState = JSON.parse(data);
|
|
||||||
let tab = knownTabs.find((tab) => tab.index === chatTabUnreadState.index);
|
|
||||||
if (tab) {
|
|
||||||
tab.unreadCount = chatTabUnreadState.unreadCount;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.error("Unable to find tab!")
|
|
||||||
console.error(chatTabUnreadState)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
// from kizer, gfd icons
|
|
||||||
interface GdfEntry {
|
|
||||||
id: number,
|
|
||||||
left: number,
|
|
||||||
top: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
unk0A: number,
|
|
||||||
redirect: number,
|
|
||||||
unk0E: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StylesheetEntry {
|
|
||||||
ids: number[],
|
|
||||||
style1: string,
|
|
||||||
style2: string,
|
|
||||||
width: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addGfdStylesheet(gfdPath: string, texPath: string) {
|
|
||||||
const texPromise = loadTexAsBlob(texPath);
|
|
||||||
const gfdPromise = loadGfd(gfdPath);
|
|
||||||
const texUrl = URL.createObjectURL(await texPromise);
|
|
||||||
const gfd = await gfdPromise;
|
|
||||||
|
|
||||||
const stylesheets: {[id: number]: StylesheetEntry} = [];
|
|
||||||
for (const entry of gfd) {
|
|
||||||
if (entry.width * entry.height <= 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (entry.redirect !== 0) {
|
|
||||||
stylesheets[entry.redirect].ids.push(entry.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stylesheets[entry.id] = {
|
|
||||||
ids: [entry.id],
|
|
||||||
style1: [
|
|
||||||
`background-position: -${entry.left}px -${entry.top}px`,
|
|
||||||
`background-image: url('${texUrl}')`,
|
|
||||||
`width: ${entry.width}px`,
|
|
||||||
`height: ${entry.height}px`
|
|
||||||
].join(';'),
|
|
||||||
style2: [
|
|
||||||
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
|
|
||||||
`background-image: url('${texUrl}')`,
|
|
||||||
`width: ${entry.width * 2}px`,
|
|
||||||
`height: ${entry.height * 2}px`
|
|
||||||
].join(';'),
|
|
||||||
width: entry.width
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let stylesheet = '';
|
|
||||||
for (const entry of Object.values(stylesheets)) {
|
|
||||||
if (!entry)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry.style1};}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry.style2};}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry.width}px;}`;
|
|
||||||
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry.width * 2}px;}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styleNode = document.createElement('style');
|
|
||||||
styleNode.appendChild(document.createTextNode(stylesheet));
|
|
||||||
document.head.appendChild(styleNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTexAsBlob(path: string) {
|
|
||||||
const tex = parseTex(await (await fetch(path)).arrayBuffer());
|
|
||||||
if (tex.format !== 0x1450) // B8G8R8A8
|
|
||||||
throw 'Not supported';
|
|
||||||
|
|
||||||
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
|
|
||||||
for (let i = 0; i < dataArray.length; i += 4) {
|
|
||||||
const t = dataArray[i];
|
|
||||||
dataArray[i] = dataArray[i + 2];
|
|
||||||
dataArray[i + 2] = t;
|
|
||||||
}
|
|
||||||
const imageData = new ImageData(dataArray, tex.width, tex.height);
|
|
||||||
const bitmap = await createImageBitmap(imageData);
|
|
||||||
|
|
||||||
const canvas = new OffscreenCanvas(tex.width, tex.height);
|
|
||||||
canvas.getContext('bitmaprenderer')?.transferFromImageBitmap(bitmap);
|
|
||||||
return await canvas.convertToBlob();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGfd(path: string) {
|
|
||||||
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
|
|
||||||
const count = buffer.getInt32(8, true);
|
|
||||||
const entries: GdfEntry[] = new Array(count);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const offset = 0x10 + (i * 0x10);
|
|
||||||
entries[i] = {
|
|
||||||
id: buffer.getInt16(offset, true),
|
|
||||||
left: buffer.getInt16(offset + 2, true),
|
|
||||||
top: buffer.getInt16(offset + 4, true),
|
|
||||||
width: buffer.getInt16(offset + 6, true),
|
|
||||||
height: buffer.getInt16(offset + 8, true),
|
|
||||||
unk0A: buffer.getInt16(offset + 10, true),
|
|
||||||
redirect: buffer.getInt16(offset + 12, true),
|
|
||||||
unk0E: buffer.getInt16(offset + 14, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTex(arrayBuffer: ArrayBuffer) {
|
|
||||||
const buffer = new DataView(arrayBuffer);
|
|
||||||
const type = buffer.getInt32(0, true);
|
|
||||||
const format = buffer.getInt32(4, true);
|
|
||||||
const width = buffer.getInt16(8, true);
|
|
||||||
const height = buffer.getInt16(10, true);
|
|
||||||
const depth = buffer.getInt16(12, true);
|
|
||||||
const mipsAndFlag = buffer.getInt8(14);
|
|
||||||
const arraySize = buffer.getInt8(15);
|
|
||||||
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
|
|
||||||
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer: arrayBuffer,
|
|
||||||
type,
|
|
||||||
format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth,
|
|
||||||
mipsAndFlag,
|
|
||||||
arraySize,
|
|
||||||
lodOffsets,
|
|
||||||
offsetToSurface,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export enum WebPayloadType {
|
|
||||||
// Dalamud
|
|
||||||
Unknown,
|
|
||||||
Player,
|
|
||||||
Item,
|
|
||||||
Status,
|
|
||||||
RawText,
|
|
||||||
UIForeground,
|
|
||||||
UIGlow,
|
|
||||||
MapLink,
|
|
||||||
AutoTranslateText,
|
|
||||||
EmphasisItalic,
|
|
||||||
Icon,
|
|
||||||
Quest,
|
|
||||||
DalamudLink,
|
|
||||||
NewLine,
|
|
||||||
SeHyphen,
|
|
||||||
PartyFinder,
|
|
||||||
|
|
||||||
// Custom
|
|
||||||
CustomPartyFinder = 0x50,
|
|
||||||
CustomAchievement = 0x51,
|
|
||||||
CustomUri = 0x52,
|
|
||||||
CustomEmote = 0x53,
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { ChatTab } from "./chat.svelte";
|
|
||||||
|
|
||||||
export const isChannelLocked: { locked: boolean } = $state({ locked: false });
|
|
||||||
export const channelOptions: ChannelOption[] = $state([ { text: 'Invalid', value: 0, preview: true } ]);
|
|
||||||
|
|
||||||
export interface ChannelOption {
|
|
||||||
text: string;
|
|
||||||
value: number;
|
|
||||||
preview: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selectedTab: { index: number } = $state({ index: 0 });
|
|
||||||
export const knownTabs: ChatTab[] = $state([]);
|
|
||||||
export const tabPaneState: { visible: boolean } = $state({ visible: true });
|
|
||||||
export const tabPaneAnimationState: { noAnimation: boolean } = $state({ noAnimation: true });
|
|
||||||
export const persistentTabPabeStateKey = 'chat2_tab_pane_visible';
|
|
||||||
|
|
||||||
export function openTabPane() {
|
|
||||||
tabPaneState.visible = true;
|
|
||||||
window.localStorage.setItem(persistentTabPabeStateKey, 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeTabPane() {
|
|
||||||
tabPaneState.visible = false;
|
|
||||||
window.localStorage.setItem(persistentTabPabeStateKey, 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chatInput: { content: string } = $state({ content: ''} );
|
|
||||||
export const messagesList: {
|
|
||||||
element: HTMLElement | null,
|
|
||||||
scrolledToBottom: boolean
|
|
||||||
} = $state({ element: null, scrolledToBottom: true });
|
|
||||||
|
|
||||||
export function scrollMessagesToBottom() {
|
|
||||||
if (messagesList.element === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
messagesList.element.lastElementChild?.scrollIntoView();
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import {writable} from "svelte/store";
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/79696571
|
|
||||||
export const subscribe = <T>(functionToState: () => T, callback: (v: T) => void) => {
|
|
||||||
let value = writable<T>(functionToState());
|
|
||||||
value.subscribe(callback);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
value.set(functionToState());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<link rel="stylesheet" href="/static/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="/static/start.css">
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{@render children?.()}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const prerender = true;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state'
|
|
||||||
import { Alert } from '@sveltestrap/sveltestrap';
|
|
||||||
|
|
||||||
let data: App.Warning = $state({ hasWarning: false, content: '' });
|
|
||||||
$effect.pre(() => {
|
|
||||||
if (page.url.searchParams.has('message')) {
|
|
||||||
data = {
|
|
||||||
hasWarning: true,
|
|
||||||
content: page.url.searchParams.get('message') ?? '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
hasWarning: false,
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="auth">
|
|
||||||
<h1>Authcode</h1>
|
|
||||||
{#if data?.hasWarning }
|
|
||||||
<Alert content={data.content} color="warning" dismissible={true}/>
|
|
||||||
{/if}
|
|
||||||
<form action="/auth" method="POST">
|
|
||||||
<label><input type="password" name="authcode"></label>
|
|
||||||
<button type="submit" class="submitButton">Submit</button>
|
|
||||||
</form>
|
|
||||||
<div data-sveltekit-preload-data="false">
|
|
||||||
<img src="/emote/Sure" alt=":Sure:" data-sveltekit-preload-data="off">
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state'
|
|
||||||
import { Alert } from "@sveltestrap/sveltestrap";
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { ChatTwoWeb } from '$lib/chat.svelte'
|
|
||||||
import { tabPaneState, persistentTabPabeStateKey } from "$lib/shared.svelte";
|
|
||||||
import { addGfdStylesheet } from "$lib/gfd";
|
|
||||||
import DynamicTextArea from "../../components/DynamicTextArea.svelte";
|
|
||||||
import ChannelSelector from "../../components/ChannelSelector.svelte";
|
|
||||||
import TabPane from "../../components/TabPane.svelte";
|
|
||||||
import TabPaneOpener from "../../components/TabPaneOpener.svelte";
|
|
||||||
|
|
||||||
let data: App.Warning = $state({ hasWarning: false, content: '' });
|
|
||||||
$effect.pre(() => {
|
|
||||||
if (page.url.searchParams.has('message')) {
|
|
||||||
data = {
|
|
||||||
hasWarning: true,
|
|
||||||
content: page.url.searchParams.get('message') ?? '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
hasWarning: false,
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
console.log('the component has mounted');
|
|
||||||
|
|
||||||
// Populate the stylesheet with gfd data
|
|
||||||
addGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex');
|
|
||||||
|
|
||||||
// read saved tab pane state from localStorage
|
|
||||||
try {
|
|
||||||
const tabPaneVisible = window.localStorage.getItem(persistentTabPabeStateKey);
|
|
||||||
if (tabPaneVisible !== null) {
|
|
||||||
tabPaneState.visible = JSON.parse(tabPaneVisible);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// JSON.parse() failed, let's reset what's in localStorage
|
|
||||||
window.localStorage.removeItem(persistentTabPabeStateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all web functions in the background
|
|
||||||
const _ = new ChatTwoWeb();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="chat">
|
|
||||||
<TabPane />
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<TabPaneOpener />
|
|
||||||
|
|
||||||
<section id="messages">
|
|
||||||
<div class="scroll-container">
|
|
||||||
<ol id="messages-list"></ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="more-messages-indicator">
|
|
||||||
<!-- "arrow-down" icon from https://github.com/feathericons/feather, under MIT license -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if data?.hasWarning }
|
|
||||||
<section id="warnings">
|
|
||||||
<Alert content={data.content} color="warning" dismissible={true}/>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section id="input">
|
|
||||||
<form>
|
|
||||||
<div class="input-container">
|
|
||||||
<DynamicTextArea />
|
|
||||||
<ChannelSelector />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="timestamp-width-probe"></div>
|
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
# allow crawling everything by default
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
/* fonts */
|
|
||||||
@font-face {
|
|
||||||
font-family: Lodestone;
|
|
||||||
src: url('/files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
|
||||||
unicode-range: U+E020-E0DB;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter var';
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: oblique 0deg 10deg;
|
|
||||||
src: url('/static/Inter.var.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* variables */
|
|
||||||
:root {
|
|
||||||
--fg: white;
|
|
||||||
--fg-faint: #a0a0a0;
|
|
||||||
--fg-scrollbar: #404040;
|
|
||||||
--bg: #101010;
|
|
||||||
--bg-sidebar: #080808;
|
|
||||||
--bg-input: #202020;
|
|
||||||
--bg-input-hover: #282828;
|
|
||||||
--focus-color: #4060a0;
|
|
||||||
--unread-color: #beffa0;
|
|
||||||
|
|
||||||
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
|
|
||||||
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
|
|
||||||
|
|
||||||
--timestamp-width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* reset */
|
|
||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: Lodestone, 'Inter var', sans-serif;
|
|
||||||
font-feature-settings: 'tnum', 'calt' 0; /* calt appears to be on by default */
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span > a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* layout and global styles */
|
|
||||||
body {
|
|
||||||
padding: 25px;
|
|
||||||
height: 100dvh;
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
main.chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bg);
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
& > .main-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main.auth {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
input { width: 150px; }
|
|
||||||
|
|
||||||
input, .submitButton {
|
|
||||||
padding: 5px 20px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitButton {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border: 3px solid var(--bg-input);
|
|
||||||
background-image: var(--gradient-clickable);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* tab list */
|
|
||||||
aside#tabs {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-color: var(--fg-scrollbar) var(--bg-sidebar);
|
|
||||||
background-color: var(--bg-sidebar);
|
|
||||||
transition: width 250ms ease;
|
|
||||||
|
|
||||||
width: 200px;
|
|
||||||
&.hidden { width: 0px; }
|
|
||||||
|
|
||||||
&.no-animation {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.inner {
|
|
||||||
width: 200px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 550;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 0.6rem 0 0.75rem;
|
|
||||||
border-color: var(--fg-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol#tabs-list {
|
|
||||||
margin: 0 -5px;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding: 3px 5px;
|
|
||||||
color: var(--fg-faint);
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
li + li {
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li:has(button:hover) {
|
|
||||||
color: var(--fg);
|
|
||||||
background-color: rgb(from var(--bg-input) r g b / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.active {
|
|
||||||
color: var(--fg);
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.unread button {
|
|
||||||
color: var(--unread-color);
|
|
||||||
text-shadow: 0 0 5px var(--unread-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* message list */
|
|
||||||
section#messages {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
.scroll-container {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
scrollbar-color: var(--fg-scrollbar) var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol#messages-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
flex: 0 0 var(--timestamp-width);
|
|
||||||
color: var(--fg-faint);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#more-messages-indicator {
|
|
||||||
position: absolute;
|
|
||||||
display: none;
|
|
||||||
right: 30px;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
filter: drop-shadow(0 0 5px #60a0ff) drop-shadow(0 0 15px #60a0ff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.more-messages #more-messages-indicator {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#timestamp-width-probe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* alerts */
|
|
||||||
section#warnings {
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 20px 20px 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* input bar, channel selector, ... */
|
|
||||||
section#input {
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, button {
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border: 3px solid var(--bg-input);
|
|
||||||
background-image: var(--gradient-clickable);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
position: relative;
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding-left: calc(20px + 1.5rem);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
|
|
||||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 15px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 1.3rem;
|
|
||||||
height: 1.3rem;
|
|
||||||
background-color: var(--fg);
|
|
||||||
mask-size: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
#chat-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#channel-select {
|
|
||||||
position: absolute;
|
|
||||||
top: -1.5em;
|
|
||||||
left: 23px;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 550;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
padding: 5px 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--bg-input-hover);
|
|
||||||
background-color: var(--bg-input-hover);
|
|
||||||
background-image: var(--gradient-clickable-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid var(--focus-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* icons, emotes */
|
|
||||||
.gfd-icon {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
vertical-align: middle;
|
|
||||||
zoom: 0.75;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emote-icon {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 2rem;
|
|
||||||
height: 1px;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*** mobile ***/
|
|
||||||
@media ((max-width: 600px) and (orientation: portrait)) or (max-height: 400px) {
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
main.chat {
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
section#messages {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
align-items: baseline;
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#timestamp-width-probe {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section#input {
|
|
||||||
button {
|
|
||||||
max-width: 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
button::before {
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-container #channel-select {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gfd-icon { zoom: 0.65; }
|
|
||||||
.emote-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#tabs {
|
|
||||||
position: fixed;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
div.inner {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
kit: {
|
|
||||||
prerender: {
|
|
||||||
handleHttpError: 'warn'
|
|
||||||
},
|
|
||||||
adapter: adapter({
|
|
||||||
// default options are shown. On some platforms
|
|
||||||
// these options are set automatically — see below
|
|
||||||
pages: 'build',
|
|
||||||
assets: 'build',
|
|
||||||
fallback: undefined,
|
|
||||||
precompress: false,
|
|
||||||
strict: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
|
||||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [sveltekit()]
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
using WatsonWebserver.Core;
|
|
||||||
using WatsonWebserver.Lite;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class HostContext
|
|
||||||
{
|
|
||||||
public readonly ServerCore Core;
|
|
||||||
|
|
||||||
public bool IsActive;
|
|
||||||
public bool IsStopping;
|
|
||||||
|
|
||||||
// Initialized at webserver start
|
|
||||||
public WebserverLite Host = null!;
|
|
||||||
public Processing Processing = null!;
|
|
||||||
public RouteController RouteController = null!;
|
|
||||||
|
|
||||||
public readonly List<SSEConnection> EventConnections = [];
|
|
||||||
|
|
||||||
public readonly CancellationTokenSource TokenSource = new();
|
|
||||||
public readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Frontend/");
|
|
||||||
|
|
||||||
public HostContext(ServerCore core)
|
|
||||||
{
|
|
||||||
Core = core;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Start()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Host = new WebserverLite(new WebserverSettings("*", Plugin.Config.WebinterfacePort), DefaultRoute);
|
|
||||||
|
|
||||||
Processing = new Processing(this);
|
|
||||||
RouteController = new RouteController(this);
|
|
||||||
|
|
||||||
Host.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
|
|
||||||
Host.Routes.AuthenticateRequest = CheckAuthenticationCookie;
|
|
||||||
Host.Events.ExceptionEncountered += ExceptionEncountered;
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
#if DEBUG
|
|
||||||
Host.Settings.Debug.Requests = true;
|
|
||||||
Host.Settings.Debug.Routing = true;
|
|
||||||
Host.Settings.Debug.Responses = true;
|
|
||||||
Host.Settings.Debug.AccessControl = true;
|
|
||||||
#endif
|
|
||||||
Host.Events.Logger = logMessage => Plugin.Log.Debug(logMessage);
|
|
||||||
|
|
||||||
IsActive = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
Plugin.Log.Error(ex, "Initialization of the webserver failed.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Host.Start(TokenSource.Token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Webserver failed to boot up.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<bool> Stop()
|
|
||||||
{
|
|
||||||
// Is already stopped
|
|
||||||
if (!IsActive)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
IsStopping = true;
|
|
||||||
Host.Stop();
|
|
||||||
|
|
||||||
// Save our session tokens
|
|
||||||
Core.Plugin.SaveConfig();
|
|
||||||
|
|
||||||
// We get a copy, so that the original can be cleaned up successfully
|
|
||||||
foreach (var eventServer in EventConnections.ToArray())
|
|
||||||
await eventServer.DisposeAsync();
|
|
||||||
|
|
||||||
EventConnections.Clear();
|
|
||||||
Host.Dispose();
|
|
||||||
RouteController.Dispose();
|
|
||||||
IsStopping = false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Webserver failed to stop and dispose all resources.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region GeneralHandlers
|
|
||||||
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> DefaultRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
return await ctx.Response.Send("Nothing to see here.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (Plugin.Config.AuthStore.Count == 0)
|
|
||||||
{
|
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
|
||||||
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.AuthStore.Contains(token))
|
|
||||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
|
||||||
|
|
||||||
// Do nothing to let auth pass
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
using ChatTwo.Code;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
#region Outgoing SSE
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a valid tab with its assigned index
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTab(string name, int index, uint unreadCount)
|
|
||||||
{
|
|
||||||
[JsonProperty("name")] public string Name = name;
|
|
||||||
[JsonProperty("index")] public int Index = index;
|
|
||||||
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a number of tabs that are valid for the user to pick from
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTabList(ChatTab[] tabs)
|
|
||||||
{
|
|
||||||
[JsonProperty("tabs")] public ChatTab[] Tabs = tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a valid tab index and the current unread state as a number unread of messages
|
|
||||||
/// </summary>
|
|
||||||
public struct ChatTabUnreadState(int index, uint unreadCount)
|
|
||||||
{
|
|
||||||
[JsonProperty("index")] public int Index = index;
|
|
||||||
[JsonProperty("unreadCount")] public uint UnreadCount = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains the current channel name
|
|
||||||
/// </summary>
|
|
||||||
public struct SwitchChannel((MessageTemplate[] Name, bool Locked) channel)
|
|
||||||
{
|
|
||||||
[JsonProperty("channelName")] public MessageTemplate[] ChannelName = channel.Name;
|
|
||||||
[JsonProperty("channelLocked")] public bool Locked = channel.Locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a number of channels that are valid for the user to pick from
|
|
||||||
/// </summary>
|
|
||||||
public struct ChannelList(Dictionary<string, uint> channels)
|
|
||||||
{
|
|
||||||
[JsonProperty("channels")] public Dictionary<string, uint> Channels = channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains one or multiple messages
|
|
||||||
/// </summary>
|
|
||||||
public struct Messages(MessageResponse[] set)
|
|
||||||
{
|
|
||||||
[JsonProperty("messages")] public MessageResponse[] Set = set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Contains a single message with all its templates and a timestamp
|
|
||||||
/// </summary>
|
|
||||||
public struct MessageResponse()
|
|
||||||
{
|
|
||||||
[JsonProperty("id")] public Guid Id = Guid.Empty;
|
|
||||||
[JsonProperty("timestamp")] public string Timestamp = "";
|
|
||||||
[JsonProperty("templates")] public MessageTemplate[] Templates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Template that is used for the channel name or any message posted to the chatlog
|
|
||||||
/// </summary>
|
|
||||||
public struct MessageTemplate()
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The type of payload.
|
|
||||||
/// Dalamuds enum is just a baseline, there exists more that are expressed through raw values.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("payloadType")] public WebPayloadType PayloadType = WebPayloadType.Unknown;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for text and emote.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("content")] public string Content = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for an icon.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("iconId")] public uint IconId;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Used for text and url
|
|
||||||
///
|
|
||||||
/// Note:
|
|
||||||
/// 0 is used for invalid colors
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty("color")] public uint Color;
|
|
||||||
|
|
||||||
public static MessageTemplate Empty => new();
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Outgoing POST
|
|
||||||
public struct OkResponse(string message)
|
|
||||||
{
|
|
||||||
[JsonProperty("message")] public string Message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ErrorResponse(string reason)
|
|
||||||
{
|
|
||||||
[JsonProperty("reason")] public string Reason = reason;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Incoming POST
|
|
||||||
/// <summary>
|
|
||||||
/// Message must fulfill the posting requirement
|
|
||||||
/// Greater than or equal 2 characters
|
|
||||||
/// Less than or equal 500 characters
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingMessage()
|
|
||||||
{
|
|
||||||
[JsonProperty("message")] public string Message = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The channel type must be a valid <see cref="InputChannel"/>
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingChannel()
|
|
||||||
{
|
|
||||||
[JsonProperty("channel")] public InputChannel Channel = InputChannel.Invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The tabs index must be a valid int
|
|
||||||
/// </summary>
|
|
||||||
public struct IncomingTab()
|
|
||||||
{
|
|
||||||
[JsonProperty("index")] public int Index = -1;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
// General
|
|
||||||
public class CloseEvent() : BaseEvent("close");
|
|
||||||
|
|
||||||
// Tab related
|
|
||||||
public class ChatTabListEvent(ChatTabList list) : BaseEvent("tab-list", JsonConvert.SerializeObject(list));
|
|
||||||
public class ChatTabSwitchedEvent(ChatTab chatTab) : BaseEvent("tab-switched", JsonConvert.SerializeObject(chatTab));
|
|
||||||
public class ChatTabUnreadStateEvent(ChatTabUnreadState unreadState) : BaseEvent("tab-unread-state", JsonConvert.SerializeObject(unreadState));
|
|
||||||
|
|
||||||
// Input channel related
|
|
||||||
public class ChannelListEvent(ChannelList channelList) : BaseEvent("channel-list", JsonConvert.SerializeObject(channelList));
|
|
||||||
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("channel-switched", JsonConvert.SerializeObject(switchChannel));
|
|
||||||
|
|
||||||
// Chat message related
|
|
||||||
public class BulkMessagesEvent(Messages messages) : BaseEvent("bulk-messages", JsonConvert.SerializeObject(messages));
|
|
||||||
public class NewMessageEvent(MessageResponse message) : BaseEvent("new-message", JsonConvert.SerializeObject(message));
|
|
||||||
|
|
||||||
public class BaseEvent(string eventType, string? data = null)
|
|
||||||
{
|
|
||||||
private string Event = eventType;
|
|
||||||
private string Data = data ?? "0"; // SSE requires data on each response
|
|
||||||
|
|
||||||
public byte[] Build()
|
|
||||||
{
|
|
||||||
// SSE always ends with \n\n
|
|
||||||
return Encoding.UTF8.GetBytes($"event: {Event}\ndata: {Data}\n\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace ChatTwo.Http.MessageProtocol;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Baseline: <see cref="Dalamud.Game.Text.SeStringHandling.PayloadType"/>
|
|
||||||
/// </summary>
|
|
||||||
public enum WebPayloadType
|
|
||||||
{
|
|
||||||
// Dalamud
|
|
||||||
Unknown,
|
|
||||||
Player,
|
|
||||||
Item,
|
|
||||||
Status,
|
|
||||||
RawText,
|
|
||||||
UIForeground,
|
|
||||||
UIGlow,
|
|
||||||
MapLink,
|
|
||||||
AutoTranslateText,
|
|
||||||
EmphasisItalic,
|
|
||||||
Icon,
|
|
||||||
Quest,
|
|
||||||
DalamudLink,
|
|
||||||
NewLine,
|
|
||||||
SeHyphen,
|
|
||||||
PartyFinder,
|
|
||||||
|
|
||||||
// Custom
|
|
||||||
CustomPartyFinder = 0x50,
|
|
||||||
CustomAchievement = 0x51,
|
|
||||||
CustomUri = 0x52,
|
|
||||||
CustomEmote = 0x53,
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using ChatTwo.Code;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class Processing
|
|
||||||
{
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
public Processing(HostContext hostContext)
|
|
||||||
{
|
|
||||||
HostContext = hostContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal (MessageTemplate[] Name, bool Locked) ReadChannelName(Chunk[] channelName)
|
|
||||||
{
|
|
||||||
var locked = HostContext.Core.Plugin.CurrentTab is not { Channel: null };
|
|
||||||
return (channelName.Select(ProcessChunk).ToArray(), locked);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<MessageResponse[]> ReadMessageList()
|
|
||||||
{
|
|
||||||
var tabMessages = await HostContext.Core.Plugin.CurrentTab.Messages.GetCopy();
|
|
||||||
return tabMessages.TakeLast(Plugin.Config.WebinterfaceMaxLinesToSend).Select(ReadMessageContent).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal MessageResponse ReadMessageContent(Message message)
|
|
||||||
{
|
|
||||||
var response = new MessageResponse
|
|
||||||
{
|
|
||||||
Id = message.Id,
|
|
||||||
Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
|
|
||||||
};
|
|
||||||
|
|
||||||
var sender = message.Sender.Select(ProcessChunk);
|
|
||||||
var content = message.Content.Select(ProcessChunk);
|
|
||||||
response.Templates = sender.Concat(content).ToArray();
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageTemplate ProcessChunk(Chunk chunk)
|
|
||||||
{
|
|
||||||
if (chunk is IconChunk { } icon)
|
|
||||||
{
|
|
||||||
var iconId = (uint)icon.Icon;
|
|
||||||
return IconUtil.GfdFileView.TryGetEntry(iconId, out _) ? new MessageTemplate {PayloadType = WebPayloadType.Icon, IconId = iconId}: MessageTemplate.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk is TextChunk { } text)
|
|
||||||
{
|
|
||||||
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
|
|
||||||
{
|
|
||||||
var image = EmoteCache.GetEmote(emotePayload.Code);
|
|
||||||
|
|
||||||
if (image is { Failed: false })
|
|
||||||
return new MessageTemplate { PayloadType = WebPayloadType.CustomEmote, Color = 0, Content = emotePayload.Code };
|
|
||||||
}
|
|
||||||
|
|
||||||
var color = text.Foreground;
|
|
||||||
if (color == null && text.FallbackColour != null)
|
|
||||||
{
|
|
||||||
var type = text.FallbackColour.Value;
|
|
||||||
color = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
color ??= 0;
|
|
||||||
|
|
||||||
var userContent = text.Content;
|
|
||||||
if (HostContext.Core.Plugin.ChatLogWindow.ScreenshotMode)
|
|
||||||
{
|
|
||||||
if (chunk.Link is PlayerPayload playerPayload)
|
|
||||||
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
|
|
||||||
else if (Plugin.PlayerState.IsLoaded)
|
|
||||||
userContent = HostContext.Core.Plugin.ChatLogWindow.HidePlayerInString(userContent, Plugin.PlayerState.CharacterName, Plugin.PlayerState.HomeWorld.RowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNotUrl = text.Link is not UriPayload;
|
|
||||||
return new MessageTemplate { PayloadType = isNotUrl ? WebPayloadType.RawText : WebPayloadType.CustomUri, Color = color.Value, Content = userContent };
|
|
||||||
}
|
|
||||||
|
|
||||||
return MessageTemplate.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Messages> GetAllMessages()
|
|
||||||
{
|
|
||||||
var messages = await WebserverUtil.FrameworkWrapper(ReadMessageList);
|
|
||||||
return new Messages(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SwitchChannel GetCurrentChannel()
|
|
||||||
{
|
|
||||||
var channel = ReadChannelName(HostContext.Core.Plugin.ChatLogWindow.PreviousChannel);
|
|
||||||
return new SwitchChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChannelList GetValidChannels()
|
|
||||||
{
|
|
||||||
var channels = HostContext.Core.Plugin.ChatLogWindow.GetValidChannels();
|
|
||||||
return new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatTab GetCurrentTab()
|
|
||||||
{
|
|
||||||
var currentTab = HostContext.Core.Plugin.CurrentTab;
|
|
||||||
return new ChatTab(currentTab.Name, HostContext.Core.Plugin.LastTab, currentTab.Unread);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatTabList GetAllTabs()
|
|
||||||
{
|
|
||||||
var tabs = Plugin.Config.Tabs.Select((tab, idx) => new ChatTab(tab.Name, idx, tab.Unread)).ToArray();
|
|
||||||
return new ChatTabList(tabs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Web;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Lumina.Data.Files;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using WatsonWebserver.Core;
|
|
||||||
using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
|
|
||||||
using HttpMethod = WatsonWebserver.Core.HttpMethod;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class RouteController
|
|
||||||
{
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
private readonly string AuthTemplate;
|
|
||||||
private readonly string ChatBoxTemplate;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, long> RateLimit = [];
|
|
||||||
|
|
||||||
private readonly JsonSerializerSettings JsonSettings = new()
|
|
||||||
{
|
|
||||||
Error = delegate(object? _, ErrorEventArgs args) { args.ErrorContext.Handled = true; }
|
|
||||||
};
|
|
||||||
|
|
||||||
public RouteController(HostContext hostContext)
|
|
||||||
{
|
|
||||||
HostContext = hostContext;
|
|
||||||
|
|
||||||
AuthTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "index.html"));
|
|
||||||
ChatBoxTemplate = File.ReadAllText(Path.Combine(HostContext.StaticDir, "chat.html"));
|
|
||||||
|
|
||||||
// Pre Auth
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/favicon.ico", GetFavicon, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
|
||||||
|
|
||||||
// Post Auth
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/tab", ReceiveTabSwitch, ExceptionRoute);
|
|
||||||
|
|
||||||
// Ship all other static files dynamically
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Content.Add("/_app/", true, ExceptionRoute);
|
|
||||||
HostContext.Host.Routes.PreAuthentication.Content.Add("/static/", true, ExceptionRoute);
|
|
||||||
|
|
||||||
// Server-Sent Events Route
|
|
||||||
HostContext.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/sse", NewSSEConnection, ExceptionRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 500;
|
|
||||||
await ctx.Response.Send("Internal Server Error, please try again");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AuthRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (Plugin.Config.AuthStore.Count > 0)
|
|
||||||
{
|
|
||||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
|
||||||
if (cookies.TryGetValue("ChatTwo-token", out var value) && Plugin.Config.AuthStore.Contains(value))
|
|
||||||
{
|
|
||||||
await Redirect(ctx, "/chat");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.Response.Send(AuthTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#region FileHandlerRoutes
|
|
||||||
private async Task GetTexData(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = Plugin.DataManager.GetFile<TexFile>("common/font/fonticon_ps5.tex")!.Data;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetGfdData(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetLodestoneFont(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var data = HostContext.Core.Plugin.FontManager.GameSymFont;
|
|
||||||
await ctx.Response.Send(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetFavicon(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 404;
|
|
||||||
await ctx.Response.Send();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task GetEmote(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var name = ctx.Request.Url.Parameters["name"] ?? "";
|
|
||||||
if (name == "" || !EmoteCache.Exists(name))
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send("Malformed emote name.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var emote = EmoteCache.GetEmote(name);
|
|
||||||
if (emote is null)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send("Emote not valid.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the emote to be loaded a maximum of 5 times
|
|
||||||
var timeout = 5;
|
|
||||||
while (!emote.IsLoaded && timeout > 0)
|
|
||||||
{
|
|
||||||
timeout--;
|
|
||||||
await Task.Delay(25);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
|
|
||||||
await ctx.Response.Send(emote.RawData);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PreAuthRoutes
|
|
||||||
private async Task<bool> AuthenticateClient(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
var currentTick = Environment.TickCount64;
|
|
||||||
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp > currentTick)
|
|
||||||
{
|
|
||||||
_ = ctx.Request.DataAsString; // Temp fix for Watson.Lite bug #155
|
|
||||||
return await Redirect(ctx, "/", ("message", "Rate limit active (10s)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The next request will be rate limited for 10s
|
|
||||||
RateLimit[ctx.Request.Source.IpAddress] = currentTick + 10_000;
|
|
||||||
|
|
||||||
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
|
|
||||||
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
|
|
||||||
return await Redirect(ctx, "/", ("message", "Authentication failed"));
|
|
||||||
|
|
||||||
var token = WebinterfaceUtil.GenerateSimpleToken();
|
|
||||||
Plugin.Config.AuthStore.Add(token);
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
|
||||||
return await Redirect(ctx, "/chat");
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PostAuthRoutes
|
|
||||||
private async Task ChatBoxRoute(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
await ctx.Response.Send(ChatBoxTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveMessage(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var content = JsonConvert.DeserializeObject<IncomingMessage>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (content.Message.Length is < 2 or > 500)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid message received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() =>
|
|
||||||
{
|
|
||||||
HostContext.Core.Plugin.ChatLogWindow.Chat = content.Message;
|
|
||||||
HostContext.Core.Plugin.ChatLogWindow.SendChatBox(HostContext.Core.Plugin.CurrentTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Message was send to the channel.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var channel = JsonConvert.DeserializeObject<IncomingChannel>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (!Enum.IsDefined(channel.Channel))
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid channel received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.ChatLogWindow.SetChannel(channel.Channel); });
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Channel switch was initiated.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveTabSwitch(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
if (!await EnforceMediaType(ctx, "application/json"))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var tab = JsonConvert.DeserializeObject<IncomingTab>(ctx.Request.DataAsString, JsonSettings);
|
|
||||||
if (tab.Index < 0 || tab.Index >= Plugin.Config.Tabs.Count)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Invalid tab received.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Plugin.Framework.RunOnFrameworkThread(() => { HostContext.Core.Plugin.WantedTab = tab.Index; });
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 201;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new OkResponse("Tab switch was initiated.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NewSSEConnection(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Log.Debug($"Client connected: {ctx.Guid}");
|
|
||||||
|
|
||||||
var sse = new SSEConnection(HostContext.TokenSource.Token);
|
|
||||||
await HostContext.Core.PrepareNewClient(sse);
|
|
||||||
HostContext.EventConnections.Add(sse);
|
|
||||||
|
|
||||||
await sse.HandleEventLoop(ctx);
|
|
||||||
|
|
||||||
// It should always be done after return
|
|
||||||
if (sse.Done)
|
|
||||||
HostContext.EventConnections.Remove(sse);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Failed to finish the server event function");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region RedirectHelper
|
|
||||||
public static async Task<bool> Redirect(HttpContextBase ctx, string location, params (string, string)[] parameter)
|
|
||||||
{
|
|
||||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
|
||||||
foreach (var (key, value) in parameter)
|
|
||||||
query.Add(key, value);
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Location", $"{location}?{query}");
|
|
||||||
ctx.Response.StatusCode = 303;
|
|
||||||
return await ctx.Response.Send();
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region PreChecks
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check that the request has the correct media type that the functions expects.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ctx"></param>
|
|
||||||
/// <param name="requiredMediaType"></param>
|
|
||||||
/// <returns>True if media type is correct, otherwise handled and false</returns>
|
|
||||||
private async Task<bool> EnforceMediaType(HttpContextBase ctx, string requiredMediaType)
|
|
||||||
{
|
|
||||||
if (ctx.Request.ContentType == requiredMediaType)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
ctx.Response.StatusCode = 415;
|
|
||||||
await ctx.Response.Send(JsonConvert.SerializeObject(new ErrorResponse("Request contains wrong media type.")));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using WatsonWebserver.Core;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class SSEConnection
|
|
||||||
{
|
|
||||||
private bool Stopping;
|
|
||||||
private readonly CancellationToken Token;
|
|
||||||
|
|
||||||
public bool Done;
|
|
||||||
public readonly ConcurrentQueue<BaseEvent> OutboundQueue = new();
|
|
||||||
|
|
||||||
public SSEConnection(CancellationToken token)
|
|
||||||
{
|
|
||||||
Token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task HandleEventLoop(HttpContextBase ctx)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ctx.Response.Headers.Add("Content-Type", "text/event-stream");
|
|
||||||
ctx.Response.Headers.Add("Cache-Control", "no-cache");
|
|
||||||
ctx.Response.Headers.Add("Connection", "keep-alive");
|
|
||||||
|
|
||||||
ctx.Response.ChunkedTransfer = true;
|
|
||||||
while (!Token.IsCancellationRequested && !Stopping)
|
|
||||||
{
|
|
||||||
await Task.Delay(10, Token);
|
|
||||||
if (Token.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!await ctx.Response.SendChunk(outgoingEvent.Build(), false, Token))
|
|
||||||
{
|
|
||||||
Plugin.Log.Debug("SSE connection was unable to send new data");
|
|
||||||
Plugin.Log.Debug($"Client disconnected: {ctx.Guid}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "SSE handler failed.");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
|
||||||
await ctx.Response.SendChunk(new CloseEvent().Build(), true, Token);
|
|
||||||
|
|
||||||
// Manually confirm that we have finished our connection, even if the final response failed
|
|
||||||
// This can happen if the client disconnects before the server does
|
|
||||||
ctx.Response.ResponseSent = true;
|
|
||||||
|
|
||||||
Done = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Stopping = true;
|
|
||||||
|
|
||||||
var timeout = 1000; // 1000ms
|
|
||||||
while (timeout > 0)
|
|
||||||
{
|
|
||||||
if (Done)
|
|
||||||
break;
|
|
||||||
|
|
||||||
timeout -= 100;
|
|
||||||
await Task.Delay(100);
|
|
||||||
Plugin.Log.Debug("Sleeping because EventServer still alive");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using Dalamud.Plugin.Services;
|
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public class ServerCore : IAsyncDisposable
|
|
||||||
{
|
|
||||||
public readonly Plugin Plugin;
|
|
||||||
private readonly HostContext HostContext;
|
|
||||||
|
|
||||||
public ServerCore(Plugin plugin)
|
|
||||||
{
|
|
||||||
Plugin = plugin;
|
|
||||||
HostContext = new HostContext(this);
|
|
||||||
|
|
||||||
Plugin.Framework.Update += FrameworkUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Plugin.Framework.Update -= FrameworkUpdate;
|
|
||||||
await HostContext.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void FrameworkUpdate(IFramework _)
|
|
||||||
{
|
|
||||||
foreach (var (idx, tab) in Plugin.Config.Tabs.Index())
|
|
||||||
{
|
|
||||||
if (tab.Unread == tab.LastSendUnread)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
tab.LastSendUnread = tab.Unread;
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(new ChatTabUnreadStateEvent(new ChatTabUnreadState(idx, tab.Unread)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region SSE Helper
|
|
||||||
internal async Task PrepareNewClient(SSEConnection sse)
|
|
||||||
{
|
|
||||||
// This takes long, so keep it outside the next frame
|
|
||||||
var messages = await HostContext.Processing.GetAllMessages();
|
|
||||||
|
|
||||||
// Using the bulk message event to clear everything on the client side that may still exist
|
|
||||||
await Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
sse.OutboundQueue.Enqueue(new BulkMessagesEvent(messages));
|
|
||||||
|
|
||||||
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(HostContext.Processing.GetCurrentChannel()));
|
|
||||||
sse.OutboundQueue.Enqueue(new ChannelListEvent(HostContext.Processing.GetValidChannels()));
|
|
||||||
|
|
||||||
sse.OutboundQueue.Enqueue(new ChatTabSwitchedEvent(HostContext.Processing.GetCurrentTab()));
|
|
||||||
sse.OutboundQueue.Enqueue(new ChatTabListEvent(HostContext.Processing.GetAllTabs()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendNewMessage(Message message)
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new NewMessageEvent(HostContext.Processing.ReadMessageContent(message));
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending message over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendBulkMessageList()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(HostContext.Processing.ReadMessageList().Result)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendChannelSwitch(Chunk[] channelName)
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(HostContext.Processing.ReadChannelName(channelName)));
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendChannelList()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(() =>
|
|
||||||
{
|
|
||||||
var bundledResponse = new ChannelListEvent(HostContext.Processing.GetValidChannels());
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void SendNewLogin()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Plugin.Framework.RunOnTick(async () =>
|
|
||||||
{
|
|
||||||
foreach (var eventServer in HostContext.EventConnections)
|
|
||||||
await HostContext.Core.PrepareNewClient(eventServer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Plugin.Log.Error(ex, "Preparing all clients after login failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public void InvalidateSessions()
|
|
||||||
{
|
|
||||||
if (!HostContext.IsActive)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Plugin.Config.AuthStore.Clear();
|
|
||||||
Plugin.SaveConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsActive()
|
|
||||||
{
|
|
||||||
return HostContext is { IsActive: true, Host.IsListening: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsStopping()
|
|
||||||
{
|
|
||||||
return HostContext is { IsActive: false, IsStopping: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public bool Start()
|
|
||||||
{
|
|
||||||
return HostContext.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run()
|
|
||||||
{
|
|
||||||
HostContext.Run();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<bool> Stop()
|
|
||||||
{
|
|
||||||
return await HostContext.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace ChatTwo.Http;
|
|
||||||
|
|
||||||
public static class WebserverUtil
|
|
||||||
{
|
|
||||||
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> func)
|
|
||||||
{
|
|
||||||
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// From: https://github.com/NancyFx/Nancy/blob/master/src/Nancy/Request.cs#L176
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the cookie data from the provided string if it exists
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cookieHeader">The string containing cookie data</param>
|
|
||||||
/// <returns>Cookies dictionary</returns>
|
|
||||||
public static Dictionary<string, string> GetCookieData(string cookieHeader)
|
|
||||||
{
|
|
||||||
var cookieDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (cookieHeader.Length == 0)
|
|
||||||
return cookieDictionary;
|
|
||||||
|
|
||||||
var values = cookieHeader.TrimEnd(';').Split(';');
|
|
||||||
foreach (var parts in values.Select(c => c.Split(['='], 2)))
|
|
||||||
{
|
|
||||||
var cookieName = parts[0].Trim();
|
|
||||||
var cookieValue = parts.Length == 1 ? string.Empty : parts[1]; //Cookie attribute
|
|
||||||
|
|
||||||
cookieDictionary[cookieName] = cookieValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookieDictionary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ChatTwo.Tests")]
|
|
||||||
-141
@@ -1,141 +0,0 @@
|
|||||||
//------------------------------------------------------------------------------
|
|
||||||
// <auto-generated>
|
|
||||||
// Hand-maintained strongly-typed accessor for HellionStrings.resx.
|
|
||||||
// Mirrors the layout of Language.Designer.cs so the same Plugin.cs
|
|
||||||
// LanguageChanged handler can update Culture for both classes.
|
|
||||||
// </auto-generated>
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace ChatTwo.Resources;
|
|
||||||
|
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
|
||||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]
|
|
||||||
internal class HellionStrings
|
|
||||||
{
|
|
||||||
private static global::System.Resources.ResourceManager? resourceMan;
|
|
||||||
private static global::System.Globalization.CultureInfo? resourceCulture;
|
|
||||||
|
|
||||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
|
||||||
internal HellionStrings() { }
|
|
||||||
|
|
||||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
|
||||||
internal static global::System.Resources.ResourceManager ResourceManager
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (resourceMan is null)
|
|
||||||
resourceMan = new global::System.Resources.ResourceManager("ChatTwo.Resources.HellionStrings", typeof(HellionStrings).Assembly);
|
|
||||||
return resourceMan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
|
||||||
internal static global::System.Globalization.CultureInfo? Culture
|
|
||||||
{
|
|
||||||
get => resourceCulture;
|
|
||||||
set => resourceCulture = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Get(string key)
|
|
||||||
=> ResourceManager.GetString(key, resourceCulture) ?? key;
|
|
||||||
|
|
||||||
internal static string Privacy_Tab_Title => Get(nameof(Privacy_Tab_Title));
|
|
||||||
internal static string Privacy_FilterEnabled_Name => Get(nameof(Privacy_FilterEnabled_Name));
|
|
||||||
internal static string Privacy_FilterEnabled_Description => Get(nameof(Privacy_FilterEnabled_Description));
|
|
||||||
internal static string Privacy_Whitelist_Help => Get(nameof(Privacy_Whitelist_Help));
|
|
||||||
internal static string Privacy_Preset_PrivacyFirst => Get(nameof(Privacy_Preset_PrivacyFirst));
|
|
||||||
internal static string Privacy_Preset_ClearAll => Get(nameof(Privacy_Preset_ClearAll));
|
|
||||||
internal static string Privacy_Preset_SelectAll => Get(nameof(Privacy_Preset_SelectAll));
|
|
||||||
internal static string Privacy_Group_DirectMessages => Get(nameof(Privacy_Group_DirectMessages));
|
|
||||||
internal static string Privacy_Group_PartyAlliance => Get(nameof(Privacy_Group_PartyAlliance));
|
|
||||||
internal static string Privacy_Group_FreeCompany => Get(nameof(Privacy_Group_FreeCompany));
|
|
||||||
internal static string Privacy_Group_Linkshells => Get(nameof(Privacy_Group_Linkshells));
|
|
||||||
internal static string Privacy_Group_CrossLinkshells => Get(nameof(Privacy_Group_CrossLinkshells));
|
|
||||||
internal static string Privacy_Group_ExtraChat => Get(nameof(Privacy_Group_ExtraChat));
|
|
||||||
internal static string Privacy_Group_PublicChat => Get(nameof(Privacy_Group_PublicChat));
|
|
||||||
internal static string Privacy_Group_SystemLogs => Get(nameof(Privacy_Group_SystemLogs));
|
|
||||||
internal static string Privacy_PersistUnknown_Name => Get(nameof(Privacy_PersistUnknown_Name));
|
|
||||||
internal static string Privacy_PersistUnknown_Description => Get(nameof(Privacy_PersistUnknown_Description));
|
|
||||||
|
|
||||||
internal static string Cleanup_Heading => Get(nameof(Cleanup_Heading));
|
|
||||||
internal static string Cleanup_Help_Intro => Get(nameof(Cleanup_Help_Intro));
|
|
||||||
internal static string Cleanup_Help_SavedNote => Get(nameof(Cleanup_Help_SavedNote));
|
|
||||||
internal static string Cleanup_RefreshPreview => Get(nameof(Cleanup_RefreshPreview));
|
|
||||||
internal static string Cleanup_NoPreview => Get(nameof(Cleanup_NoPreview));
|
|
||||||
internal static string Cleanup_TotalStored => Get(nameof(Cleanup_TotalStored));
|
|
||||||
internal static string Cleanup_WillKeep => Get(nameof(Cleanup_WillKeep));
|
|
||||||
internal static string Cleanup_WillDelete => Get(nameof(Cleanup_WillDelete));
|
|
||||||
internal static string Cleanup_Breakdown => Get(nameof(Cleanup_Breakdown));
|
|
||||||
internal static string Cleanup_Marker_Keep => Get(nameof(Cleanup_Marker_Keep));
|
|
||||||
internal static string Cleanup_Marker_Delete => Get(nameof(Cleanup_Marker_Delete));
|
|
||||||
internal static string Cleanup_Apply_Label => Get(nameof(Cleanup_Apply_Label));
|
|
||||||
internal static string Cleanup_Apply_Tooltip => Get(nameof(Cleanup_Apply_Tooltip));
|
|
||||||
internal static string Cleanup_Running => Get(nameof(Cleanup_Running));
|
|
||||||
internal static string Cleanup_PreviewError => Get(nameof(Cleanup_PreviewError));
|
|
||||||
internal static string Cleanup_Success => Get(nameof(Cleanup_Success));
|
|
||||||
internal static string Cleanup_Error => Get(nameof(Cleanup_Error));
|
|
||||||
|
|
||||||
internal static string Retention_Heading => Get(nameof(Retention_Heading));
|
|
||||||
internal static string Retention_Enabled_Name => Get(nameof(Retention_Enabled_Name));
|
|
||||||
internal static string Retention_Enabled_Description => Get(nameof(Retention_Enabled_Description));
|
|
||||||
internal static string Retention_Default_Label => Get(nameof(Retention_Default_Label));
|
|
||||||
internal static string Retention_Default_Help => Get(nameof(Retention_Default_Help));
|
|
||||||
internal static string Retention_Reset_Spec => Get(nameof(Retention_Reset_Spec));
|
|
||||||
internal static string Retention_Clear_Overrides => Get(nameof(Retention_Clear_Overrides));
|
|
||||||
internal static string Retention_Tree_Heading => Get(nameof(Retention_Tree_Heading));
|
|
||||||
internal static string Retention_Tag_Override => Get(nameof(Retention_Tag_Override));
|
|
||||||
internal static string Retention_Tag_Spec => Get(nameof(Retention_Tag_Spec));
|
|
||||||
internal static string Retention_Tag_Global => Get(nameof(Retention_Tag_Global));
|
|
||||||
internal static string Retention_Reset_Button => Get(nameof(Retention_Reset_Button));
|
|
||||||
internal static string Retention_Apply_Label => Get(nameof(Retention_Apply_Label));
|
|
||||||
internal static string Retention_Apply_Tooltip => Get(nameof(Retention_Apply_Tooltip));
|
|
||||||
internal static string Retention_Running => Get(nameof(Retention_Running));
|
|
||||||
internal static string Retention_LastRun_Never => Get(nameof(Retention_LastRun_Never));
|
|
||||||
internal static string Retention_LastRun_At => Get(nameof(Retention_LastRun_At));
|
|
||||||
internal static string Retention_Success => Get(nameof(Retention_Success));
|
|
||||||
internal static string Retention_Error => Get(nameof(Retention_Error));
|
|
||||||
|
|
||||||
internal static string Migration_Notification_Title => Get(nameof(Migration_Notification_Title));
|
|
||||||
internal static string Migration_Notification_Content => Get(nameof(Migration_Notification_Content));
|
|
||||||
|
|
||||||
internal static string Wizard_Title => Get(nameof(Wizard_Title));
|
|
||||||
internal static string Wizard_Intro => Get(nameof(Wizard_Intro));
|
|
||||||
internal static string Wizard_Profile_PrivacyFirst_Heading => Get(nameof(Wizard_Profile_PrivacyFirst_Heading));
|
|
||||||
internal static string Wizard_Profile_PrivacyFirst_Description => Get(nameof(Wizard_Profile_PrivacyFirst_Description));
|
|
||||||
internal static string Wizard_Profile_PrivacyFirst_Apply => Get(nameof(Wizard_Profile_PrivacyFirst_Apply));
|
|
||||||
internal static string Wizard_Profile_Casual_Heading => Get(nameof(Wizard_Profile_Casual_Heading));
|
|
||||||
internal static string Wizard_Profile_Casual_Description => Get(nameof(Wizard_Profile_Casual_Description));
|
|
||||||
internal static string Wizard_Profile_Casual_Apply => Get(nameof(Wizard_Profile_Casual_Apply));
|
|
||||||
internal static string Wizard_Profile_FullHistory_Heading => Get(nameof(Wizard_Profile_FullHistory_Heading));
|
|
||||||
internal static string Wizard_Profile_FullHistory_Description => Get(nameof(Wizard_Profile_FullHistory_Description));
|
|
||||||
internal static string Wizard_Profile_FullHistory_GdprWarning => Get(nameof(Wizard_Profile_FullHistory_GdprWarning));
|
|
||||||
internal static string Wizard_Profile_FullHistory_Apply => Get(nameof(Wizard_Profile_FullHistory_Apply));
|
|
||||||
internal static string Wizard_Reopen_Button => Get(nameof(Wizard_Reopen_Button));
|
|
||||||
|
|
||||||
internal static string Export_Heading => Get(nameof(Export_Heading));
|
|
||||||
internal static string Export_Help => Get(nameof(Export_Help));
|
|
||||||
internal static string Export_Range_Label => Get(nameof(Export_Range_Label));
|
|
||||||
internal static string Export_Sender_Label => Get(nameof(Export_Sender_Label));
|
|
||||||
internal static string Export_Channels_Heading => Get(nameof(Export_Channels_Heading));
|
|
||||||
internal static string Export_Channels_AllOff => Get(nameof(Export_Channels_AllOff));
|
|
||||||
internal static string Export_Format_Label => Get(nameof(Export_Format_Label));
|
|
||||||
internal static string Export_Format_Markdown => Get(nameof(Export_Format_Markdown));
|
|
||||||
internal static string Export_Format_Json => Get(nameof(Export_Format_Json));
|
|
||||||
internal static string Export_Format_Csv => Get(nameof(Export_Format_Csv));
|
|
||||||
internal static string Export_Button => Get(nameof(Export_Button));
|
|
||||||
internal static string Export_Dialog_Title => Get(nameof(Export_Dialog_Title));
|
|
||||||
internal static string Export_Running => Get(nameof(Export_Running));
|
|
||||||
internal static string Export_Success => Get(nameof(Export_Success));
|
|
||||||
internal static string Export_Empty => Get(nameof(Export_Empty));
|
|
||||||
internal static string Export_Error => Get(nameof(Export_Error));
|
|
||||||
|
|
||||||
internal static string Theme_Heading => Get(nameof(Theme_Heading));
|
|
||||||
internal static string Theme_Enabled_Name => Get(nameof(Theme_Enabled_Name));
|
|
||||||
internal static string Theme_Enabled_Description => Get(nameof(Theme_Enabled_Description));
|
|
||||||
internal static string Theme_WindowOpacity_Label => Get(nameof(Theme_WindowOpacity_Label));
|
|
||||||
internal static string Theme_WindowOpacity_Help => Get(nameof(Theme_WindowOpacity_Help));
|
|
||||||
internal static string Theme_UseHellionFont_Name => Get(nameof(Theme_UseHellionFont_Name));
|
|
||||||
internal static string Theme_UseHellionFont_Description => Get(nameof(Theme_UseHellionFont_Description));
|
|
||||||
}
|
|
||||||
@@ -1,288 +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>Datenschutz</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_FilterEnabled_Name" xml:space="preserve">
|
|
||||||
<value>Datenschutz-Filter aktivieren</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
|
|
||||||
<value>Wenn aktiviert, werden nur Nachrichten aus den erlaubten Kanälen in die Datenbank gespeichert. Beim Deaktivieren gilt wieder das Standard-Verhalten von ChatTwo, also alles außer Battle-Logs wird gespeichert.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Whitelist_Help" xml:space="preserve">
|
|
||||||
<value>Wähle aus, welche Kanäle in die lokale Datenbank gespeichert werden. Standard nach Datensparsamkeit: nur deine eigenen Konversationen. Über die Buttons unten kannst du eine Voreinstellung anwenden.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Preset_PrivacyFirst" xml:space="preserve">
|
|
||||||
<value>Datensparsamkeit (empfohlen)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Preset_ClearAll" xml:space="preserve">
|
|
||||||
<value>Alle abwählen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Preset_SelectAll" xml:space="preserve">
|
|
||||||
<value>Alle auswählen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Group_DirectMessages" xml:space="preserve">
|
|
||||||
<value>Direktnachrichten</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Group_PartyAlliance" xml:space="preserve">
|
|
||||||
<value>Gruppe & Allianz</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 (verschlüsselt)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Group_PublicChat" xml:space="preserve">
|
|
||||||
<value>Öffentlicher Chat (Daten Dritter)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_Group_SystemLogs" xml:space="preserve">
|
|
||||||
<value>System & Spiel-Logs</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_PersistUnknown_Name" xml:space="preserve">
|
|
||||||
<value>Unbekannte Kanal-Typen speichern</value>
|
|
||||||
</data>
|
|
||||||
<data name="Privacy_PersistUnknown_Description" xml:space="preserve">
|
|
||||||
<value>Sicherheitsnetz für ChatTypes, die durch zukünftige FFXIV-Patches dazukommen und dem Plugin noch nicht bekannt sind. Standard ist AUS (Datensparsamkeit). Aktivieren, wenn du auch zukünftige Kanäle vollständig mitloggen willst.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Heading" xml:space="preserve">
|
|
||||||
<value>Filter auf bestehende Datenbank anwenden</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Help_Intro" xml:space="preserve">
|
|
||||||
<value>Der Datenschutz-Filter wirkt nur auf neue Nachrichten. Über das Aufräumen unten kannst du bereits gespeicherte Nachrichten nachträglich entfernen, die nicht zu deiner gespeicherten Whitelist passen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Help_SavedNote" xml:space="preserve">
|
|
||||||
<value>Das Aufräumen nutzt deine GESPEICHERTE Whitelist (Plugin.Config), nicht ungespeicherte Änderungen oben. Klicke zuerst Speichern, wenn du deine aktuellen Änderungen anwenden willst.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_RefreshPreview" xml:space="preserve">
|
|
||||||
<value>Vorschau aktualisieren</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_NoPreview" xml:space="preserve">
|
|
||||||
<value>Noch keine Vorschau. Klicke Aktualisieren, um die Auswirkung zu berechnen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_TotalStored" xml:space="preserve">
|
|
||||||
<value>Gespeicherte Nachrichten gesamt: {0:N0}</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_WillKeep" xml:space="preserve">
|
|
||||||
<value>Behalten: {0:N0}</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_WillDelete" xml:space="preserve">
|
|
||||||
<value>Löschen: {0:N0}</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Breakdown" xml:space="preserve">
|
|
||||||
<value>Aufschlüsselung pro Kanal</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Marker_Keep" xml:space="preserve">
|
|
||||||
<value>[BEHALTEN]</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Marker_Delete" xml:space="preserve">
|
|
||||||
<value>[LÖSCHEN] </value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Apply_Label" xml:space="preserve">
|
|
||||||
<value>Aktuellen Filter auf Datenbank anwenden</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Apply_Tooltip" xml:space="preserve">
|
|
||||||
<value>Strg+Umschalt: Löscht {0:N0} Nachrichten unwiderruflich und führt danach VACUUM aus. Nicht rückgängig zu machen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Running" xml:space="preserve">
|
|
||||||
<value>Aufräumen läuft im Hintergrund…</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_PreviewError" xml:space="preserve">
|
|
||||||
<value>Vorschau konnte nicht berechnet werden, siehe /xllog</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Success" xml:space="preserve">
|
|
||||||
<value>Aufräumen abgeschlossen, {0:N0} Nachrichten entfernt.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Cleanup_Error" xml:space="preserve">
|
|
||||||
<value>Aufräumen fehlgeschlagen, siehe /xllog</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Heading" xml:space="preserve">
|
|
||||||
<value>Aufbewahrung von Nachrichten</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Enabled_Name" xml:space="preserve">
|
|
||||||
<value>Nachrichten nach Kanal-Aufbewahrung automatisch löschen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Enabled_Description" xml:space="preserve">
|
|
||||||
<value>Wenn aktiviert, werden Nachrichten älter als das eingestellte Fenster bei jedem Plugin-Start gelöscht (höchstens einmal pro 24 Stunden). Standard ist AUS, das Plugin löscht ohne deine ausdrückliche Zustimmung nichts.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Default_Label" xml:space="preserve">
|
|
||||||
<value>Standard-Aufbewahrung (Tage, 0 = nie)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Default_Help" xml:space="preserve">
|
|
||||||
<value>Gilt für Kanäle, die unten keine eigene Vorgabe haben.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Reset_Spec" xml:space="preserve">
|
|
||||||
<value>Vorgaben auf Spec-Defaults setzen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Clear_Overrides" xml:space="preserve">
|
|
||||||
<value>Alle Vorgaben entfernen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Tree_Heading" xml:space="preserve">
|
|
||||||
<value>Aufbewahrung pro Kanal</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Tag_Override" xml:space="preserve">
|
|
||||||
<value>[eigen]</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>zurück</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Apply_Label" xml:space="preserve">
|
|
||||||
<value>Aufbewahrung jetzt anwenden</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Apply_Tooltip" xml:space="preserve">
|
|
||||||
<value>Strg+Umschalt: Führt die Aufbewahrungs-Bereinigung sofort mit der GESPEICHERTEN Vorgabe aus. Speichere deine Änderungen vorher.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Running" xml:space="preserve">
|
|
||||||
<value>Aufbewahrungs-Bereinigung läuft im Hintergrund…</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_LastRun_Never" xml:space="preserve">
|
|
||||||
<value>Letzter Lauf: nie</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_LastRun_At" xml:space="preserve">
|
|
||||||
<value>Letzter Lauf: {0:yyyy-MM-dd HH:mm}</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Success" xml:space="preserve">
|
|
||||||
<value>Aufbewahrungs-Bereinigung abgeschlossen, {0:N0} Nachrichten entfernt.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Retention_Error" xml:space="preserve">
|
|
||||||
<value>Aufbewahrungs-Bereinigung fehlgeschlagen, siehe /xllog</value>
|
|
||||||
</data>
|
|
||||||
<data name="Migration_Notification_Title" xml:space="preserve">
|
|
||||||
<value>Hellion Chat</value>
|
|
||||||
</data>
|
|
||||||
<data name="Migration_Notification_Content" xml:space="preserve">
|
|
||||||
<value>Datenschutz-Filter ist standardmäßig aktiviert. Einstellungen → Datenschutz zum Anpassen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Title" xml:space="preserve">
|
|
||||||
<value>Hellion Chat — Willkommen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Intro" xml:space="preserve">
|
|
||||||
<value>Wähle ein Start-Profil. Du kannst später alles unter Einstellungen → Datenschutz anpassen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_PrivacyFirst_Heading" xml:space="preserve">
|
|
||||||
<value>Datensparsamkeit (empfohlen)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_PrivacyFirst_Description" xml:space="preserve">
|
|
||||||
<value>Es werden nur deine eigenen Konversationen gespeichert: Tells, Gruppe, FC, Linkshells, Cross-World-Linkshells, Allianz und ExtraChat. Öffentlicher Chat, NPC-Dialoge und System-Spam werden auf der Storage-Ebene verworfen. Aufbewahrung nach Spec-Defaults (Tells 365 Tage, eigene Konversations-Kanäle 90 Tage).</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_PrivacyFirst_Apply" xml:space="preserve">
|
|
||||||
<value>Datensparsamkeit übernehmen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_Casual_Heading" xml:space="preserve">
|
|
||||||
<value>Locker</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_Casual_Description" xml:space="preserve">
|
|
||||||
<value>Datensparsamkeit plus ein 24-Stunden-Fenster für öffentlichen Chat (Sagen, Schreien, Rufen, beide Emote-Typen, Anfänger-Netzwerk). Für RP-Spieler, die die letzte Szene nochmal nachlesen wollen, ohne öffentlichen Chat ewig zu behalten.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_Casual_Apply" xml:space="preserve">
|
|
||||||
<value>Locker übernehmen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_FullHistory_Heading" xml:space="preserve">
|
|
||||||
<value>Volle Historie</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_FullHistory_Description" xml:space="preserve">
|
|
||||||
<value>Deaktiviert den Datenschutz-Filter komplett. Speichert alles außer Battle-Logs, wie das Original-Chat 2. Aufbewahrung ist AUS, die Historie wächst dauerhaft.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_FullHistory_GdprWarning" xml:space="preserve">
|
|
||||||
<value>DSGVO-Hinweis: Wenn du Nachrichten Dritter (Sagen/Schreien/Rufen fremder Spieler, NPC-Dialoge mit Spielernamen usw.) zeitlich unbegrenzt speicherst, kann das die Ausnahme für rein persönliche oder familiäre Tätigkeiten (Art. 2 Abs. 2 Buchst. c) sprengen. Nutze dieses Profil nur, wenn du einen klaren Grund hast, das volle Archiv zu behalten.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Profile_FullHistory_Apply" xml:space="preserve">
|
|
||||||
<value>Volle Historie übernehmen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Wizard_Reopen_Button" xml:space="preserve">
|
|
||||||
<value>Wizard erneut zeigen</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Heading" xml:space="preserve">
|
|
||||||
<value>Export (DSGVO Art. 15 — Auskunftsrecht)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Help" xml:space="preserve">
|
|
||||||
<value>Gespeicherte Nachrichten als Markdown, JSON oder CSV exportieren. Damit kannst du einer Auskunftsanfrage einer Person nachkommen, deren Nachrichten du gespeichert hast, oder deine eigene Historie mitnehmen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Range_Label" xml:space="preserve">
|
|
||||||
<value>Letzte X Tage (0 = ohne Zeitlimit)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Sender_Label" xml:space="preserve">
|
|
||||||
<value>Sender enthält (optional, Groß-/Kleinschreibung egal)</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Channels_Heading" xml:space="preserve">
|
|
||||||
<value>Auf Kanäle einschränken</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Channels_AllOff" xml:space="preserve">
|
|
||||||
<value>(nichts ausgewählt = alle gespeicherten Kanäle)</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>In Datei exportieren…</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Dialog_Title" xml:space="preserve">
|
|
||||||
<value>Export speichern</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Running" xml:space="preserve">
|
|
||||||
<value>Export läuft im Hintergrund…</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Success" xml:space="preserve">
|
|
||||||
<value>Export abgeschlossen, {0:N0} Nachrichten in {1} geschrieben</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Empty" xml:space="preserve">
|
|
||||||
<value>Export abgeschlossen, keine Nachricht passte zum Filter.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Export_Error" xml:space="preserve">
|
|
||||||
<value>Export fehlgeschlagen, siehe /xllog</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_Heading" xml:space="preserve">
|
|
||||||
<value>Erscheinungsbild</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_Enabled_Name" xml:space="preserve">
|
|
||||||
<value>Hellion-Theme für alle Plugin-Fenster verwenden</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_Enabled_Description" xml:space="preserve">
|
|
||||||
<value>Industrielle HUD-Palette mit cyan-blauen Aktionsfarben, schiefer-violetten Tabs und Bernstein-Akzenten für aktive Zustände, global angewendet auf Chat-Fenster, Einstellungen, Viewer und Wizard. Deaktivieren, um das Standard-Dalamud-Erscheinungsbild zu nutzen.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
|
|
||||||
<value>Fenster-Deckkraft</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_WindowOpacity_Help" xml:space="preserve">
|
|
||||||
<value>Wie deckend die Plugin-Fenster sind. Niedrigere Werte lassen das Spiel durchscheinen, Form-Felder und Dialoge bleiben oben drauf deckend und gut lesbar.</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_UseHellionFont_Name" xml:space="preserve">
|
|
||||||
<value>Mitgelieferte Hellion-Schrift (Exo 2) verwenden</value>
|
|
||||||
</data>
|
|
||||||
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
|
|
||||||
<value>Rendert Chat und UI in Exo 2 (SIL Open Font License 1.1), die mit dem Plugin ausgeliefert wird. Deaktivieren, um auf die unter Einstellungen → Schrift gewählte Schriftart zurückzufallen.</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
|
||||||
@@ -1,288 +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_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="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="Migration_Notification_Title" xml:space="preserve">
|
|
||||||
<value>Hellion Chat</value>
|
|
||||||
</data>
|
|
||||||
<data name="Migration_Notification_Content" xml:space="preserve">
|
|
||||||
<value>Privacy filter activated by default. Settings → Privacy to adjust.</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_Heading" xml:space="preserve">
|
|
||||||
<value>Appearance</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>Industrial HUD palette with cyan-teal action accents, slate-violet tabs and amber active highlights, applied globally to 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>
|
|
||||||
</root>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
using System.Numerics;
|
|
||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Interface.Colors;
|
|
||||||
using Dalamud.Interface.Utility;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class About : ISettingsTab
|
|
||||||
{
|
|
||||||
public string Name => string.Format(Language.Options_About_Tab, Plugin.PluginName) + "###tabs-about";
|
|
||||||
|
|
||||||
private readonly List<string> Translators =
|
|
||||||
[
|
|
||||||
"q673135110", "Akizem", "d0tiKs",
|
|
||||||
"Moonlight_Everlit", "Dark32", "andreycout",
|
|
||||||
"Button_", "Cali666", "cassandra308",
|
|
||||||
"lokinmodar", "jtabox", "AkiraYorumoto",
|
|
||||||
"MKhayle", "elena.space", "imlisa",
|
|
||||||
"andrei5125", "ShivaMaheshvara", "aislinn87",
|
|
||||||
"nishinatsu051", "lichuyuan", "Risu64",
|
|
||||||
"yummypillow", "witchymary", "Yuzumi",
|
|
||||||
"zomsakura", "Sirayuki"
|
|
||||||
];
|
|
||||||
|
|
||||||
internal About()
|
|
||||||
{
|
|
||||||
Translators.Sort((a, b) => string.Compare(a.ToLowerInvariant(), b.ToLowerInvariant(), StringComparison.Ordinal));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(string.Format(Language.Options_About_Opening, Plugin.PluginName));
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Authors);
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, Plugin.Interface.Manifest.Author);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Discord);
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "@j.j_kazama");
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Version);
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedOrange, Plugin.Interface.Manifest.AssemblyVersion.ToString(3));
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_About_Github_Issues);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "githubIssues"))
|
|
||||||
Dalamud.Utility.Util.OpenLink("https://github.com/JonKazama-Hellion/HellionChat/issues");
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
// Hellion-specific maintainer / attribution / license / SE-
|
|
||||||
// disclaimer block. Hand-rolled in English here rather than via
|
|
||||||
// HellionStrings — the legal-ish copy stays close to the EUPL-1.2
|
|
||||||
// wording and the SE disclaimer is the same in every locale.
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "Maintainer");
|
|
||||||
ImGui.TextUnformatted("Hellion Chat is maintained by Hellion Online Media (Florian Wathling).");
|
|
||||||
ImGui.TextUnformatted("Website:");
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "hellionMedia"))
|
|
||||||
Dalamud.Utility.Util.OpenLink("https://hellion-media.de");
|
|
||||||
ImGui.TextUnformatted("For licensing, legal or contact inquiries please reach out via the website above.");
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "Built on Chat 2");
|
|
||||||
ImGui.TextUnformatted("Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens).");
|
|
||||||
ImGui.TextUnformatted("Every chat replacement feature, the IPC integration, the rendering engine and the storage core come from upstream Chat 2.");
|
|
||||||
ImGui.TextUnformatted("Upstream repository:");
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "chatTwoUpstream"))
|
|
||||||
Dalamud.Utility.Util.OpenLink("https://github.com/Infiziert90/ChatTwo");
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "License");
|
|
||||||
ImGui.TextUnformatted("Hellion Chat and Chat 2 are licensed under the European Union Public License v1.2 (EUPL-1.2).");
|
|
||||||
ImGui.TextUnformatted("© 2023–2026 the Chat 2 authors (Infi, Anna and the upstream contributors).");
|
|
||||||
ImGui.TextUnformatted("© 2026 Hellion Online Media — for the Hellion Chat additions.");
|
|
||||||
|
|
||||||
ImGuiHelpers.ScaledDummy(10.0f);
|
|
||||||
|
|
||||||
ImGui.TextColored(ImGuiColors.DalamudOrange, "FINAL FANTASY XIV disclaimer");
|
|
||||||
ImGui.TextUnformatted("FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.");
|
|
||||||
ImGui.TextUnformatted("Hellion Chat is an unofficial, fan-made plugin and is not affiliated with, endorsed, sponsored or approved by Square Enix.");
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextColored(ImGuiColors.ParsedGold, "Localization");
|
|
||||||
ImGui.TextUnformatted("German translations of Hellion-specific UI strings (HellionStrings.de.resx) are written by the Hellion Online Media maintainer.");
|
|
||||||
ImGui.TextUnformatted("All other locales for Hellion-specific strings are not currently provided.");
|
|
||||||
ImGui.TextUnformatted("The translator list below covers the upstream Chat 2 community translators on Crowdin — their work covers the inherited Chat 2 strings, not the Hellion additions.");
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
var height = ImGui.GetContentRegionAvail().Y - ImGui.CalcTextSize("A").Y - ImGui.GetStyle().ItemSpacing.Y * 2;
|
|
||||||
using (var aboutChild = ImRaii.Child("about", new Vector2(-1, height)))
|
|
||||||
{
|
|
||||||
if (aboutChild)
|
|
||||||
{
|
|
||||||
using var treeNode = ImRaii.TreeNode("Chat 2 community translators (upstream)");
|
|
||||||
if (treeNode)
|
|
||||||
{
|
|
||||||
using var translatorChild = ImRaii.Child("translators");
|
|
||||||
if (translatorChild)
|
|
||||||
{
|
|
||||||
foreach (var translator in Translators)
|
|
||||||
ImGui.TextUnformatted(translator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Changelog : ISettingsTab
|
|
||||||
{
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_Changelog_Tab + "###tabs-changelog";
|
|
||||||
|
|
||||||
internal Changelog(Configuration mutable)
|
|
||||||
{
|
|
||||||
Mutable = mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_Warning_NotImplemented);
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.PrintChangelog, Language.Options_PrintChangelog_Name, Language.Options_PrintChangelog_Description);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
var changelog = Plugin.Interface.Manifest.Changelog;
|
|
||||||
if (changelog != null)
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(Language.Options_Changelog_Header);
|
|
||||||
ImGui.TextUnformatted($"Version {Plugin.Interface.Manifest.AssemblyVersion.ToString(3)}");
|
|
||||||
ImGui.Spacing();
|
|
||||||
foreach (var sentence in changelog.Split("\n"))
|
|
||||||
{
|
|
||||||
if (sentence == string.Empty)
|
|
||||||
{
|
|
||||||
ImGui.NewLine();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var condition = sentence.StartsWith('-') || sentence.StartsWith(" -");
|
|
||||||
using var indent = ImRaii.PushIndent(10.0f, true, condition);
|
|
||||||
ImGui.TextUnformatted(sentence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
using ChatTwo.Code;
|
|
||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class ChatColours : ISettingsTab
|
|
||||||
{
|
|
||||||
private Plugin Plugin { get; }
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_ChatColours_Tab + "###tabs-chat-colours";
|
|
||||||
|
|
||||||
internal ChatColours(Plugin plugin, Configuration mutable)
|
|
||||||
{
|
|
||||||
Plugin = plugin;
|
|
||||||
Mutable = mutable;
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Users can set colours for ExtraChat linkshells in the ExtraChat plugin directly.
|
|
||||||
var sortable = ChatTypeExt.SortOrder
|
|
||||||
.SelectMany(entry => entry.Item2)
|
|
||||||
.Where(type => !type.IsGm() && !type.IsExtraChatLinkshell())
|
|
||||||
.ToHashSet();
|
|
||||||
var total = Enum.GetValues<ChatType>()
|
|
||||||
.Where(type => !type.IsGm() && !type.IsExtraChatLinkshell())
|
|
||||||
.ToHashSet();
|
|
||||||
if (sortable.Count != total.Count)
|
|
||||||
{
|
|
||||||
Plugin.Log.Warning($"There are {sortable.Count} sortable channels, but there are {total.Count} total channels.");
|
|
||||||
total.ExceptWith(sortable);
|
|
||||||
foreach (var missing in total)
|
|
||||||
Plugin.Log.Information($"Missing {missing}");
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface.Style;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class ChatLog : ISettingsTab
|
|
||||||
{
|
|
||||||
private readonly Plugin Plugin;
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_ChatLog_Tab + "###tabs-chatlog";
|
|
||||||
|
|
||||||
internal ChatLog(Plugin plugin, Configuration mutable)
|
|
||||||
{
|
|
||||||
Plugin = plugin;
|
|
||||||
Mutable = mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using (ImRaii.TextWrapPos(0.0f))
|
|
||||||
{
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.KeepInputFocus, Language.Options_KeepInputFocus_Name, Language.Options_KeepInputFocus_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.PlaySounds, Language.Options_PlaySounds_Name, Language.Options_PlaySounds_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.SidebarTabView, Language.Options_SidebarTabView_Name, string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.ShowNoviceNetwork, Language.Options_ShowNoviceNetwork_Name, Language.Options_ShowNoviceNetwork_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.ShowHideButton, Language.Options_ShowHideButton_Name, Language.Options_ShowHideButton_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.NativeItemTooltips, Language.Options_NativeItemTooltips_Name, string.Format(Language.Options_NativeItemTooltips_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
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);
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Options_MaxLinesToShow_Name, Language.Options_MaxLinesToShow_Description, ref Mutable.MaxLinesToRender))
|
|
||||||
Mutable.MaxLinesToRender = Math.Clamp(Mutable.MaxLinesToRender, 1, 10_000);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.CanMove, Language.Options_CanMove_Name);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.CanResize, Language.Options_CanResize_Name);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.ShowTitleBar, Language.Options_ShowTitleBar_Name);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.ShowPopOutTitleBar, Language.Options_ShowPopOutTitleBar_Name);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.OverrideStyle, Language.Options_OverrideStyle_Name, Language.Options_OverrideStyle_Name_Desc);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_ChatTabForwardKeybind_Name);
|
|
||||||
ImGui.SetNextItemWidth(-1);
|
|
||||||
ImGuiUtil.KeybindInput("ChatTabForwardKeybind", ref Mutable.ChatTabForward);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_ChatTabBackwardKeybind_Name);
|
|
||||||
ImGui.SetNextItemWidth(-1);
|
|
||||||
ImGuiUtil.KeybindInput("ChatTabBackwardKeybind", ref Mutable.ChatTabBackward);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_AdjustPosition_Name);
|
|
||||||
ImGui.SetNextItemWidth(-1);
|
|
||||||
var pos = Plugin.ChatLogWindow.LastWindowPos;
|
|
||||||
if (ImGui.DragFloat2($"##{Language.Options_AdjustPosition_Name}", ref pos, 1, 0, float.MaxValue, "%.0fpx"))
|
|
||||||
Plugin.ChatLogWindow.Position = pos;
|
|
||||||
ImGuiUtil.WarningText(Language.Options_AdjustPosition_Warning);
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Mutable.OverrideStyle)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var styles = StyleModel.GetConfiguredStyles();
|
|
||||||
if (styles == null)
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(Language.Options_OverrideStyle_NotAvailable);
|
|
||||||
ImGui.Spacing();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentStyle = Mutable.ChosenStyle ?? Language.Options_OverrideStyle_NotSelected;
|
|
||||||
using var combo = ImRaii.Combo(Language.Options_OverrideStyleDropdown_Name, currentStyle);
|
|
||||||
if (combo)
|
|
||||||
{
|
|
||||||
foreach (var style in styles)
|
|
||||||
if (ImGui.Selectable(style.Name, Mutable.ChosenStyle == style.Name))
|
|
||||||
Mutable.ChosenStyle = style.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Display : ISettingsTab
|
|
||||||
{
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_Display_Tab + "###tabs-display";
|
|
||||||
|
|
||||||
internal Display(Configuration mutable)
|
|
||||||
{
|
|
||||||
Mutable = mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideChat, Language.Options_HideChat_Name, Language.Options_HideChat_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideDuringCutscenes, Language.Options_HideDuringCutscenes_Name, string.Format(Language.Options_HideDuringCutscenes_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideWhenNotLoggedIn, Language.Options_HideWhenNotLoggedIn_Name, string.Format(Language.Options_HideWhenNotLoggedIn_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideWhenUiHidden, Language.Options_HideWhenUiHidden_Name, string.Format(Language.Options_HideWhenUiHidden_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideInLoadingScreens, Language.Options_HideInLoadingScreens_Name, string.Format(Language.Options_HideInLoadingScreens_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideInBattle, Language.Options_HideInBattle_Name, Language.Options_HideInBattle_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideWhenInactive, Language.Options_HideWhenInactive_Name, Language.Options_HideWhenInactive_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (Mutable.HideWhenInactive)
|
|
||||||
{
|
|
||||||
using var _ = ImRaii.PushIndent();
|
|
||||||
ImGuiUtil.InputIntVertical(Language.Options_InactivityHideTimeout_Name,
|
|
||||||
Language.Options_InactivityHideTimeout_Description, ref Mutable.InactivityHideTimeout, 1, 10);
|
|
||||||
// Enforce a minimum of 2 seconds to avoid people soft locking
|
|
||||||
// themselves.
|
|
||||||
Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
// This setting conflicts with HideInBattle, so it's disabled.
|
|
||||||
using (ImRaii.Disabled(Mutable.HideInBattle))
|
|
||||||
{
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.InactivityHideActiveDuringBattle,
|
|
||||||
Language.Options_InactivityHideActiveDuringBattle_Name,
|
|
||||||
Language.Options_InactivityHideActiveDuringBattle_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
using var channelTree = ImRaii.TreeNode(Language.Options_InactivityHideChannels_Name);
|
|
||||||
if (channelTree.Success)
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.Use24HourClock, Language.Options_Use24HourClock_Name, Language.Options_Use24HourClock_Description);
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.PrettierTimestamps, Language.Options_PrettierTimestamps_Name, Language.Options_PrettierTimestamps_Description);
|
|
||||||
|
|
||||||
if (Mutable.PrettierTimestamps)
|
|
||||||
{
|
|
||||||
using var _ = ImRaii.PushIndent();
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.MoreCompactPretty, Language.Options_MoreCompactPretty_Name, Language.Options_MoreCompactPretty_Description);
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.HideSameTimestamps, Language.Options_HideSameTimestamps_Name, Language.Options_HideSameTimestamps_Description);
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.CollapseDuplicateMessages, Language.Options_CollapseDuplicateMessages_Name, Language.Options_CollapseDuplicateMessages_Description);
|
|
||||||
if (Mutable.CollapseDuplicateMessages)
|
|
||||||
{
|
|
||||||
using var _ = ImRaii.PushIndent();
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.CollapseKeepUniqueLinks, Language.Options_CollapseDuplicateMsgUniqueLink_Name, Language.Options_CollapseDuplicateMsgUniqueLink_Description);
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
using System.Numerics;
|
|
||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Interface.Colors;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Emote : ISettingsTab
|
|
||||||
{
|
|
||||||
private readonly Plugin Plugin;
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_Emote_Tab + "###tabs-emote";
|
|
||||||
|
|
||||||
private static SearchSelector.SelectorPopupOptions? WordPopupOptions;
|
|
||||||
|
|
||||||
internal Emote(Plugin plugin, Configuration mutable)
|
|
||||||
{
|
|
||||||
Plugin = plugin;
|
|
||||||
Mutable = mutable;
|
|
||||||
|
|
||||||
WordPopupOptions = new SearchSelector.SelectorPopupOptions
|
|
||||||
{
|
|
||||||
FilteredSheet = EmoteCache.SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w)).ToArray()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SearchSelector.SelectorPopupOptions RefillSheet()
|
|
||||||
{
|
|
||||||
return new SearchSelector.SelectorPopupOptions
|
|
||||||
{
|
|
||||||
FilteredSheet = EmoteCache.SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w)).ToArray()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.ShowEmotes, Language.Options_ShowEmotes_Name, Language.Options_ShowEmotes_Desc);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
WordPopupOptions ??= RefillSheet();
|
|
||||||
if (EmoteCache.State is EmoteCache.LoadingState.Done && WordPopupOptions.FilteredSheet.Length == 0)
|
|
||||||
WordPopupOptions = RefillSheet();
|
|
||||||
|
|
||||||
var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
|
|
||||||
using (Plugin.FontManager.FontAwesome.Push())
|
|
||||||
ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(buttonWidth, 0));
|
|
||||||
|
|
||||||
if (SearchSelector.SelectorPopup("WordAddPopup", out var newWord, WordPopupOptions))
|
|
||||||
Mutable.BlockedEmotes.Add(newWord);
|
|
||||||
|
|
||||||
using(var table = ImRaii.Table("##BlockedWords", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner))
|
|
||||||
{
|
|
||||||
if (table)
|
|
||||||
{
|
|
||||||
ImGui.TableSetupColumn(Language.Options_Emote_EmoteTable);
|
|
||||||
ImGui.TableSetupColumn("##Del", ImGuiTableColumnFlags.WidthStretch, 0.07f);
|
|
||||||
|
|
||||||
ImGui.TableHeadersRow();
|
|
||||||
|
|
||||||
var copiedList = Mutable.BlockedEmotes.ToArray();
|
|
||||||
foreach (var word in copiedList)
|
|
||||||
{
|
|
||||||
ImGui.TableNextColumn();
|
|
||||||
ImGui.TextUnformatted(word);
|
|
||||||
|
|
||||||
ImGui.TableNextColumn();
|
|
||||||
if (ImGuiUtil.Button($"##{word}Del", FontAwesomeIcon.Trash, !ImGui.GetIO().KeyCtrl))
|
|
||||||
Mutable.BlockedEmotes.Remove(word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Options_Emote_EmoteStats);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (EmoteCache.State is EmoteCache.LoadingState.Done)
|
|
||||||
ImGui.TextColored(ImGuiColors.HealerGreen, Language.Options_Emote_Ready);
|
|
||||||
else
|
|
||||||
ImGui.TextColored(ImGuiColors.DPSRed, Language.Options_Emote_NotReady);
|
|
||||||
|
|
||||||
ImGui.TextUnformatted($"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length}");
|
|
||||||
using (var emoteTable = ImRaii.Table("##LoadedEmotes", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner))
|
|
||||||
{
|
|
||||||
if (emoteTable)
|
|
||||||
{
|
|
||||||
ImGui.TableSetupColumn("##word1");
|
|
||||||
ImGui.TableSetupColumn("##word2");
|
|
||||||
ImGui.TableSetupColumn("##word3");
|
|
||||||
ImGui.TableSetupColumn("##word4");
|
|
||||||
ImGui.TableSetupColumn("##word5");
|
|
||||||
|
|
||||||
foreach (var word in EmoteCache.SortedCodeArray)
|
|
||||||
{
|
|
||||||
ImGui.TableNextColumn();
|
|
||||||
ImGui.TextUnformatted(word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud;
|
|
||||||
using Dalamud.Interface.FontIdentifier;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
public class Fonts : ISettingsTab
|
|
||||||
{
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => Language.Options_Fonts_Tab + "###tabs-fonts";
|
|
||||||
|
|
||||||
internal Fonts(Configuration mutable)
|
|
||||||
{
|
|
||||||
Mutable = mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool _)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
ImGui.Checkbox(Language.Options_FontsEnabled, ref Mutable.FontsEnabled);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
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 _);
|
|
||||||
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.HelpText(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 _, 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.HelpText(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.HelpText(string.Format(Language.Options_Italic_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name))
|
|
||||||
{
|
|
||||||
ImGuiUtil.HelpText(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.HelpText(Language.Options_SymbolsFontSize_Description);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Miscellaneous(Configuration mutable) : ISettingsTab
|
|
||||||
{
|
|
||||||
private Configuration Mutable { get; } = mutable;
|
|
||||||
public string Name => Language.Options_Miscellaneous_Tab + "###tabs-miscellaneous";
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Language_Name, Mutable.LanguageOverride.Name()))
|
|
||||||
{
|
|
||||||
if (combo.Success)
|
|
||||||
{
|
|
||||||
foreach (var language in Enum.GetValues<LanguageOverride>())
|
|
||||||
if (ImGui.Selectable(language.Name()))
|
|
||||||
Mutable.LanguageOverride = language;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.HelpText(string.Format(Language.Options_Language_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_CommandHelpSide_Name, Mutable.CommandHelpSide.Name()))
|
|
||||||
{
|
|
||||||
if (combo.Success)
|
|
||||||
{
|
|
||||||
foreach (var side in Enum.GetValues<CommandHelpSide>())
|
|
||||||
if (ImGui.Selectable(side.Name(), Mutable.CommandHelpSide == side))
|
|
||||||
Mutable.CommandHelpSide = side;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.HelpText(string.Format(Language.Options_CommandHelpSide_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_KeybindMode_Name, Mutable.KeybindMode.Name()))
|
|
||||||
{
|
|
||||||
if (combo.Success)
|
|
||||||
{
|
|
||||||
foreach (var mode in Enum.GetValues<KeybindMode>())
|
|
||||||
{
|
|
||||||
if (ImGui.Selectable(mode.Name(), Mutable.KeybindMode == mode))
|
|
||||||
Mutable.KeybindMode = mode;
|
|
||||||
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
ImGuiUtil.Tooltip(mode.Tooltip() ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.HelpText(string.Format(Language.Options_KeybindMode_Description, Plugin.PluginName));
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGui.Checkbox(Language.Options_SortAutoTranslate_Name, ref Mutable.SortAutoTranslate);
|
|
||||||
ImGuiUtil.HelpText(Language.Options_SortAutoTranslate_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Preview : ISettingsTab
|
|
||||||
{
|
|
||||||
private Configuration Mutable { get; }
|
|
||||||
|
|
||||||
public string Name => $"{Language.Options_Preview_Tab}###tabs-preview";
|
|
||||||
|
|
||||||
internal Preview(Configuration mutable)
|
|
||||||
{
|
|
||||||
Mutable = mutable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
using var wrap = ImRaii.TextWrapPos(0.0f);
|
|
||||||
|
|
||||||
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Preview_Name, Mutable.PreviewPosition.Name()))
|
|
||||||
{
|
|
||||||
if (combo)
|
|
||||||
{
|
|
||||||
foreach (var position in Enum.GetValues<PreviewPosition>())
|
|
||||||
if (ImGui.Selectable(position.Name(), Mutable.PreviewPosition == position))
|
|
||||||
Mutable.PreviewPosition = position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGuiUtil.HelpText(Language.Options_Preview_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Options_PreviewMinimum_Name, Language.Options_PreviewMinimum_Description, ref Mutable.PreviewMinimum))
|
|
||||||
Mutable.PreviewMinimum = Math.Clamp(Mutable.PreviewMinimum, 1, 250);
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.OnlyPreviewIf, Language.Options_PreviewOnlyIf_Name, Language.Options_PreviewOnlyIf_Description);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
using ChatTwo.Resources;
|
|
||||||
using ChatTwo.Util;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Interface.Colors;
|
|
||||||
using Dalamud.Interface.ImGuiNotification;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
|
|
||||||
namespace ChatTwo.Ui.SettingsTabs;
|
|
||||||
|
|
||||||
internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISettingsTab
|
|
||||||
{
|
|
||||||
private Plugin Plugin { get; } = plugin;
|
|
||||||
private Configuration Mutable { get; } = mutable;
|
|
||||||
public string Name => Language.Options_Webinterface_Tab + "###tabs-Webinterface";
|
|
||||||
|
|
||||||
public void Draw(bool changed)
|
|
||||||
{
|
|
||||||
if (ImGui.CollapsingHeader(Language.Webinterface_UsageNotice, ImGuiTreeNodeFlags.DefaultOpen))
|
|
||||||
{
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudWhite, Language.Options_Webinterface_Warning_Header);
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Reason);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_Warning_DoNot);
|
|
||||||
using (ImRaii.PushIndent(15.0f))
|
|
||||||
{
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Port);
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Share);
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, Language.Options_Webinterface_DoNot_Multibox);
|
|
||||||
}
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Options_Webinterface_Warning_Support);
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceEnabled, Language.Options_WebinterfaceEnable_Name, Language.Options_WebinterfaceEnable_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (!Mutable.WebinterfaceEnabled)
|
|
||||||
return;
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.OptionCheckbox(ref Mutable.WebinterfaceAutoStart, Language.Options_WebinterfaceAutoStart_Name, Language.Options_WebinterfaceAutoStart_Description);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Webinterface_Option_Port_Name, Language.Webinterface_Option_Port_Description, ref Mutable.WebinterfacePort))
|
|
||||||
Mutable.WebinterfacePort = Math.Clamp(Mutable.WebinterfacePort, 1024, 49151);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
if (ImGuiUtil.InputIntVertical(Language.Options_WebinterfaceMaxLinesToSend_Name, Language.Options_WebinterfaceMaxLinesToSend_Description, ref Mutable.WebinterfaceMaxLinesToSend))
|
|
||||||
Mutable.WebinterfaceMaxLinesToSend = Math.Clamp(Mutable.WebinterfaceMaxLinesToSend, 1, 10_000);
|
|
||||||
ImGui.Spacing();
|
|
||||||
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Webinterface_CurrentPassword);
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Mutable.WebinterfacePassword);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
|
|
||||||
{
|
|
||||||
Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
|
||||||
Plugin.ServerCore.InvalidateSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls);
|
|
||||||
using (ImRaii.PushIndent(10.0f))
|
|
||||||
{
|
|
||||||
var isActive = Plugin.ServerCore.IsActive();
|
|
||||||
using (ImRaii.Disabled(isActive || Plugin.ServerCore.IsStopping()))
|
|
||||||
{
|
|
||||||
if (ImGui.Button(Language.Webinterface_Button_Start))
|
|
||||||
{
|
|
||||||
Task.Run(() =>
|
|
||||||
{
|
|
||||||
var ok = Plugin.ServerCore.Start();
|
|
||||||
if (ok)
|
|
||||||
{
|
|
||||||
Plugin.ServerCore.Run();
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Start_Success, NotificationType.Success);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Start_Failed, NotificationType.Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
|
|
||||||
using (ImRaii.Disabled(!isActive || Plugin.ServerCore.IsStopping()))
|
|
||||||
{
|
|
||||||
if (ImGui.Button(Language.Webinterface_Button_Stop))
|
|
||||||
{
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var ok = await Plugin.ServerCore.Stop();
|
|
||||||
if (ok)
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Stop_Success, NotificationType.Success);
|
|
||||||
else
|
|
||||||
WrapperUtil.AddNotification(Language.Webinterface_Stop_Failed, NotificationType.Error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls_Active);
|
|
||||||
ImGui.SameLine();
|
|
||||||
using (Plugin.FontManager.FontAwesome.Push())
|
|
||||||
using (ImRaii.PushColor(ImGuiCol.Text, isActive ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed))
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(isActive ? FontAwesomeIcon.Check.ToIconString() : FontAwesomeIcon.Times.ToIconString());
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri? uri;
|
|
||||||
try {
|
|
||||||
uri = new Uri($"http://{System.Net.Dns.GetHostName()}:{Mutable.WebinterfacePort}/");
|
|
||||||
}
|
|
||||||
catch(Exception)
|
|
||||||
{
|
|
||||||
uri = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted(Language.Webinterface_Controls_Url);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (uri is not null)
|
|
||||||
{
|
|
||||||
var clicked = false;
|
|
||||||
clicked |= ImGui.Selectable(uri.AbsoluteUri);
|
|
||||||
ImGui.SameLine();
|
|
||||||
clicked |= ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "urlOpen");
|
|
||||||
|
|
||||||
if (clicked)
|
|
||||||
WrapperUtil.TryOpenUri(uri);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(Language.Options_Webinterface_Hostname_Fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Options_Webinterface_Note);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Spacing();
|
|
||||||
ImGui.Spacing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
namespace ChatTwo.Util;
|
|
||||||
|
|
||||||
public static class WebinterfaceUtil
|
|
||||||
{
|
|
||||||
private static readonly Random Rng = new();
|
|
||||||
|
|
||||||
public static string GenerateSimpleAuthCode()
|
|
||||||
{
|
|
||||||
return (100000 + Rng.Next() % 100000).ToString()[1..];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GenerateSimpleToken()
|
|
||||||
{
|
|
||||||
var buffer = new byte[15];
|
|
||||||
Rng.NextBytes(buffer);
|
|
||||||
|
|
||||||
return Convert.ToHexString(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +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=="
|
|
||||||
},
|
|
||||||
"Watson.Lite": {
|
|
||||||
"type": "Direct",
|
|
||||||
"requested": "[6.3.9, )",
|
|
||||||
"resolved": "6.3.9",
|
|
||||||
"contentHash": "sDigTY8D8V7W38lfzJGiigf7xZEfp3Kw7XE7VJyeNO9mxOkv+w8HcmCsmORMDhsipDqGU0gMEsPOqORmZzRaWg==",
|
|
||||||
"dependencies": {
|
|
||||||
"CavemanTcp": "2.0.9",
|
|
||||||
"Watson.Core": "6.3.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"CavemanTcp": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "2.0.9",
|
|
||||||
"contentHash": "KgIwYhPhGkBTm+wwVAmWonkKPw4xYVnutzzlIeqOLcX1fti+8d+MEGTvbern1smf3S/UpjFjihkf6XRziTddzQ=="
|
|
||||||
},
|
|
||||||
"IpMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.5",
|
|
||||||
"contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
|
|
||||||
},
|
|
||||||
"MessagePack.Annotations": {
|
|
||||||
"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=="
|
|
||||||
},
|
|
||||||
"RegexMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.9",
|
|
||||||
"contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Timestamps": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "1.0.11",
|
|
||||||
"contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
|
|
||||||
},
|
|
||||||
"UrlMatcher": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "3.0.1",
|
|
||||||
"contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
|
|
||||||
},
|
|
||||||
"Watson.Core": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "6.3.9",
|
|
||||||
"contentHash": "hGoadE4SLbko8yxhx5+nxGV8lEVgEquNli87lN6/eOTQEJNpK/Cs+OF0etTgFKZ4p0u5ivetoDxl82Lg6oHZEg==",
|
|
||||||
"dependencies": {
|
|
||||||
"IpMatcher": "1.0.5",
|
|
||||||
"RegexMatcher": "1.0.9",
|
|
||||||
"Timestamps": "1.0.11",
|
|
||||||
"UrlMatcher": "3.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatTwo", "ChatTwo\ChatTwo.csproj", "{739F75E6-B65F-41EF-9D90-F7BC519E4875}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HellionChat", "HellionChat\HellionChat.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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -14,9 +12,5 @@ Global
|
|||||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using HellionChat.Code;
|
||||||
|
using HellionChat.GameFunctions.Types;
|
||||||
|
using HellionChat.Resources;
|
||||||
|
using HellionChat.Util;
|
||||||
|
using Dalamud.Game.Text;
|
||||||
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
|
|
||||||
|
namespace HellionChat;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.6.1 — if the victim is currently popped out, tear down the
|
||||||
|
// matching Popout window first. Otherwise the window stays in
|
||||||
|
// PopOutWindows + WindowSystem and renders empty / re-spawns on the
|
||||||
|
// next AddPopOutsToDraw tick. Latent since pop-outs were introduced;
|
||||||
|
// becomes visible with AutoTellTabsOpenAsPopout where dropping a
|
||||||
|
// popped tab is now a routine code path.
|
||||||
|
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 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.
|
||||||
|
// The current message is already persisted in the store by the
|
||||||
|
// time MessageProcessed fires (see MessageManager.cs: UpsertMessage
|
||||||
|
// runs before the event), so we have to exclude it explicitly to
|
||||||
|
// avoid the separator landing below the live tell.
|
||||||
|
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
|
||||||
|
|
||||||
|
tab.AddMessage(currentMessage, unread: true);
|
||||||
|
|
||||||
|
// Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a
|
||||||
|
// pop-out window. Set BEFORE Tabs.Add so the next render-tick's
|
||||||
|
// AddPopOutsToDraw() sees PopOut=true and spawns the Popout window
|
||||||
|
// alongside the tab going into the list. No SaveConfig() because
|
||||||
|
// auto-tell tabs are IsTempTab (session-only, never persisted).
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
// 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, Guid currentMessageId)
|
||||||
|
{
|
||||||
|
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
|
||||||
|
if (preloadCount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Pull one extra row because the live tell that triggered this
|
||||||
|
// spawn is already in the store and would otherwise eat one of
|
||||||
|
// the user's preload-budget slots.
|
||||||
|
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 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 historicMessages)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
// v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any
|
||||||
|
// popped-out temp tab windows before removing the tabs themselves,
|
||||||
|
// otherwise PopOutWindows + WindowSystem keep ghost entries until
|
||||||
|
// the next plugin reload. Especially relevant once Auto-Pop-Out is
|
||||||
|
// enabled — every logout would otherwise leak as many ghosts as
|
||||||
|
// there were active /tell pop-outs.
|
||||||
|
var poppedTempTabIds = Plugin.Config.Tabs
|
||||||
|
.Where(t => t.IsTempTab && 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(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using HellionChat.Resources;
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
|
||||||
|
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 HellionChat.Code;
|
||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace ChatTwo;
|
namespace HellionChat;
|
||||||
|
|
||||||
[Union(0, typeof(TextChunk))]
|
[Union(0, typeof(TextChunk))]
|
||||||
[Union(1, typeof(IconChunk))]
|
[Union(1, typeof(IconChunk))]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Dalamud.Game.Text;
|
using Dalamud.Game.Text;
|
||||||
|
|
||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
public class ChatCode
|
public class ChatCode
|
||||||
{
|
{
|
||||||
@@ -91,13 +91,10 @@ public class ChatCode
|
|||||||
|
|
||||||
public override bool Equals(object? obj)
|
public override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
if (obj == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (obj is not ChatCode code)
|
if (obj is not ChatCode code)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return GetHashCode() == code.GetHashCode();
|
return Type == code.Type && Source == code.Source && Target == code.Target;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int GetHashCode()
|
public override int GetHashCode()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Dalamud.Game.Text;
|
using Dalamud.Game.Text;
|
||||||
|
|
||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum ChatSource : ushort
|
public enum ChatSource : ushort
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
|
|
||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
internal static class ChatSourceExt
|
internal static class ChatSourceExt
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
public enum ChatType : ushort
|
public enum ChatType : ushort
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using Dalamud.Game.Config;
|
using Dalamud.Game.Config;
|
||||||
|
|
||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
internal static class ChatTypeExt
|
internal static class ChatTypeExt
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
public enum InputChannel : uint
|
public enum InputChannel : uint
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Lumina.Excel.Sheets;
|
using Lumina.Excel.Sheets;
|
||||||
|
|
||||||
namespace ChatTwo.Code;
|
namespace HellionChat.Code;
|
||||||
|
|
||||||
internal static class InputChannelExt
|
internal static class InputChannelExt
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Dalamud.Game.Command;
|
using Dalamud.Game.Command;
|
||||||
|
|
||||||
namespace ChatTwo;
|
namespace HellionChat;
|
||||||
|
|
||||||
internal sealed class Commands : IDisposable
|
internal sealed class Commands : IDisposable
|
||||||
{
|
{
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using ChatTwo.Code;
|
using HellionChat.Code;
|
||||||
using ChatTwo.GameFunctions.Types;
|
using HellionChat.GameFunctions.Types;
|
||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using Dalamud;
|
using Dalamud;
|
||||||
using Dalamud.Configuration;
|
using Dalamud.Configuration;
|
||||||
using Dalamud.Game.ClientState.Keys;
|
using Dalamud.Game.ClientState.Keys;
|
||||||
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||||||
using Dalamud.Interface.FontIdentifier;
|
using Dalamud.Interface.FontIdentifier;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
|
|
||||||
namespace ChatTwo;
|
namespace HellionChat;
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class ConfigKeyBind
|
public class ConfigKeyBind
|
||||||
@@ -33,7 +34,7 @@ public class ConfigKeyBind
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public class Configuration : IPluginConfiguration
|
public class Configuration : IPluginConfiguration
|
||||||
{
|
{
|
||||||
private const int LatestVersion = 7;
|
private const int LatestVersion = 12;
|
||||||
|
|
||||||
public int Version { get; set; } = LatestVersion;
|
public int Version { get; set; } = LatestVersion;
|
||||||
|
|
||||||
@@ -72,8 +73,10 @@ public class Configuration : IPluginConfiguration
|
|||||||
public bool HellionThemeEnabled = true;
|
public bool HellionThemeEnabled = true;
|
||||||
|
|
||||||
// Window background opacity, 0.5–1.0. Lower values make the plugin
|
// Window background opacity, 0.5–1.0. Lower values make the plugin
|
||||||
// panes more glass-like so the game shines through. Default ~92%.
|
// panes more glass-like so the game shines through. Default 0.5
|
||||||
public float HellionThemeWindowOpacity = 0.92f;
|
// matches the maintainer's daily-driver preference; users who want
|
||||||
|
// a less translucent look bump it up in Aussehen → Theme.
|
||||||
|
public float HellionThemeWindowOpacity = 0.5f;
|
||||||
|
|
||||||
// Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font
|
// 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
|
// instead of whatever GlobalFontV2.FontId points at. Default ON so a
|
||||||
@@ -81,6 +84,52 @@ public class Configuration : IPluginConfiguration
|
|||||||
// to fall back to the user's chosen system or Dalamud font.
|
// to fall back to the user's chosen system or Dalamud font.
|
||||||
public bool UseHellionFont = true;
|
public bool UseHellionFont = 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).
|
||||||
|
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.
|
||||||
|
public bool AutoTellTabsShowGreetedToggle;
|
||||||
|
|
||||||
|
// Hellion Chat — One-Time-Hint-Banner that introduces the v0.6.0 pop-out
|
||||||
|
// input feature. Set to true once the user dismisses the banner from a
|
||||||
|
// pop-out window; never reset after that.
|
||||||
|
public bool SeenPopOutInputHint;
|
||||||
|
|
||||||
|
// Hellion Chat — v0.6.0 master switch for the pop-out input bar.
|
||||||
|
// Global on purpose: per-tab makes no sense for Auto-Tell-Tabs which
|
||||||
|
// are session-only and would force the user to re-enable it for every
|
||||||
|
// new conversation. Default flipped to ON in v0.6.1 (was OFF in v0.6.0)
|
||||||
|
// because tester feedback called the manual toggle "umständlich, wirkt
|
||||||
|
// unfertig". v11 → v12 migration applies the same flip to existing users.
|
||||||
|
public bool PopOutInputEnabled = true;
|
||||||
|
|
||||||
|
// Hellion Chat — v0.6.1 One-Time-Hint-Banner that introduces the
|
||||||
|
// chat-header pop-out toolbar button and reminds about the pop-out
|
||||||
|
// input default flip. Set to true once the user dismisses the banner
|
||||||
|
// from the main chat window; never reset after that.
|
||||||
|
public bool SeenPopOutHeaderHint;
|
||||||
|
|
||||||
|
// Hellion Chat — v0.6.1 opt-in: when true, AutoTellTabsService.SpawnTempTab
|
||||||
|
// sets tab.PopOut = true on every new auto-tell tab so the conversation
|
||||||
|
// pops out as its own window directly. Closing the pop-out returns the
|
||||||
|
// tab to the sidebar via the standard Popout.OnClose() flow. Default OFF
|
||||||
|
// because the existing sidebar workflow is what most users (especially
|
||||||
|
// club greeters tracking many parallel tells) expect by default.
|
||||||
|
public bool AutoTellTabsOpenAsPopout;
|
||||||
|
|
||||||
public int GetRetentionDays(ChatType type)
|
public int GetRetentionDays(ChatType type)
|
||||||
{
|
{
|
||||||
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
|
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
|
||||||
@@ -112,7 +161,12 @@ public class Configuration : IPluginConfiguration
|
|||||||
public bool MoreCompactPretty;
|
public bool MoreCompactPretty;
|
||||||
public bool HideSameTimestamps;
|
public bool HideSameTimestamps;
|
||||||
public bool ShowNoviceNetwork;
|
public bool ShowNoviceNetwork;
|
||||||
public bool SidebarTabView;
|
// 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 PrintChangelog = true;
|
||||||
public bool OnlyPreviewIf;
|
public bool OnlyPreviewIf;
|
||||||
public int PreviewMinimum = 1;
|
public int PreviewMinimum = 1;
|
||||||
@@ -122,7 +176,7 @@ public class Configuration : IPluginConfiguration
|
|||||||
public LanguageOverride LanguageOverride = LanguageOverride.None;
|
public LanguageOverride LanguageOverride = LanguageOverride.None;
|
||||||
public bool CanMove = true;
|
public bool CanMove = true;
|
||||||
public bool CanResize = true;
|
public bool CanResize = true;
|
||||||
public bool ShowTitleBar;
|
public bool ShowTitleBar = true;
|
||||||
public bool ShowPopOutTitleBar = true;
|
public bool ShowPopOutTitleBar = true;
|
||||||
public bool DatabaseBattleMessages;
|
public bool DatabaseBattleMessages;
|
||||||
public bool LoadPreviousSession;
|
public bool LoadPreviousSession;
|
||||||
@@ -132,8 +186,12 @@ public class Configuration : IPluginConfiguration
|
|||||||
public bool CollapseKeepUniqueLinks;
|
public bool CollapseKeepUniqueLinks;
|
||||||
public bool PlaySounds = true;
|
public bool PlaySounds = true;
|
||||||
public bool KeepInputFocus = true;
|
public bool KeepInputFocus = true;
|
||||||
public int MaxLinesToRender = 10_000; // 1-10000
|
public int MaxLinesToRender = 5_000; // 1-10000
|
||||||
public bool Use24HourClock;
|
// Default ON to match a German / European 24h locale. The
|
||||||
|
// ChatLogWindow.cs format-flip in v0.5.1 honours this strictly via
|
||||||
|
// CultureInfo.InvariantCulture so the result is consistent across
|
||||||
|
// host locales.
|
||||||
|
public bool Use24HourClock = true;
|
||||||
|
|
||||||
public bool ShowEmotes = true;
|
public bool ShowEmotes = true;
|
||||||
public HashSet<string> BlockedEmotes = [];
|
public HashSet<string> BlockedEmotes = [];
|
||||||
@@ -171,14 +229,6 @@ public class Configuration : IPluginConfiguration
|
|||||||
public ConfigKeyBind? ChatTabForward;
|
public ConfigKeyBind? ChatTabForward;
|
||||||
public ConfigKeyBind? ChatTabBackward;
|
public ConfigKeyBind? ChatTabBackward;
|
||||||
|
|
||||||
// Webinterface
|
|
||||||
public bool WebinterfaceEnabled;
|
|
||||||
public bool WebinterfaceAutoStart;
|
|
||||||
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
|
||||||
public int WebinterfacePort = 9000;
|
|
||||||
public HashSet<string> AuthStore = [];
|
|
||||||
public int WebinterfaceMaxLinesToSend = 1000; // 1-10000
|
|
||||||
|
|
||||||
public void UpdateFrom(Configuration other, bool backToOriginal)
|
public void UpdateFrom(Configuration other, bool backToOriginal)
|
||||||
{
|
{
|
||||||
if (backToOriginal)
|
if (backToOriginal)
|
||||||
@@ -238,16 +288,21 @@ public class Configuration : IPluginConfiguration
|
|||||||
TooltipOffset = other.TooltipOffset;
|
TooltipOffset = other.TooltipOffset;
|
||||||
WindowAlpha = other.WindowAlpha;
|
WindowAlpha = other.WindowAlpha;
|
||||||
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
|
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
|
||||||
Tabs = other.Tabs.Select(t => t.Clone()).ToList();
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
OverrideStyle = other.OverrideStyle;
|
OverrideStyle = other.OverrideStyle;
|
||||||
ChosenStyle = other.ChosenStyle;
|
ChosenStyle = other.ChosenStyle;
|
||||||
ChatTabForward = other.ChatTabForward;
|
ChatTabForward = other.ChatTabForward;
|
||||||
ChatTabBackward = other.ChatTabBackward;
|
ChatTabBackward = other.ChatTabBackward;
|
||||||
WebinterfaceEnabled = other.WebinterfaceEnabled;
|
|
||||||
WebinterfaceAutoStart = other.WebinterfaceAutoStart;
|
|
||||||
WebinterfacePassword = other.WebinterfacePassword;
|
|
||||||
WebinterfacePort = other.WebinterfacePort;
|
|
||||||
WebinterfaceMaxLinesToSend = other.WebinterfaceMaxLinesToSend;
|
|
||||||
|
|
||||||
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
|
||||||
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
|
||||||
@@ -262,6 +317,17 @@ public class Configuration : IPluginConfiguration
|
|||||||
HellionThemeEnabled = other.HellionThemeEnabled;
|
HellionThemeEnabled = other.HellionThemeEnabled;
|
||||||
HellionThemeWindowOpacity = other.HellionThemeWindowOpacity;
|
HellionThemeWindowOpacity = other.HellionThemeWindowOpacity;
|
||||||
UseHellionFont = other.UseHellionFont;
|
UseHellionFont = other.UseHellionFont;
|
||||||
|
|
||||||
|
EnableAutoTellTabs = other.EnableAutoTellTabs;
|
||||||
|
AutoTellTabsLimit = other.AutoTellTabsLimit;
|
||||||
|
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
|
||||||
|
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
|
||||||
|
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
|
||||||
|
|
||||||
|
SeenPopOutInputHint = other.SeenPopOutInputHint;
|
||||||
|
PopOutInputEnabled = other.PopOutInputEnabled;
|
||||||
|
SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
|
||||||
|
AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,9 +403,27 @@ public class Tab
|
|||||||
|
|
||||||
[NonSerialized] public Guid Identifier = Guid.NewGuid();
|
[NonSerialized] public Guid Identifier = Guid.NewGuid();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
public bool Matches(Message message)
|
public bool Matches(Message message)
|
||||||
{
|
{
|
||||||
return message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels);
|
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".
|
||||||
|
if (IsTempTab && TellTarget?.IsSet() == true)
|
||||||
|
{
|
||||||
|
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddMessage(Message message, bool unread = true)
|
public void AddMessage(Message message, bool unread = true)
|
||||||
@@ -388,6 +472,7 @@ public class Tab
|
|||||||
IsTempTab = IsTempTab,
|
IsTempTab = IsTempTab,
|
||||||
AllSenderMessages = AllSenderMessages,
|
AllSenderMessages = AllSenderMessages,
|
||||||
TellTarget = TellTarget.From(TellTarget),
|
TellTarget = TellTarget.From(TellTarget),
|
||||||
|
IsGreeted = IsGreeted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ using Dalamud.Bindings.ImGui;
|
|||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
namespace ChatTwo;
|
namespace HellionChat;
|
||||||
|
|
||||||
public static class EmoteCache
|
public static class EmoteCache
|
||||||
{
|
{
|
||||||
@@ -32,23 +32,23 @@ public static class EmoteCache
|
|||||||
private struct Top100()
|
private struct Top100()
|
||||||
{
|
{
|
||||||
[JsonPropertyName("emote")]
|
[JsonPropertyName("emote")]
|
||||||
public Emote Emote = default;
|
public Emote Emote { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id = string.Empty;
|
public required string Id { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public struct Emote()
|
public struct Emote()
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")]
|
[JsonPropertyName("id")]
|
||||||
public string Id = string.Empty;
|
public required string Id { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("code")]
|
[JsonPropertyName("code")]
|
||||||
public string Code = string.Empty;
|
public required string Code { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("imageType")]
|
[JsonPropertyName("imageType")]
|
||||||
public string ImageType = string.Empty;
|
public required string ImageType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LoadingState
|
public enum LoadingState
|
||||||
@@ -66,7 +66,7 @@ public static class EmoteCache
|
|||||||
|
|
||||||
public static string[] SortedCodeArray = [];
|
public static string[] SortedCodeArray = [];
|
||||||
|
|
||||||
public static async void LoadData()
|
public static async Task LoadData()
|
||||||
{
|
{
|
||||||
if (State is not LoadingState.Unloaded)
|
if (State is not LoadingState.Unloaded)
|
||||||
return;
|
return;
|
||||||
@@ -105,6 +105,11 @@ public static class EmoteCache
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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.
|
||||||
|
State = LoadingState.Unloaded;
|
||||||
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,17 +173,26 @@ public static class EmoteCache
|
|||||||
|
|
||||||
internal async Task<byte[]> LoadAsync(Emote emote)
|
internal async Task<byte[]> LoadAsync(Emote emote)
|
||||||
{
|
{
|
||||||
var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1");
|
// 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"));
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
|
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}");
|
||||||
|
|
||||||
if (File.Exists(filePath))
|
if (File.Exists(filePath))
|
||||||
{
|
{
|
||||||
RawData = await File.ReadAllBytesAsync(filePath);
|
RawData = await File.ReadAllBytesAsync(filePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
|
var content = await Client.GetAsync(EmotePath.Format(emote.Id));
|
||||||
RawData = await content.Content.ReadAsByteArrayAsync();
|
RawData = await content.Content.ReadAsByteArrayAsync();
|
||||||
|
|
||||||
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ChatTwo.Code;
|
using HellionChat.Code;
|
||||||
|
|
||||||
namespace ChatTwo.Export;
|
namespace HellionChat.Export;
|
||||||
|
|
||||||
internal enum ExportFormat
|
internal enum ExportFormat
|
||||||
{
|
{
|
||||||
@@ -6,7 +6,7 @@ using Dalamud.Interface.ManagedFontAtlas;
|
|||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
|
|
||||||
namespace ChatTwo;
|
namespace HellionChat;
|
||||||
|
|
||||||
public class FontManager
|
public class FontManager
|
||||||
{
|
{
|
||||||
@@ -39,11 +39,18 @@ public class FontManager
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
GameSymFont = new HttpClient().GetAsync("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")
|
// Dispose HttpClient and HttpResponseMessage to avoid socket
|
||||||
.Result
|
// exhaustion on repeated cold-start downloads. GetAwaiter().GetResult()
|
||||||
.Content
|
// unwraps AggregateException so failures surface cleanly. A full
|
||||||
.ReadAsByteArrayAsync()
|
// async refactor of the constructor would be cleaner but is out of
|
||||||
.Result;
|
// scope for v1.0.0 — tracked in the backlog.
|
||||||
|
using var client = new HttpClient();
|
||||||
|
using var response = client
|
||||||
|
.GetAsync("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
GameSymFont = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
Dalamud.Utility.FilesystemUtil.WriteAllBytesSafe(filePath, GameSymFont);
|
Dalamud.Utility.FilesystemUtil.WriteAllBytesSafe(filePath, GameSymFont);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using ChatTwo.Code;
|
using HellionChat.Code;
|
||||||
using ChatTwo.GameFunctions.Types;
|
using HellionChat.GameFunctions.Types;
|
||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using Dalamud.Game.Config;
|
using Dalamud.Game.Config;
|
||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
using Dalamud.Hooking;
|
using Dalamud.Hooking;
|
||||||
@@ -22,7 +22,7 @@ using Lumina.Text.ReadOnly;
|
|||||||
|
|
||||||
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
internal sealed unsafe class Chat : IDisposable
|
internal sealed unsafe class Chat : IDisposable
|
||||||
{
|
{
|
||||||
@@ -156,9 +156,6 @@ internal sealed unsafe class Chat : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
ChangeChannelNameDetour(agent);
|
ChangeChannelNameDetour(agent);
|
||||||
|
|
||||||
// Inform all clients that a new login happened
|
|
||||||
Plugin.ServerCore.SendNewLogin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
|
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
using Dalamud.Memory;
|
using Dalamud.Memory;
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
public unsafe class ChatBox
|
public unsafe class ChatBox
|
||||||
{
|
{
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
internal sealed unsafe class Context
|
internal sealed unsafe class Context
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@ using Lumina.Excel;
|
|||||||
using Lumina.Excel.Sheets;
|
using Lumina.Excel.Sheets;
|
||||||
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
internal unsafe class GameFunctions : IDisposable
|
internal unsafe class GameFunctions : IDisposable
|
||||||
{
|
{
|
||||||
@@ -249,9 +249,15 @@ internal unsafe class GameFunctions : IDisposable
|
|||||||
|
|
||||||
private nint ResolveTextCommandPlaceholderDetour(nint a1, byte* placeholderText, byte a3, byte a4)
|
private nint ResolveTextCommandPlaceholderDetour(nint a1, byte* placeholderText, byte a3, byte a4)
|
||||||
{
|
{
|
||||||
|
// The detour is only invoked through the hook, so the hook should
|
||||||
|
// never be null here, but the nullable field declaration forces us
|
||||||
|
// to handle the theoretical race during teardown.
|
||||||
|
if (ResolveTextCommandPlaceholderHook is null)
|
||||||
|
return nint.Zero;
|
||||||
|
|
||||||
var placeholder = MemoryHelper.ReadStringNullTerminated((nint) placeholderText);
|
var placeholder = MemoryHelper.ReadStringNullTerminated((nint) placeholderText);
|
||||||
if (ReplacementName == null || placeholder != Placeholder)
|
if (ReplacementName == null || placeholder != Placeholder)
|
||||||
return ResolveTextCommandPlaceholderHook!.Original(a1, placeholderText, a3, a4);
|
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
|
||||||
|
|
||||||
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
|
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
|
||||||
ReplacementName = null;
|
ReplacementName = null;
|
||||||
+25
-7
@@ -1,16 +1,16 @@
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using ChatTwo.Code;
|
using HellionChat.Code;
|
||||||
using ChatTwo.GameFunctions.Types;
|
using HellionChat.GameFunctions.Types;
|
||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using Dalamud.Game.ClientState.Keys;
|
using Dalamud.Game.ClientState.Keys;
|
||||||
using Dalamud.Game.Config;
|
using Dalamud.Game.Config;
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using ModifierFlag = ChatTwo.GameFunctions.Types.ModifierFlag;
|
using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
internal enum KeyboardSource {
|
internal enum KeyboardSource {
|
||||||
Game,
|
Game,
|
||||||
@@ -414,13 +414,13 @@ internal unsafe class KeybindManager : IDisposable {
|
|||||||
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabForward))
|
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabForward))
|
||||||
{
|
{
|
||||||
Plugin.KeyState[Plugin.Config.ChatTabForward!.Key] = false;
|
Plugin.KeyState[Plugin.Config.ChatTabForward!.Key] = false;
|
||||||
Plugin.ChatLogWindow.ChangeTabDelta(1);
|
DispatchTabDelta(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabBackward))
|
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabBackward))
|
||||||
{
|
{
|
||||||
Plugin.KeyState[Plugin.Config.ChatTabBackward!.Key] = false;
|
Plugin.KeyState[Plugin.Config.ChatTabBackward!.Key] = false;
|
||||||
Plugin.ChatLogWindow.ChangeTabDelta(-1);
|
DispatchTabDelta(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +465,24 @@ internal unsafe class KeybindManager : IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
private static Keybind GetKeybind(string id)
|
||||||
{
|
{
|
||||||
var outData = new FFXIVClientStructs.FFXIV.Client.System.Input.Keybind();
|
var outData = new FFXIVClientStructs.FFXIV.Client.System.Input.Keybind();
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using ChatTwo.Resources;
|
using HellionChat.Resources;
|
||||||
using ChatTwo.Util;
|
using HellionChat.Util;
|
||||||
using Dalamud.Interface.ImGuiNotification;
|
using Dalamud.Interface.ImGuiNotification;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||||
|
|
||||||
namespace ChatTwo.GameFunctions;
|
namespace HellionChat.GameFunctions;
|
||||||
|
|
||||||
internal static unsafe class Party
|
internal static unsafe class Party
|
||||||
{
|
{
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
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 InputChannel? Channel { get; }
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace ChatTwo.GameFunctions.Types;
|
namespace HellionChat.GameFunctions.Types;
|
||||||
|
|
||||||
internal sealed class ChatActivatedArgs
|
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