Block D of v1.12.0. The line between the two is the whole job, and I got it wrong once on the way: six cache fields on Tab looked dead because nothing writes them, and nothing writes them because AutoTellTabTint and TabTintCache went out with the chat window incf4705e. Deleting the fields would have cemented a loss instead of recording a decision. So they are back, and the sidebar uses them again: an auto-tell tab is tinted and glyphed from its partner, twelve colours against seven icons. Four open tells are no longer four identical envelopes in one colour. Their own header promised the same partner keeps its colour "across sessions" while hashing with string.GetHashCode, which .NET salts per process -- every game start reshuffled every tab. FNV-1a now, with a lowbias32 finalizer that is not decoration: without it a probe over 144 similar keys reached six of the twelve colours, because the caller takes the low bits with a modulo and FNV leaves those correlated. Three pinned values guard it, which is also the only assertion that can catch a regression to a salted hash. The same question, asked of the three hide conditions this block had quietly orphaned: HideDuringCutscenes, HideInBattle, HideWhenNotLoggedIn all had readers in v1.5.6 and lost them in the same commit. Two of them are states rather than conditions -- a cutscene the user dismissed stays dismissed until it ends, and combat must not seize a chat that is already hidden for another reason -- so they come back as a small state machine with eight pinned transitions, and three toggles whose labels were already translated in all 25 languages. Actually deleted, with a reader search each time: - Six per-tab hide fields. Their reader was the pop-out window and it stopped consulting them incf4705e. Per-tab was the wrong unit anyway: "hide during cutscenes" is a statement about the screen. - Tab.ChatCodes, whose migration the v16 schema gate had already made unreachable. - InactivityHideTimeout and InactivityHideActiveDuringBattle, MaxLinesToRender which had stopped bounding anything, and the 155 lines of Configuration.UpdateFrom with no caller at all. Config version 25, at all three places that carry it. No migration step: the gate only refuses anything under 16 and Json.NET drops keys it does not know, so the deleted fields simply stop being written. One thing a review pass caught that matters more than any of the above: the clone parity guard had gone hollow. It compares collections by count, ChatCodes was the only collection the probe seeded, and removing it left the guard comparing zero against zero. Verified by making Tab.Clone discard both remaining collections and watching every assertion stay green. The probe seeds them now, and the same sabotage fails as it should.
997 lines
35 KiB
C#
Executable File
997 lines
35 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 = 25;
|
||
|
||
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;
|
||
|
||
// UI-12: background opacity of the main chat window while unfocused.
|
||
// WindowOpacity above stays the focused value.
|
||
public float WindowOpacityInactive = 0.65f;
|
||
|
||
// Reserved for future UI toggles; pre-declared to avoid a migration later.
|
||
public bool ReduceMotion;
|
||
|
||
// v1.2.1: default flipped false → true. Compact single-line layout is
|
||
// more readable than the card-rows layout introduced in v1.2.0.
|
||
public bool UseCompactDensity = true;
|
||
|
||
// Privacy by Default master switch. Set false to restore upstream behaviour.
|
||
public bool PrivacyFilterEnabled = true;
|
||
|
||
// 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;
|
||
|
||
// F3.2: dedup unknown-ChatType warnings so a chatty filter doesn't spam
|
||
// the log every frame. NonSerialized so the warning fires once per
|
||
// runtime, not once-ever-per-install.
|
||
[NonSerialized]
|
||
private readonly HashSet<ChatType> _warnedUnknownChannels = new();
|
||
|
||
// 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);
|
||
|
||
// F3.2: log 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;
|
||
public bool EnableAutoTellTabs = true;
|
||
public int AutoTellTabsLimit = 15;
|
||
public bool AutoTellTabsCompactDisplay;
|
||
public int AutoTellTabsHistoryPreload = 20;
|
||
|
||
// Sidebar width in pixels. Default 44 mirrors the icon-only layout from
|
||
// v1.2.0; users can widen up to 160 to fit a section-header line like
|
||
// "Active Tells (3)" without truncation.
|
||
public int SidebarWidth = 44;
|
||
public bool AutoTellTabsShowGreetedToggle;
|
||
public bool SeenPopOutInputHint;
|
||
public bool PopOutInputEnabled = true;
|
||
public bool SeenPopOutHeaderHint;
|
||
public bool AutoTellTabsOpenAsPopout;
|
||
|
||
// UI-7: 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;
|
||
|
||
[Obsolete("Use InactivityHideChannelsV2 instead")]
|
||
public Dictionary<ChatType, ChatSource> InactivityHideChannels = [];
|
||
|
||
public Dictionary<ChatType, (ChatSource, ChatSource)> InactivityHideChannelsV2 = [];
|
||
public bool InactivityHideExtraChatAll = true;
|
||
public HashSet<Guid> InactivityHideExtraChatChannels = [];
|
||
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;
|
||
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.None;
|
||
public KeybindMode KeybindMode = KeybindMode.Strict;
|
||
public LanguageOverride LanguageOverride = LanguageOverride.None;
|
||
public bool CanMove = true;
|
||
public bool CanResize = true;
|
||
public bool ShowTitleBar = true;
|
||
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;
|
||
|
||
// UI-11: warn before sending a message that carries plugin-only glyphs.
|
||
public bool NotifyPluginDisclosure = true;
|
||
public bool KeepInputFocus = true;
|
||
public bool Use24HourClock = true;
|
||
public bool ShowEmotes = true;
|
||
public HashSet<string> BlockedEmotes = [];
|
||
public bool FontsEnabled = true;
|
||
public ExtraGlyphRanges ExtraGlyphRanges = 0;
|
||
public float FontSizeV2 = 12.75f;
|
||
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;
|
||
public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar;
|
||
|
||
// 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,
|
||
}
|
||
|
||
public static class TellAutoOpenModeExt
|
||
{
|
||
// The only display name set still in English. It sat inline in ChannelsTab
|
||
// as a literal array, which is why it was missed when the rest moved into
|
||
// resources; here it is at least in the same place as its peers for the
|
||
// localisation pass to pick up.
|
||
public static string Name(this TellAutoOpenMode mode) =>
|
||
mode switch
|
||
{
|
||
TellAutoOpenMode.Off => "Off",
|
||
TellAutoOpenMode.Sidebar => "Sidebar",
|
||
TellAutoOpenMode.TopTab => "Top tab",
|
||
TellAutoOpenMode.Popout => "Popout",
|
||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||
};
|
||
}
|
||
|
||
[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 from
|
||
// Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
|
||
// separate from the AutoTellTabsLimit bucket.
|
||
public bool IsPinned;
|
||
public bool AllSenderMessages;
|
||
public TellTarget TellTarget = TellTarget.Empty();
|
||
|
||
// 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;
|
||
|
||
// PM-3 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;
|
||
|
||
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;
|
||
if (
|
||
message.Matches(
|
||
Plugin.Config.InactivityHideChannelsV2,
|
||
Plugin.Config.InactivityHideExtraChatAll,
|
||
Plugin.Config.InactivityHideExtraChatChannels
|
||
)
|
||
)
|
||
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.
|
||
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),
|
||
};
|
||
}
|