Reported by Carla: a tell arriving while you are typing pulls the focus away. The interruption is the visible half. The sharp half is that the input buffer belongs to the WINDOW while the send target is read off whatever tab is active at Enter -- so a line typed at one person could leave addressed to whoever just wrote, and in this game losing the keyboard means the next sentence walks the character around. Nothing is revealed now while any chat surface is mid-sentence, in any mode. The tab still appears and still carries its unread mark. The check lives in its own file because the answer has to be identical everywhere: it started inside the reveal plan, and a second pop-out path walked straight past it -- AutoTellTabsService opened windows off its own flag, at tab creation, a tick before the router was ever asked. Those two paths are one now. AutoTellTabsOpenAsPopout and TellAutoOpenMode were two settings for one decision, and the older one won every race, which is why the other looked inert. Config schema 28 carries the old flag forward so nobody's behaviour changes. "Off" went with it: it never stopped the tab from being created -- that is the auto-tell switch -- it only stopped the jump to it, which is what the switch below it does. Also in here, all from the same corner of the code: - Closing a tab was lost in the v2.0.0 rebuild. The trash entry lived in the retired ChatLogWindow menu, and the rebuilt one restored rename, sound, pop-out and pinning but not this. For tell tabs that left no way out at all: IsEditable keeps them out of the settings editor on purpose and points at the context menu, which could not close them either. Pinned tell tabs stay disabled with a tooltip rather than absent. - Re-anchoring the active tab used an unconditional Tabs[0] in three places, and Tabs[0] can be popped out -- so it ran OnTabActivated over a tab live in its own window and stripped its tell binding. With every tab popped, the seed and the re-anchor also fought each other every frame. - PinTab_LimitReached still pointed at "Promote to permanent", removed in May. Spanish said "Desija", which is not a word; Greek left "tell tabs" untranslated; pt-PT broke its own unpin verb. - Pop Out was a hardcoded English literal despite the key existing in all 25 languages since v1.5.6, and the tell-open modes were the last English display names in the plugin. - Segmented setting rows measured 200px flat, which cut German labels in half. They size to their longest label now. - Metrics.Scale still called GlobalScaleSafe. It is an alias for GlobalScale in current Dalamud, and dropping it clears the last compiler warning in the project.
1057 lines
38 KiB
C#
Executable File
1057 lines
38 KiB
C#
Executable File
using System.Collections;
|
||
using System.Linq;
|
||
using Dalamud;
|
||
using Dalamud.Bindings.ImGui;
|
||
using Dalamud.Configuration;
|
||
using Dalamud.Game.ClientState.Keys;
|
||
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
||
using Dalamud.Interface.FontIdentifier;
|
||
using HellionChat.Code;
|
||
using HellionChat.GameFunctions.Types;
|
||
using HellionChat.Resources;
|
||
using HellionChat.Util;
|
||
|
||
namespace HellionChat;
|
||
|
||
[Serializable]
|
||
public class ConfigKeyBind
|
||
{
|
||
public ModifierFlag Modifier;
|
||
public VirtualKey Key;
|
||
|
||
public override string ToString()
|
||
{
|
||
var modString = "";
|
||
if (Modifier.HasFlag(ModifierFlag.Ctrl))
|
||
modString += Language.Keybind_Modifier_Ctrl + " + ";
|
||
if (Modifier.HasFlag(ModifierFlag.Shift))
|
||
modString += Language.Keybind_Modifier_Shift + " + ";
|
||
if (Modifier.HasFlag(ModifierFlag.Alt))
|
||
modString += Language.Keybind_Modifier_Alt + " + ";
|
||
return modString + Key.GetFancyName();
|
||
}
|
||
}
|
||
|
||
[Serializable]
|
||
public class Configuration : IPluginConfiguration
|
||
{
|
||
internal const int LatestVersion = 28;
|
||
|
||
public int Version { get; set; } = LatestVersion;
|
||
|
||
// Slug-based; ThemeRegistry resolves the object at runtime.
|
||
public string Theme = "hellion-arctic";
|
||
|
||
// Global window opacity, applied across all themes.
|
||
public float WindowOpacity = 0.85f;
|
||
|
||
// Background opacity of the main chat window while unfocused.
|
||
// WindowOpacity above stays the focused value.
|
||
public float WindowOpacityInactive = 0.75f;
|
||
|
||
// Reserved for future UI toggles; pre-declared to avoid a migration later.
|
||
public bool ReduceMotion;
|
||
|
||
// v1.2.1: default flipped false → true. Compact single-line layout is
|
||
// more readable than the card-rows layout introduced in v1.2.0.
|
||
public bool UseCompactDensity;
|
||
|
||
// Privacy by Default master switch. Set false to restore upstream behaviour.
|
||
public bool PrivacyFilterEnabled = true;
|
||
|
||
// Stays empty here. Dalamud deserialises with Json.NET's default settings,
|
||
// which means ObjectCreationHandling.Auto: a collection field that already
|
||
// holds items is *populated*, not replaced. A non-empty initializer would
|
||
// therefore union itself into every config on load and switch channels the
|
||
// user had unticked back on. Verified against Newtonsoft 13.0.3:
|
||
// saved [] loads as the initializer, saved [Say] loads as initializer + Say.
|
||
//
|
||
// Privacy by Default (DSGVO Art. 25) is seeded in CreateFresh instead, which
|
||
// only runs when there is no config file at all.
|
||
public HashSet<ChatType> PrivacyPersistChannels = [];
|
||
|
||
// Failsafe for ChatTypes added by future FFXIV patches. New configs default
|
||
// to the failsafe via PrivacyDefaults; existing configs keep their saved
|
||
// choice because the deserializer overrides this initializer.
|
||
public bool PrivacyPersistUnknownChannels = Privacy
|
||
.PrivacyDefaults
|
||
.DefaultPersistUnknownChannels;
|
||
|
||
// Dedup unknown-ChatType warnings so a chatty filter doesn't spam
|
||
// the log every frame. NonSerialized so the warning fires once per
|
||
// runtime, not once-ever-per-install.
|
||
[NonSerialized]
|
||
private readonly HashSet<ChatType> _warnedUnknownChannels = new();
|
||
|
||
// A first-ever start records the player's own conversations and nothing
|
||
// else. Deliberately not a field initializer -- see PrivacyPersistChannels.
|
||
internal static Configuration CreateFresh()
|
||
{
|
||
var config = new Configuration();
|
||
config.PrivacyPersistChannels = [.. Privacy.PrivacyDefaults.PrivacyFirstWhitelist];
|
||
return config;
|
||
}
|
||
|
||
public bool IsAllowedForStorage(ChatType type)
|
||
{
|
||
if (!PrivacyFilterEnabled)
|
||
return true;
|
||
|
||
// Runs per message on the worker thread while the settings UI can Add to the
|
||
// same set from the draw thread. A HashSet.Contains racing an Add that
|
||
// resizes buckets can return the wrong answer -- and this answer decides
|
||
// whether a message is persisted. Lock kept tight, this is a hot path.
|
||
bool listed;
|
||
lock (Plugin.Instance.ConfigMapsLock)
|
||
listed = PrivacyPersistChannels.Contains(type);
|
||
|
||
var known = Enum.IsDefined(typeof(ChatType), type);
|
||
|
||
// Log the first occurrence of a ChatType the running build doesn't
|
||
// recognise — i.e. one a future FFXIV patch may have added.
|
||
if (!known && !listed && _warnedUnknownChannels.Add(type))
|
||
{
|
||
Plugin.LogProxy.Warning(
|
||
"PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
|
||
type,
|
||
PrivacyPersistUnknownChannels
|
||
);
|
||
}
|
||
|
||
return Privacy.StorageRule.Allows(listed, known, PrivacyPersistUnknownChannels);
|
||
}
|
||
|
||
// Retention master switch defaults to false — plugin will not delete
|
||
// history until the user explicitly opts in.
|
||
public bool RetentionEnabled;
|
||
public int RetentionDefaultDays = 30;
|
||
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
|
||
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
|
||
public bool FirstRunCompleted;
|
||
|
||
// Tracks which plugin version last surfaced the first-run wizard.
|
||
// When the running version is newer than this, Plugin.LoadAsync
|
||
// re-opens the wizard once so existing users see major UX reworks
|
||
// (e.g. the v1.5.2 multi-step rewrite). Skip path and Finish both
|
||
// set FirstRunCompleted = true on close, so the wizard only fires
|
||
// once per version bump even if the user dismisses it.
|
||
public string WizardLastShownVersion = string.Empty;
|
||
|
||
public bool UseHellionFont = true;
|
||
public bool ShowHonorificTitleInHeader = true;
|
||
|
||
// v1.4.7 opt-in: renders the Honorific glow outline when the title carries
|
||
// a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
|
||
// who don't care, and dodges the per-frame DrawList overhead on low-end
|
||
// hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
|
||
// as the primary Color until a later cycle ports the animation.
|
||
public bool ShowHonorificGlow = true;
|
||
public bool EnableAutoTellTabs = true;
|
||
public int AutoTellTabsLimit = 15;
|
||
public bool AutoTellTabsCompactDisplay = true;
|
||
public int AutoTellTabsHistoryPreload = 100;
|
||
|
||
// Expanded sidebar width in pixels. 44 was carried over from the v1.2.0
|
||
// icon-only layout and stayed the default long after the sidebar started
|
||
// drawing labels beside those icons, so every tab name came out clipped --
|
||
// it only went unnoticed because everyone had widened it by hand. 160 fits
|
||
// the German tab names, which are the longest of the 25 languages, and the
|
||
// floor below is set where they stop being readable rather than where the
|
||
// icons stop fitting.
|
||
public int SidebarWidth = 160;
|
||
public bool AutoTellTabsShowGreetedToggle;
|
||
public bool SeenPopOutInputHint;
|
||
public bool PopOutInputEnabled = true;
|
||
public bool SeenPopOutHeaderHint;
|
||
|
||
// On by default: the wizard's closing step tells the user to try /tell and
|
||
// watch a conversation open on its own, so the behaviour it describes has to
|
||
// be the behaviour they get.
|
||
// Retired in v2.0.5: this and TellAutoOpenMode were two settings for one
|
||
// decision, and this one always won because it fired first -- which made the
|
||
// other one look broken. The FIELD stays so the v28 migration can still read
|
||
// what the user actually had; nothing else reads it, and it is gone from the
|
||
// settings window.
|
||
public bool AutoTellTabsOpenAsPopout = true;
|
||
|
||
// How sender names are rendered in the chat log.
|
||
public WorldSuffixMode WorldSuffixMode = WorldSuffixMode.OtherWorldOnly;
|
||
public NameFormMode NameFormMode = NameFormMode.Full;
|
||
|
||
public int GetRetentionDays(ChatType type)
|
||
{
|
||
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
|
||
return userOverride;
|
||
if (Privacy.PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDefault))
|
||
return specDefault;
|
||
return RetentionDefaultDays;
|
||
}
|
||
|
||
public bool HideChat = true;
|
||
public bool HideDuringCutscenes = true;
|
||
public bool HideWhenNotLoggedIn = true;
|
||
public bool HideWhenUiHidden = true;
|
||
public bool HideInLoadingScreens;
|
||
public bool HideInBattle;
|
||
|
||
// v1.2.1: default flipped false → true for consistency with other hide defaults.
|
||
public bool HideInNewGamePlusMenu = true;
|
||
public bool HideWhenInactive;
|
||
|
||
public bool ShowHideButton = true;
|
||
public bool NativeItemTooltips = true;
|
||
public bool ScreenshotMode;
|
||
|
||
// No control and no reader. Kept so a stored value survives until the
|
||
// rendering they describe exists; see the reconnect backlog. Note the two
|
||
// resource sets disagree on what PrettierTimestamps even means -- the wizard
|
||
// called it "relative time", the settings tab "modern layout".
|
||
public bool PrettierTimestamps = true;
|
||
public bool MoreCompactPretty = true;
|
||
public bool HideSameTimestamps = true;
|
||
|
||
// No reader; see the reconnect backlog.
|
||
public bool ShowNoviceNetwork;
|
||
|
||
// Migration-only since v23: the 1.5.6 sidebar↔top-tabs switch, superseded by
|
||
// MainWindowLayoutMode in the v1.6.0 rewrite. No UI control anymore; read by
|
||
// the v23 migration in Plugin.cs and kept deserializable so a 1.5.6 user's
|
||
// false value survives one load. Remove in a later schema bump.
|
||
public bool SidebarTabView = true;
|
||
|
||
// No reader; see the reconnect backlog.
|
||
public bool PrintChangelog = true;
|
||
public bool OnlyPreviewIf;
|
||
public int PreviewMinimum = 1;
|
||
public PreviewPosition PreviewPosition = PreviewPosition.Inside;
|
||
public CommandHelpSide CommandHelpSide = CommandHelpSide.Right;
|
||
public KeybindMode KeybindMode = KeybindMode.Strict;
|
||
public LanguageOverride LanguageOverride = LanguageOverride.None;
|
||
public bool CanMove = true;
|
||
public bool CanResize = true;
|
||
public bool ShowTitleBar;
|
||
public bool ShowPopOutTitleBar = true;
|
||
public bool DatabaseBattleMessages;
|
||
public bool FilterIncludePreviousSessions;
|
||
public bool SortAutoTranslate;
|
||
public bool CollapseDuplicateMessages;
|
||
public bool CollapseKeepUniqueLinks;
|
||
public bool SymbolPickerEnabled = true;
|
||
public bool PlaySounds = true;
|
||
|
||
// AUDIO-1: playback volume (0-1) for the three bundled custom sounds.
|
||
public float CustomSoundVolume = 0.5f;
|
||
|
||
// Toast when a tell the user sent could not be delivered.
|
||
public bool NotifyFailedTell = true;
|
||
|
||
// Warn before sending a message that carries plugin-only glyphs.
|
||
public bool NotifyPluginDisclosure = true;
|
||
public bool KeepInputFocus = true;
|
||
public bool Use24HourClock = true;
|
||
public bool FontsEnabled = true;
|
||
public ExtraGlyphRanges ExtraGlyphRanges = 0;
|
||
public float FontSizeV2 = 12.75f;
|
||
public float SymbolsFontSizeV2 = 12.75f;
|
||
public SingleFontSpec GlobalFontV2 = new()
|
||
{
|
||
// dalamud only ships KR as regular, which chat2 used previously for global fonts
|
||
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
|
||
SizePt = 12.75f,
|
||
};
|
||
public SingleFontSpec JapaneseFontV2 = new()
|
||
{
|
||
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium),
|
||
SizePt = 12.75f,
|
||
};
|
||
public bool ItalicEnabled;
|
||
public SingleFontSpec ItalicFontV2 = new()
|
||
{
|
||
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
|
||
SizePt = 12.75f,
|
||
};
|
||
|
||
public float TooltipOffset;
|
||
|
||
public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();
|
||
|
||
private static Dictionary<ChatType, uint> BuildDefaultChatColours()
|
||
{
|
||
var defaults = new Dictionary<ChatType, uint>();
|
||
foreach (
|
||
var (channel, colour) in HellionChat.Resources.ChatColourPresets.All["Hellion"].Colours
|
||
)
|
||
defaults[channel] = colour;
|
||
return defaults;
|
||
}
|
||
|
||
// No reader; see the reconnect backlog.
|
||
public bool ColorSelectedInputChannelButton = true;
|
||
public List<Tab> Tabs = [];
|
||
|
||
public ConfigKeyBind? ChatTabForward;
|
||
public ConfigKeyBind? ChatTabBackward;
|
||
|
||
// v20 fields: window visibility state, channel popout pool size and
|
||
// sidebar auto-switch threshold. All initializers double as the
|
||
// migration defaults for configs loaded at v19 or earlier.
|
||
// Still written on open/close, but no longer read for the start state: the
|
||
// window always shows on login (1.5.6 parity, MainWindow ctor). Kept for the
|
||
// migration round-trip and a possible future "remember session state" opt-in.
|
||
public bool MainWindowOpen = true;
|
||
public bool SettingsWindowOpen;
|
||
public int MaxParallelPopouts = 8;
|
||
|
||
// Popout, not Sidebar: with AutoTellTabsOpenAsPopout defaulting to true, a
|
||
// fresh install has ALWAYS opened tells in their own window. Naming that as
|
||
// the default is what keeps a new install behaving the way it always did.
|
||
public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Popout;
|
||
|
||
// When true (default) the tell-auto-open router switches the active tab to the
|
||
// incoming tell on every message; when false the tab is still created/revealed
|
||
// with its unread badge but the active tab is left where the user is reading.
|
||
public bool TellAutoOpenSwitchAlways = true;
|
||
public int SidebarAutoSwitchThresholdPx = 800;
|
||
|
||
// v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs).
|
||
// Initializer doubles as the migration default for configs loaded at v21.
|
||
public MainWindowLayoutMode MainWindowLayoutMode = MainWindowLayoutMode.Sidebar;
|
||
}
|
||
|
||
[Serializable]
|
||
public enum TellAutoOpenMode
|
||
{
|
||
Off,
|
||
Sidebar,
|
||
TopTab,
|
||
Popout,
|
||
}
|
||
|
||
[Serializable]
|
||
public enum MainWindowLayoutMode
|
||
{
|
||
Sidebar,
|
||
TopTabs,
|
||
}
|
||
|
||
[Serializable]
|
||
public enum UnreadMode
|
||
{
|
||
All,
|
||
Unseen,
|
||
None,
|
||
}
|
||
|
||
public static class UnreadModeExt
|
||
{
|
||
internal static string Name(this UnreadMode mode) =>
|
||
mode switch
|
||
{
|
||
UnreadMode.All => Language.UnreadMode_All,
|
||
UnreadMode.Unseen => Language.UnreadMode_Unseen,
|
||
UnreadMode.None => Language.UnreadMode_None,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||
};
|
||
|
||
internal static string? Tooltip(this UnreadMode mode) =>
|
||
mode switch
|
||
{
|
||
UnreadMode.All => Language.UnreadMode_All_Tooltip,
|
||
UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
|
||
UnreadMode.None => Language.UnreadMode_None_Tooltip,
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
[Serializable]
|
||
public class Tab
|
||
{
|
||
public string Name = Language.Tab_DefaultName;
|
||
|
||
// Optional FontAwesome glyph name; null falls back to TabIconMapping default.
|
||
public string? Icon = null;
|
||
|
||
public Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels = new();
|
||
public bool ExtraChatAll;
|
||
public HashSet<Guid> ExtraChatChannels = [];
|
||
|
||
public UnreadMode UnreadMode = UnreadMode.Unseen;
|
||
public bool UnhideOnActivity;
|
||
public bool DisplayTimestamp = true;
|
||
public InputChannel? Channel;
|
||
public bool PopOut;
|
||
public bool IndependentOpacity;
|
||
public float Opacity = 100f;
|
||
public bool InputDisabled;
|
||
|
||
public bool CanMove = true;
|
||
public bool CanResize = true;
|
||
|
||
// Six per-tab hide conditions used to live here. Their reader was the
|
||
// pop-out window, which stopped consulting them in cf4705e; the equivalents
|
||
// that survive are the window-level fields of the same name further up this
|
||
// file, and v1.12.0 gave every one of those a control.
|
||
//
|
||
// Per-tab was the wrong unit anyway: "hide during cutscenes" is a statement
|
||
// about the screen, not about one conversation.
|
||
//
|
||
// HideWhenInactive stays -- the auto-tell service writes it.
|
||
public bool HideWhenInactive;
|
||
|
||
public bool IsTempTab;
|
||
|
||
// Pinned TempTabs survive plugin reload and logout -- tester feedback in
|
||
// v1.4.7. Pinned tabs live in their own pool (MaxPinnedTempTabs) separate
|
||
// from the AutoTellTabsLimit bucket.
|
||
public bool IsPinned;
|
||
public bool AllSenderMessages;
|
||
public TellTarget TellTarget = TellTarget.Empty();
|
||
|
||
// Set once, where the name is built from a conversation partner. Never
|
||
// cleared by promotion, unlike IsTempTab and TellTarget -- both of those are
|
||
// routing state and are deliberately wiped when a tab is promoted, while the
|
||
// name they produced stays. Screenshot mode reads this, so it has to outlive
|
||
// every path that keeps the name but drops the binding.
|
||
public bool NameCameFromPartner;
|
||
|
||
// Per-tab notification sound for messages arriving in an inactive tab.
|
||
public bool EnableNotificationSound;
|
||
public uint NotificationSoundId = 1;
|
||
|
||
[NonSerialized]
|
||
public uint Unread;
|
||
|
||
[NonSerialized]
|
||
public uint LastSendUnread;
|
||
|
||
[NonSerialized]
|
||
public long LastActivity;
|
||
|
||
[NonSerialized]
|
||
public MessageList Messages = new();
|
||
|
||
[NonSerialized]
|
||
public UsedChannel CurrentChannel = new();
|
||
|
||
[NonSerialized]
|
||
public Guid Identifier = Guid.NewGuid();
|
||
|
||
// Session-only greeted flag for club-greeter workflows.
|
||
[NonSerialized]
|
||
public bool IsGreeted;
|
||
|
||
// Separate validation keys per cache so TellTarget changes don't
|
||
// cause GetTint and GetIcon to strand each other with stale entries.
|
||
[NonSerialized]
|
||
internal string? _cachedTintTellName;
|
||
|
||
[NonSerialized]
|
||
internal uint _cachedTintTellWorld;
|
||
|
||
[NonSerialized]
|
||
internal uint _cachedTellTint;
|
||
|
||
[NonSerialized]
|
||
internal string? _cachedIconTellName;
|
||
|
||
[NonSerialized]
|
||
internal uint _cachedIconTellWorld;
|
||
|
||
[NonSerialized]
|
||
internal string? _cachedTellIcon;
|
||
|
||
// hover-lerp state. Default 0f means "not hovered". Sidebar
|
||
// path animates per tab; card-mode-border path is tab-aggregate
|
||
// (any card-row hover ramps the alpha for all cards in this tab).
|
||
// Lerp speed lives in the render loop, not here, so the same field
|
||
// serves both sites at the same animation curve.
|
||
[NonSerialized]
|
||
internal float _hoverAlpha;
|
||
|
||
[NonSerialized]
|
||
internal float _cardHoverAlpha;
|
||
|
||
// Copy-on-write for the three channel-filter fields. They are read without
|
||
// any lock from the pending-message thread, the filter worker and the draw
|
||
// thread, and until v1.12.0 nothing ever wrote them after load -- so the
|
||
// tab editor is their first writer, and mutating a live Dictionary while
|
||
// Matches enumerates it is the classic way to get a wrong answer or an
|
||
// exception on somebody else's thread.
|
||
//
|
||
// Building the replacements and swapping the references means a reader sees
|
||
// either the old set or the new one, never half of either.
|
||
//
|
||
// What this deliberately does not do is make the three writes one atomic
|
||
// step. A reader can catch the new dictionary with the old ExtraChat flag
|
||
// for a single message. That is harmless: the editor finishes by clearing
|
||
// and refiltering every tab, so any message placed by a mixed view is
|
||
// reconsidered a moment later. Making it truly atomic would mean one
|
||
// reference for all three, and these three are serialized fields with a
|
||
// shape the config file already has.
|
||
internal void ReplaceChannelFilter(
|
||
Dictionary<ChatType, (ChatSource, ChatSource)> selected,
|
||
bool extraChatAll,
|
||
HashSet<Guid> extraChatChannels
|
||
)
|
||
{
|
||
Volatile.Write(ref SelectedChannels, selected);
|
||
Volatile.Write(ref ExtraChatChannels, extraChatChannels);
|
||
Volatile.Write(ref ExtraChatAll, extraChatAll);
|
||
}
|
||
|
||
public bool Matches(Message message)
|
||
{
|
||
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
|
||
return false;
|
||
|
||
// Temp tabs are bound to a single conversation partner — other tells
|
||
// matching the channel filter must not land here.
|
||
if (IsTempTab && TellTarget?.IsSet() == true)
|
||
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
|
||
|
||
return true;
|
||
}
|
||
|
||
public void AddMessage(Message message, bool unread = true)
|
||
{
|
||
Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
||
if (!unread)
|
||
return;
|
||
|
||
Unread += 1;
|
||
|
||
// Stamped for every message now. The condition that used to sit here
|
||
// filtered on InactivityHideChannels, a setting for the hide-when-
|
||
// inactive feature -- and that feature lost its reader in cf4705e. So
|
||
// which tell tab the auto-tell pool drops first, which is the only
|
||
// thing that reads this stamp, hung on a setting for something that
|
||
// does not happen.
|
||
LastActivity = Environment.TickCount64;
|
||
}
|
||
|
||
public void Clear() => Messages.Clear();
|
||
|
||
public Tab Clone()
|
||
{
|
||
return new Tab
|
||
{
|
||
Name = Name,
|
||
// Icon feeds the sidebar glyph and a clone round-trip used to drop
|
||
// it silently. ChatCodes sat beside it until v1.12.0, carrying data
|
||
// for a migration that the v16 schema gate had already made
|
||
// unreachable.
|
||
Icon = Icon,
|
||
SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value),
|
||
ExtraChatAll = ExtraChatAll,
|
||
ExtraChatChannels = ExtraChatChannels.ToHashSet(),
|
||
UnreadMode = UnreadMode,
|
||
UnhideOnActivity = UnhideOnActivity,
|
||
Unread = Unread,
|
||
LastActivity = LastActivity,
|
||
DisplayTimestamp = DisplayTimestamp,
|
||
Channel = Channel,
|
||
PopOut = PopOut,
|
||
IndependentOpacity = IndependentOpacity,
|
||
Opacity = Opacity,
|
||
Identifier = Identifier,
|
||
InputDisabled = InputDisabled,
|
||
CurrentChannel = CurrentChannel.Clone(),
|
||
CanMove = CanMove,
|
||
CanResize = CanResize,
|
||
HideWhenInactive = HideWhenInactive,
|
||
IsTempTab = IsTempTab,
|
||
IsPinned = IsPinned,
|
||
AllSenderMessages = AllSenderMessages,
|
||
TellTarget = TellTarget.Clone(),
|
||
EnableNotificationSound = EnableNotificationSound,
|
||
NotificationSoundId = NotificationSoundId,
|
||
IsGreeted = IsGreeted,
|
||
};
|
||
}
|
||
|
||
/// Ordered message list with duplicate ID tracking, sorting and mutex protection.
|
||
public class MessageList
|
||
{
|
||
private readonly SemaphoreSlim LockSlim = new(1, 1);
|
||
|
||
private readonly List<Message> Messages;
|
||
private readonly HashSet<Guid> TrackedMessageIds;
|
||
|
||
public MessageList()
|
||
{
|
||
Messages = [];
|
||
TrackedMessageIds = [];
|
||
}
|
||
|
||
public MessageList(int initialCapacity)
|
||
{
|
||
Messages = new List<Message>(initialCapacity);
|
||
TrackedMessageIds = new HashSet<Guid>(initialCapacity);
|
||
}
|
||
|
||
public void AddPrune(Message message, int max)
|
||
{
|
||
LockSlim.Wait(-1);
|
||
try
|
||
{
|
||
AddLocked(message);
|
||
PruneMaxLocked(max);
|
||
}
|
||
finally
|
||
{
|
||
LockSlim.Release();
|
||
}
|
||
}
|
||
|
||
public void AddSortPrune(IEnumerable<Message> messages, int max)
|
||
{
|
||
LockSlim.Wait(-1);
|
||
try
|
||
{
|
||
foreach (var message in messages)
|
||
AddLocked(message);
|
||
|
||
SortLocked();
|
||
PruneMaxLocked(max);
|
||
}
|
||
finally
|
||
{
|
||
LockSlim.Release();
|
||
}
|
||
}
|
||
|
||
private void AddLocked(Message message)
|
||
{
|
||
if (TrackedMessageIds.Contains(message.Id))
|
||
return;
|
||
|
||
Messages.Add(message);
|
||
TrackedMessageIds.Add(message.Id);
|
||
}
|
||
|
||
public void Clear()
|
||
{
|
||
LockSlim.Wait(-1);
|
||
try
|
||
{
|
||
Messages.Clear();
|
||
TrackedMessageIds.Clear();
|
||
}
|
||
finally
|
||
{
|
||
LockSlim.Release();
|
||
}
|
||
}
|
||
|
||
private void SortLocked()
|
||
{
|
||
Messages.Sort((a, b) => a.Date.CompareTo(b.Date));
|
||
}
|
||
|
||
private void PruneMaxLocked(int max)
|
||
{
|
||
while (Messages.Count > max)
|
||
{
|
||
TrackedMessageIds.Remove(Messages[0].Id);
|
||
Messages.RemoveAt(0);
|
||
}
|
||
}
|
||
|
||
/// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
|
||
public int Count
|
||
{
|
||
get
|
||
{
|
||
LockSlim.Wait(-1);
|
||
try
|
||
{
|
||
return Messages.Count;
|
||
}
|
||
finally
|
||
{
|
||
LockSlim.Release();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Returns an array copy of the message list for usage outside of main thread.
|
||
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
|
||
{
|
||
await LockSlim.WaitAsync(millisecondsTimeout);
|
||
try
|
||
{
|
||
return Messages.ToArray();
|
||
}
|
||
finally
|
||
{
|
||
LockSlim.Release();
|
||
}
|
||
}
|
||
|
||
/// Returns a read-only list while holding a reader lock. Use with a using statement.
|
||
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
|
||
{
|
||
LockSlim.Wait(millisecondsTimeout);
|
||
return new RLockedMessageList(LockSlim, Messages);
|
||
}
|
||
|
||
public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages)
|
||
: IReadOnlyList<Message>,
|
||
IDisposable
|
||
{
|
||
public IEnumerator<Message> GetEnumerator()
|
||
{
|
||
return messages.GetEnumerator();
|
||
}
|
||
|
||
IEnumerator IEnumerable.GetEnumerator()
|
||
{
|
||
return GetEnumerator();
|
||
}
|
||
|
||
public int Count => messages.Count;
|
||
|
||
public Message this[int index] => messages[index];
|
||
|
||
public void Dispose()
|
||
{
|
||
lockSlim.Release();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public class UsedChannel
|
||
{
|
||
public InputChannel Channel = InputChannel.Invalid;
|
||
public List<Chunk> Name = [];
|
||
public TellTarget? TellTarget;
|
||
|
||
public bool UseTempChannel;
|
||
public InputChannel TempChannel = InputChannel.Invalid;
|
||
public TellTarget? TempTellTarget;
|
||
|
||
public void ResetTempChannel()
|
||
{
|
||
UseTempChannel = false;
|
||
TempTellTarget = null;
|
||
TempChannel = InputChannel.Invalid;
|
||
}
|
||
|
||
public void SetChannel(InputChannel channel)
|
||
{
|
||
Channel = channel;
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
|
||
// - Deep-clone the UsedChannel so Tab.Clone() no longer shares
|
||
// channel state (incl. TellTarget) with its origin Tab. Previously
|
||
// a reference copy: PopOut and Temp tabs mutated each other.
|
||
// - Name is intentionally a reference copy (matches upstream); it
|
||
// gets reassigned on every channel switch anyway.
|
||
// TEST-MIRROR: ../../Hellion Build test/_Helpers/UsedChannelCloneTests.cs
|
||
// ---------------------------------------------------------------
|
||
public UsedChannel Clone()
|
||
{
|
||
return new UsedChannel
|
||
{
|
||
Channel = Channel,
|
||
Name = Name,
|
||
TellTarget = TellTarget?.Clone(),
|
||
|
||
UseTempChannel = UseTempChannel,
|
||
TempChannel = TempChannel,
|
||
TempTellTarget = TempTellTarget?.Clone(),
|
||
};
|
||
}
|
||
}
|
||
|
||
[Serializable]
|
||
public enum PreviewPosition
|
||
{
|
||
None,
|
||
Inside,
|
||
Top,
|
||
Bottom,
|
||
Tooltip,
|
||
}
|
||
|
||
public static class PreviewPositionExt
|
||
{
|
||
public static string Name(this PreviewPosition position) =>
|
||
position switch
|
||
{
|
||
PreviewPosition.None => Language.Options_Preview_None,
|
||
PreviewPosition.Inside => Language.Options_Preview_Inside,
|
||
PreviewPosition.Top => Language.Options_Preview_Top,
|
||
PreviewPosition.Bottom => Language.Options_Preview_Bottom,
|
||
PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
|
||
};
|
||
}
|
||
|
||
[Serializable]
|
||
public enum CommandHelpSide
|
||
{
|
||
None,
|
||
Left,
|
||
Right,
|
||
}
|
||
|
||
public static class CommandHelpSideExt
|
||
{
|
||
public static string Name(this CommandHelpSide side) =>
|
||
side switch
|
||
{
|
||
CommandHelpSide.None => Language.CommandHelpSide_None,
|
||
CommandHelpSide.Left => Language.CommandHelpSide_Left,
|
||
CommandHelpSide.Right => Language.CommandHelpSide_Right,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
|
||
};
|
||
}
|
||
|
||
[Serializable]
|
||
public enum KeybindMode
|
||
{
|
||
Flexible,
|
||
Strict,
|
||
}
|
||
|
||
public static class KeybindModeExt
|
||
{
|
||
public static string Name(this KeybindMode mode) =>
|
||
mode switch
|
||
{
|
||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
|
||
KeybindMode.Strict => Language.KeybindMode_Strict_Name,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||
};
|
||
|
||
public static string? Tooltip(this KeybindMode mode) =>
|
||
mode switch
|
||
{
|
||
KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
|
||
KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
[Serializable]
|
||
public enum LanguageOverride
|
||
{
|
||
None,
|
||
ChineseSimplified,
|
||
ChineseTraditional,
|
||
Dutch,
|
||
English,
|
||
French,
|
||
German,
|
||
Greek,
|
||
Japanese,
|
||
PortugueseBrazil,
|
||
Romanian,
|
||
Russian,
|
||
Spanish,
|
||
Swedish,
|
||
|
||
// v1.5.3: Crowdin-heritage activated and Forge-maintained additions.
|
||
// Append-only to preserve serialized integer values of existing user configs.
|
||
Italian,
|
||
Korean,
|
||
Norwegian,
|
||
Catalan,
|
||
Czech,
|
||
Danish,
|
||
Finnish,
|
||
Hungarian,
|
||
Polish,
|
||
PortuguesePortugal,
|
||
Turkish,
|
||
Ukrainian,
|
||
}
|
||
|
||
public static class LanguageOverrideExt
|
||
{
|
||
public static string Name(this LanguageOverride mode) =>
|
||
mode switch
|
||
{
|
||
LanguageOverride.None => Language.LanguageOverride_None,
|
||
LanguageOverride.ChineseSimplified => "简体中文",
|
||
LanguageOverride.ChineseTraditional => "繁體中文",
|
||
LanguageOverride.Dutch => "Nederlands",
|
||
LanguageOverride.English => "English",
|
||
LanguageOverride.French => "Français",
|
||
LanguageOverride.German => "Deutsch",
|
||
LanguageOverride.Greek => "Ελληνικά",
|
||
LanguageOverride.Italian => "Italiano",
|
||
LanguageOverride.Japanese => "日本語",
|
||
LanguageOverride.Korean => "한국어",
|
||
LanguageOverride.Norwegian => "Norsk bokmål",
|
||
LanguageOverride.PortugueseBrazil => "Português do Brasil",
|
||
LanguageOverride.Romanian => "Română",
|
||
LanguageOverride.Russian => "Русский",
|
||
LanguageOverride.Spanish => "Español",
|
||
LanguageOverride.Swedish => "Svenska",
|
||
LanguageOverride.Catalan => "Català",
|
||
LanguageOverride.Czech => "Čeština",
|
||
LanguageOverride.Danish => "Dansk",
|
||
LanguageOverride.Finnish => "Suomi",
|
||
LanguageOverride.Hungarian => "Magyar",
|
||
LanguageOverride.Polish => "Polski",
|
||
LanguageOverride.PortuguesePortugal => "Português (Portugal)",
|
||
LanguageOverride.Turkish => "Türkçe",
|
||
LanguageOverride.Ukrainian => "Українська",
|
||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||
};
|
||
|
||
public static string Code(this LanguageOverride mode) =>
|
||
mode switch
|
||
{
|
||
LanguageOverride.None => "",
|
||
LanguageOverride.ChineseSimplified => "zh-hans",
|
||
LanguageOverride.ChineseTraditional => "zh-hant",
|
||
LanguageOverride.Dutch => "nl",
|
||
LanguageOverride.English => "en",
|
||
LanguageOverride.French => "fr",
|
||
LanguageOverride.German => "de",
|
||
LanguageOverride.Greek => "el",
|
||
LanguageOverride.Italian => "it",
|
||
LanguageOverride.Japanese => "ja",
|
||
LanguageOverride.Korean => "ko",
|
||
LanguageOverride.Norwegian => "nb",
|
||
LanguageOverride.PortugueseBrazil => "pt-br",
|
||
LanguageOverride.Romanian => "ro",
|
||
LanguageOverride.Russian => "ru",
|
||
LanguageOverride.Spanish => "es",
|
||
LanguageOverride.Swedish => "sv",
|
||
LanguageOverride.Catalan => "ca",
|
||
LanguageOverride.Czech => "cs",
|
||
LanguageOverride.Danish => "da",
|
||
LanguageOverride.Finnish => "fi",
|
||
LanguageOverride.Hungarian => "hu",
|
||
LanguageOverride.Polish => "pl",
|
||
LanguageOverride.PortuguesePortugal => "pt-pt",
|
||
LanguageOverride.Turkish => "tr",
|
||
LanguageOverride.Ukrainian => "uk",
|
||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||
};
|
||
|
||
// Maps a language to the ExtraGlyphRanges flag required for full UI
|
||
// rendering in that locale. The settings save path ORs this into
|
||
// Mutable.ExtraGlyphRanges so users do not need to know which range
|
||
// to tick manually. Returns 0 for locales fully covered by the default
|
||
// ImGui glyph range (Latin-1) or by the separate Japanese font handle.
|
||
// The same mapping keyed by culture code, for when the language override is
|
||
// None and the UI follows Dalamud. Without this the ranges only ever get
|
||
// filled by an explicit language pick -- installs that never touched the
|
||
// setting rendered their own locale in whatever the default range covers,
|
||
// which for Korean, Chinese, Cyrillic and Greek is boxes. It went unnoticed
|
||
// while configs accumulated ranges over time; a fresh config has none.
|
||
public static ExtraGlyphRanges RequiredGlyphRangesForCulture(string? cultureCode)
|
||
{
|
||
var code = (cultureCode ?? string.Empty).ToLowerInvariant();
|
||
|
||
// Longest first: zh-hant has to win over the zh prefix.
|
||
if (code.StartsWith("zh-hant") || code.StartsWith("zh-tw") || code.StartsWith("zh-hk"))
|
||
return ExtraGlyphRanges.ChineseFull;
|
||
if (code.StartsWith("zh"))
|
||
return ExtraGlyphRanges.ChineseSimplifiedCommon;
|
||
if (code.StartsWith("ko"))
|
||
return ExtraGlyphRanges.Korean;
|
||
if (code.StartsWith("uk") || code.StartsWith("ru") || code.StartsWith("be"))
|
||
return ExtraGlyphRanges.Cyrillic;
|
||
if (code.StartsWith("el"))
|
||
return ExtraGlyphRanges.Greek;
|
||
if (
|
||
code.StartsWith("cs")
|
||
|| code.StartsWith("pl")
|
||
|| code.StartsWith("ro")
|
||
|| code.StartsWith("hu")
|
||
|| code.StartsWith("tr")
|
||
)
|
||
return ExtraGlyphRanges.LatinExtended;
|
||
|
||
return 0;
|
||
}
|
||
|
||
public static ExtraGlyphRanges RequiredGlyphRanges(this LanguageOverride mode) =>
|
||
mode switch
|
||
{
|
||
LanguageOverride.Korean => ExtraGlyphRanges.Korean,
|
||
LanguageOverride.ChineseSimplified => ExtraGlyphRanges.ChineseSimplifiedCommon,
|
||
LanguageOverride.ChineseTraditional => ExtraGlyphRanges.ChineseFull,
|
||
LanguageOverride.Ukrainian => ExtraGlyphRanges.Cyrillic,
|
||
LanguageOverride.Greek => ExtraGlyphRanges.Greek,
|
||
LanguageOverride.Czech
|
||
or LanguageOverride.Polish
|
||
or LanguageOverride.Romanian
|
||
or LanguageOverride.Hungarian
|
||
or LanguageOverride.Turkish => ExtraGlyphRanges.LatinExtended,
|
||
_ => 0,
|
||
};
|
||
}
|
||
|
||
[Serializable]
|
||
[Flags]
|
||
public enum ExtraGlyphRanges
|
||
{
|
||
ChineseFull = 1 << 0,
|
||
ChineseSimplifiedCommon = 1 << 1,
|
||
Cyrillic = 1 << 2,
|
||
Japanese = 1 << 3,
|
||
Korean = 1 << 4,
|
||
Thai = 1 << 5,
|
||
Vietnamese = 1 << 6,
|
||
|
||
// v1.5.3: Custom ranges for languages with Latin Extended-A glyphs (Czech,
|
||
// Polish, Romanian, Turkish, Hungarian) and Greek polytonic accents.
|
||
LatinExtended = 1 << 7,
|
||
Greek = 1 << 8,
|
||
}
|
||
|
||
public static class ExtraGlyphRangesExt
|
||
{
|
||
// Custom (start, end) inclusive pair lists for ranges that ImGui does
|
||
// not ship a built-in helper for. SetUpRanges() feeds these into
|
||
// ImFontGlyphRangesBuilder.AddChar via the `chars` parameter of
|
||
// BuildRange so we avoid the lifetime/pinning question that the native
|
||
// GetGlyphRanges*-pointer pathway papers over.
|
||
internal static readonly ushort[] LatinExtendedPairs = { 0x0100, 0x024F };
|
||
internal static readonly ushort[] GreekPairs = { 0x0370, 0x03FF, 0x1F00, 0x1FFF };
|
||
|
||
public static string Name(this ExtraGlyphRanges ranges) =>
|
||
ranges switch
|
||
{
|
||
ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
|
||
ExtraGlyphRanges.ChineseSimplifiedCommon =>
|
||
Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
|
||
ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
|
||
ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
|
||
ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
|
||
ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
|
||
ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
|
||
ExtraGlyphRanges.LatinExtended => Language.ExtraGlyphRanges_LatinExtended_Name,
|
||
ExtraGlyphRanges.Greek => Language.ExtraGlyphRanges_Greek_Name,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||
};
|
||
|
||
public static unsafe nint Range(this ExtraGlyphRanges ranges) =>
|
||
ranges switch
|
||
{
|
||
ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
|
||
ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)
|
||
ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
|
||
ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
|
||
ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
|
||
ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
|
||
ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
|
||
ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
|
||
// LatinExtended and Greek are applied via builder.AddChar in
|
||
// FontManager.SetUpRanges, not through a native pointer range.
|
||
ExtraGlyphRanges.LatinExtended => 0,
|
||
ExtraGlyphRanges.Greek => 0,
|
||
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
|
||
};
|
||
}
|