Files
HellionChat/HellionChat/Ui/Components/Sidebar.cs
T
JonKazama-Hellion 97d31bb8e4 fix(defaults): the values everyone had set by hand become the defaults
The 2.0.0 reset put every install on the shipped defaults for the first time, and
that exposed which of them had never actually been used. The sidebar was the
loudest: 44 pixels, a width carried over from the v1.2.0 icon-only layout and left
in place long after the sidebar started drawing labels beside those icons. Every
tab name came out clipped. Nobody had noticed in cycles because everyone had
widened it by hand -- 160 in the config this was measured against.

Default 160, and the floor moves from 40 to 130: where a tab name stops being
readable in German, which is the longest of the 25 languages, rather than where
the icons stop fitting. Anyone who wants it slimmer wants the collapsed layout,
and that is a separate width.

Glyph ranges had the same shape of problem with a worse outcome. They were only
ever filled when someone picked a language explicitly, so an install left on
"follow Dalamud" -- the default -- got none at all. That went unnoticed while
configs accumulated ranges over months; a fresh config has none, and a tester on
a Korean, Chinese, Cyrillic or Greek client would have come out of this update
reading boxes. The load path derives them from the Dalamud UI language too now.

The rest are preference defaults taken from a config that has been in daily use
across every one of these cycles: compact density off, title bar off, compact
timestamps on, compact tell tabs on, honorific glow on, 100 messages of tell
history preloaded, inactive opacity 0.75, command help on the right. New tell
tabs open as pop-outs, because the wizard's closing step tells the user to try
/tell and watch exactly that happen.

Two values were deliberately not carried over. SeenPopOutInputHint and
SeenPopOutHeaderHint are not preferences, they are "this user has seen it"
markers -- shipping them as true would mean no new user ever sees the hints that
explain a feature people did not find on their own. The greeted toggle stays off
as well: it is opt-in for people who greet.

Also dropped the light-bulb emoji from the wizard's closing hint in all 25
languages. UI icons come from the icon font here, not from emoji.
2026-08-19 22:34:35 +02:00

594 lines
25 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Ui.StyleEngine.Widgets;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui.Components;
// Channel-list panel pinned to the left of the chat window. Auto-switches
// between an icon-only column (38px) and an expanded column (Config.SidebarWidth) once
// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The
// pop-out affordance (hover button + right-click menu) routes through the
// injected ChannelPopoutPool via TryOpen, which reserves a slot and binds
// the tab to a pre-allocated pop-out window.
internal sealed class Sidebar
{
public const float IconOnlyWidth = 38f;
// Expanded sidebar width is user-configurable (Config.SidebarWidth),
// clamped to these bounds (matches the ChannelsTab slider range).
//
// The floor is where a tab name stops being readable, not where the icons
// stop fitting: 40 let the expanded sidebar be narrower than the icon-only
// one, which drew labels into a column too narrow to hold them. Anyone who
// wants it that slim wants the collapsed layout, and that is IconOnlyWidth.
public const float MinSidebarWidth = 130f;
public const float MaxSidebarWidth = 300f;
private static float RowHeight => Metrics.SidebarRowHeight;
private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth;
private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth;
// Counts rows that got the active surface this frame. At most one, with two
// exceptions: zero when every tab is popped out (PickMainActiveTab returns
// null), and two in the frame a click lands on a row drawn after the
// previously active one -- that row was still active when it was painted.
internal int LastRenderedActiveSurfaceCount { get; private set; }
// render observability: counts greeted glyphs actually drawn this frame.
// Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start.
// The SelfTest reads it after driving the real Draw — no dead service roundtrip.
internal int LastRenderedGreetedGlyphCount;
internal int LastRenderedUnreadDotCount;
// Small enough to read as a marker on the icon rather than as a second icon
// beside it.
private const float PinGlyphScale = 0.6f;
// render observability: section headers actually drawn this frame.
// Incremented only in the real header branch; reset at Draw start.
internal int LastDrawnSectionHeaderCount;
// Inline mirror of the old TabIconMapping table so the Ui layer carries
// its own glyph lookup once the standalone file is removed.
private static readonly Dictionary<string, FontAwesomeIcon> IconByName = new(
StringComparer.OrdinalIgnoreCase
)
{
["comment"] = FontAwesomeIcon.Comment,
["comments"] = FontAwesomeIcon.Comments,
["cog"] = FontAwesomeIcon.Cog,
["users"] = FontAwesomeIcon.Users,
["user-friends"] = FontAwesomeIcon.UserFriends,
["link"] = FontAwesomeIcon.Link,
["envelope"] = FontAwesomeIcon.Envelope,
["clock"] = FontAwesomeIcon.Clock,
["hashtag"] = FontAwesomeIcon.Hashtag,
["star"] = FontAwesomeIcon.Star,
["heart"] = FontAwesomeIcon.Heart,
["bell"] = FontAwesomeIcon.Bell,
["bookmark"] = FontAwesomeIcon.Bookmark,
["flag"] = FontAwesomeIcon.Flag,
["fire"] = FontAwesomeIcon.Fire,
};
private readonly ThemeRegistry _themes;
private readonly TokenResolver _resolver;
private readonly WidgetPalette _palette;
private readonly FontManager _fonts;
private readonly ILogger<Sidebar> _logger;
private readonly Windows.ChannelPopoutPool _pool;
public Sidebar(
ThemeRegistry themes,
TokenResolver resolver,
FontManager fonts,
ILogger<Sidebar> logger,
Windows.ChannelPopoutPool pool
)
{
_themes = themes;
_resolver = resolver;
_palette = new WidgetPalette(resolver);
_fonts = fonts;
_logger = logger;
_pool = pool;
}
// Both stay unscaled. The stored width and the switch threshold are user
// settings in design pixels, and SidebarModeAutoSwitchStep compares this
// return value against the raw bounds. Display scaling is applied once, at
// the single draw call site below.
public bool IsExpanded(float windowWidth) =>
windowWidth >= Plugin.Config.SidebarAutoSwitchThresholdPx;
public float GetWidth(float windowWidth) =>
IsExpanded(windowWidth)
? Math.Clamp((float)Plugin.Config.SidebarWidth, MinSidebarWidth, MaxSidebarWidth)
: IconOnlyWidth;
// Factored click logic so the SelfTest exercises the real toggle, not a direct
// MarkGreeted call (which would be a dead path the render never takes).
internal void ToggleGreetedForSelfTest(Tab tab)
{
if (Plugin.Instance.AutoTellTabsService.IsGreeted(tab))
Plugin.Instance.AutoTellTabsService.UnmarkGreeted(tab);
else
Plugin.Instance.AutoTellTabsService.MarkGreeted(tab);
}
public void Draw(float windowWidth, IReadOnlyList<Tab> tabs, ref Tab? activeTab)
{
LastRenderedGreetedGlyphCount = 0;
LastRenderedUnreadDotCount = 0;
LastDrawnSectionHeaderCount = 0;
LastRenderedActiveSurfaceCount = 0;
if (!_fonts.FontsReady)
{
// A scale change triggers a font rebuild, so this branch is really
// hit while GlobalScale is moving -- an unscaled width here makes
// the sidebar jump.
ImGui.Dummy(new Vector2(Metrics.SidebarIconOnlyWidth, 0));
return;
}
var expanded = IsExpanded(windowWidth);
var width = GetWidth(windowWidth) * Metrics.Scale;
using var child = ImRaii.Child("##hellion-sidebar", new Vector2(width, 0));
if (!child.Success)
return;
var theme = _themes.Active;
var accentRgba = _resolver.Resolve(Token.AccentPrimary, theme.Colors);
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim);
var dl = ImGui.GetWindowDrawList();
// sectioned render order (1.5.6 parity): persistent → pinned
// TempTabs → unpinned TempTabs. Only the display sequence regroups;
// the tab list itself stays untouched and every row keeps its
// ORIGINAL list index for PushID, so an open context-menu popup
// stays bound to its tab when sectioning moves it visually.
var renderOrder = TabLifecycleHelpers.BuildRenderOrder(
tabs,
t => _pool.IsOpen(t.Identifier)
);
var pinnedHeaderRendered = false;
var unpinnedHeaderRendered = false;
// Rows carry their own full-height surface now, so the default gap
// between them would read as a stripe of window background. The section
// headers are unaffected: LineDivider brings its own padding.
using var rowSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
foreach (var i in renderOrder)
{
var tab = tabs[i];
if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.PinTab_SectionHeader,
TabLifecycleHelpers.CountPinnedPool(tabs, t => _pool.IsOpen(t.Identifier))
);
pinnedHeaderRendered = true;
}
else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !unpinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.AutoTellTabs_SectionHeader,
TabLifecycleHelpers.CountUnpinnedPool(tabs, t => _pool.IsOpen(t.Identifier))
);
unpinnedHeaderRendered = true;
}
DrawRow(tab, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab);
}
}
// Section transition marker (1.5.6 parity): the rule always renders, compact
// mode suppresses only the caption. LineDivider submits its own layout item
// and carries its own padding, which is what lets the rows below sit flush
// without the header collapsing onto them.
private void DrawSectionHeader(string header, int count)
{
var colors = _themes.Active.Colors;
var compact = Plugin.Config.AutoTellTabsCompactDisplay;
LineDivider.Draw(
compact ? null : $"{header} ({count})",
_palette.Abgr(Token.Border, colors),
_palette.Abgr(Token.TextMuted, colors)
);
if (!compact)
LastDrawnSectionHeaderCount++;
}
private void DrawRow(
Tab tab,
bool expanded,
uint accentRgba,
uint textAbgr,
uint mutedAbgr,
uint dimAbgr,
ImDrawListPtr dl,
ref Tab? activeTab
)
{
// Identity, not position: ImGui keeps popup state across frames under this
// ID, so an index would re-bind an open context menu to a different tab as
// soon as the list shifts. String, not GetHashCode — hashes collide.
ImGui.PushID(tab.Identifier.ToString());
var origin = ImGui.GetCursorScreenPos();
var avail = ImGui.GetContentRegionAvail().X;
// Drop the row entirely when the sidebar is dragged below the width
// of a single hit target. ImGui's InvisibleButton asserts on a
// zero-width size, which crashes the whole window at min-drag.
if (avail < Metrics.SidebarMinDrawWidth)
{
ImGui.PopID();
return;
}
// 1.5.6 parity: greeted state dims the tab icon whenever the toggle is
// configured on. The clickable affordance additionally needs an expanded
// sidebar with room for a third hit area beside the pop-out slot — in
// icon-only or min-drag mode it is skipped entirely.
var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle;
var showGreeted =
greetedConfigured
&& expanded
&& avail > GreetedHitWidth + PopOutHitWidth + Metrics.SidebarHitSlack;
// Only split off a separate pop-out hit area when there's room for
// both buttons. Below that, the whole row stays as a single
// selectable strip without the pop-out affordance.
// Gate the pop-out affordance on the expanded sidebar too. In
// icon-only mode avail still clears the width threshold, which used to
// paint the pop-out glyph over the tab icon. The row stays a single
// selectable strip when collapsed; right-click pop-out is unaffected.
var hasPopOut = expanded && avail > PopOutHitWidth + Metrics.SidebarHitSlack;
var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail;
if (showGreeted)
{
// Greeted slot sits at the left edge (1.5.6 placement); the row
// button starts after it so the three hit areas never overlap.
tabHitWidth -= GreetedHitWidth;
ImGui.SetCursorScreenPos(origin + new Vector2(GreetedHitWidth, 0f));
}
ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight));
if (ImGui.IsItemClicked())
{
var previous = activeTab;
activeTab = tab;
TabLifecycleHelpers.OnTabActivated(tab, previous);
}
// Not IsItemHovered: the row button is up to two hit widths narrower than
// the row, so a full-width surface driven by it would flicker at the
// edges. AllowWhenBlockedByActiveItem keeps the surface while the button
// is held down; without it the fill vanishes on press.
var rowMax = origin + new Vector2(avail, RowHeight);
var surfaceHovered =
ImGui.IsMouseHoveringRect(origin, rowMax)
&& ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
// GetID is seeded from the window's ID stack, so the same "row" literal
// stays distinct per window and per PushID'd tab. The old interpolated
// key allocated two strings per row per frame.
var hoverId = ImGui.GetID("row"u8);
var hoverAmount = HoverState.Query(hoverId, surfaceHovered);
var isActiveRow = ReferenceEquals(tab, activeTab);
if (isActiveRow)
LastRenderedActiveSurfaceCount++;
var colors = _themes.Active.Colors;
// Row fills follow the window's own opacity. Theme surfaces are fully
// opaque and the window is translucent by default (0.85 focused, 0.65
// not), so unscaled fills would sit on top as solid blocks.
var opacity = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootWindow)
? Plugin.Config.WindowOpacity
: Plugin.Config.WindowOpacityInactive;
Row.Draw(
origin,
new Vector2(avail, RowHeight),
new RowVisualState
{
IsActive = isActiveRow,
HoverAmount = hoverAmount,
SurfaceHoverAbgr = ColourUtil.ApplyAlpha(
_palette.Abgr(Token.SurfaceHover, colors),
opacity
),
SurfaceActiveAbgr = ColourUtil.ApplyAlpha(
_palette.Abgr(Token.SurfaceActive, colors),
opacity
),
AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors),
BorderAbgr = ColourUtil.ApplyAlpha(_palette.Abgr(Token.Border, colors), opacity),
},
new RowStyle
{
// Across the gap between the sidebar group and the message
// area, so the selected tab touches the conversation it
// selects instead of ending at the seam.
ActiveBridgeWidth = ImGui.GetStyle().ItemSpacing.X,
}
);
dl.DrawHoverSheen(origin, rowMax, accentRgba, hoverAmount, surfaceHovered);
var icon = ResolveTabIcon(tab);
// Dim precedence (1.5.6): the active tab never dims; only greeted,
// non-active tabs drop to TextDim. "Regular colour" is the tint for an
// auto-tell tab and the theme text colour for everything else.
//
// Below that, an auto-tell tab is tinted from its partner. Twelve
// colours against seven glyphs is 84 combinations, which is plenty for
// the one to five conversations anybody actually runs in parallel. The
// greeted dim still wins: it says something about this tab right now,
// the tint only says who it belongs to.
var isCurrentTab = tab == activeTab;
var iconColor = tab.IsTempTab ? ColourUtil.RgbaToAbgr(TabTintCache.GetTint(tab)) : textAbgr;
if (
!isCurrentTab
&& greetedConfigured
&& Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
)
iconColor = dimAbgr;
// Icon and label shift right by the greeted slot when it is shown.
var contentX = showGreeted ? GreetedHitWidth : 0f;
var scale = Metrics.Scale;
var iconInset = 10f * scale;
// Centred, not a frozen offset: the old 8f was (32 - 16) / 2 for a 16px
// font and stays wrong at any other Config.FontSizeV2.
var contentY = Metrics.CenterY(RowHeight);
float iconRight;
using (_fonts.FontAwesome.Push())
{
// Measured inside the push: FontAwesome is a fixed-width icon handle
// that does not follow Config.FontSizeV2, so the text font's line
// height would misplace the glyph at any other body size.
var iconStr = icon.ToIconString();
var iconSize = ImGui.CalcTextSize(iconStr);
var iconPos =
origin
+ new Vector2(iconInset + contentX, MetricsMath.CenterY(RowHeight, iconSize.Y));
dl.AddText(iconPos, iconColor, iconStr);
iconRight = iconInset + contentX + iconSize.X;
// Pinned marker: a small thumbtack tucked into the icon's lower
// left. Drawn from the same font push and inside the row rectangle
// the InvisibleButton already reserved, so it claims no layout of
// its own -- badges in this very sidebar are where "draw into
// unreserved space" caught this project last.
//
// Lower left because the unread dot owns the upper right.
if (tab.IsPinned)
{
var pinStr = FontAwesomeIcon.Thumbtack.ToIconString();
var pinFontSize = ImGui.GetFontSize() * PinGlyphScale;
var pinSize = ImGui.CalcTextSize(pinStr) * PinGlyphScale;
dl.AddText(
ImGui.GetFont(),
pinFontSize,
iconPos + new Vector2(-pinSize.X * 0.45f, iconSize.Y - pinSize.Y * 0.75f),
_palette.Abgr(Token.AccentPrimary, colors),
pinStr
);
}
}
// Only for pinned rows, and only in the sidebar's own hover state --
// this is the one place a user meets the marker without having opened
// the menu that produced it.
if (tab.IsPinned && surfaceHovered)
ImGuiUtil.Tooltip(HellionStrings.PinTab_PinnedTooltip);
if (expanded)
dl.AddText(
origin + new Vector2(iconRight + 6f * scale, contentY),
textAbgr,
TabDisplayName.Resolve(
tab.Name,
tab.NameCameFromPartner,
Plugin.Config.ScreenshotMode
)
);
// Unread count. Drawn outside the icon-font scope on purpose: the
// FontAwesome atlas carries no ASCII digits, so the number would come out
// blank. The active tab is zeroed every frame (MainWindow.Draw), so it
// never shows on the tab you are viewing; UnreadMode.None opts out.
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
{
var unread = (int)Math.Min(tab.Unread, int.MaxValue);
var badgeSize = Badge.CalcSize(unread);
var accentAbgr = _palette.Abgr(Token.AccentEmber, colors);
var slack = Metrics.SidebarHitSlack;
var reserved = hasPopOut ? PopOutHitWidth : 0f;
var badgeX = avail - reserved - badgeSize.X - slack;
// The count only fits where it can sit clear of the icon. At the
// default sidebar width of 44 it cannot, so a plain dot takes over
// rather than the badge landing on the glyph.
if (badgeX >= iconRight + slack)
{
Badge.Draw(
origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)),
unread,
accentAbgr,
textAbgr
);
}
else
{
var r = Metrics.SidebarUnreadRadius;
dl.AddCircleFilled(
origin + new Vector2(iconRight - r * 0.5f, RowHeight * 0.5f - r),
r,
accentAbgr,
12
);
}
LastRenderedUnreadDotCount++;
}
TabContextMenu.Draw(tab, "ctx", _pool);
if (hasPopOut)
{
ImGui.SameLine(0f, 0f);
// Glyph follows the row surface, not the button's own hover: the
// button sits inside the row, and the old pairing needed a hover
// state one line before it existed.
var (popClicked, _) = IconButton.Draw(
ImGui.GetID("popout"u8),
new Vector2(PopOutHitWidth, RowHeight),
surfaceHovered ? FontAwesomeIcon.ArrowUpRightFromSquare : null,
mutedAbgr,
_palette.Abgr(Token.SurfaceHover, colors),
_fonts.FontAwesome
);
if (popClicked)
_pool.TryOpen(tab);
}
if (showGreeted)
{
// The hit area sits at the LEFT edge of the row, but the item must
// be submitted AFTER TabContextMenu.Draw — any interactive item
// between the row button and the popup call would steal the
// right-click trigger (ordering constraint).
ImGui.SetCursorScreenPos(origin);
// CheckCircle = greeted, plain Check = still pending (1.5.6 mapping).
var greetedGlyph = Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
? FontAwesomeIcon.CheckCircle
: FontAwesomeIcon.Check;
var (greetedClicked, _) = IconButton.Draw(
ImGui.GetID("greeted"u8),
new Vector2(GreetedHitWidth, RowHeight),
greetedGlyph,
mutedAbgr,
_palette.Abgr(Token.SurfaceHover, colors),
_fonts.FontAwesome
);
if (greetedClicked)
ToggleGreetedForSelfTest(tab);
LastRenderedGreetedGlyphCount++;
}
ImGui.PopID();
}
// internal since v1.13.0: the channel header shows the same icon as the row
// in here, and it has to resolve it the same way. IconByName alone would not
// do -- it only answers for a tab with an explicitly chosen icon, and the
// default is none, so most tabs fall through to the derivation below.
internal static FontAwesomeIcon ResolveTabIcon(Tab tab)
{
if (
!string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped)
)
return mapped;
// Auto-tell tabs get one of seven glyphs derived from the partner, not
// one envelope for all of them. With four tells open, identical rows in
// identical colour are four rows you have to read to tell apart.
if (tab.IsTempTab)
{
// TryGetValue rather than an indexer: every glyph in the pool is in
// the table today, and a lookup miss would be somebody editing one
// of the two lists without the other. A wrong envelope beats a
// KeyNotFoundException on the draw thread.
var hashed = TabTintCache.GetIcon(tab);
return IconByName.TryGetValue(hashed, out var tellGlyph)
? tellGlyph
: FontAwesomeIcon.Envelope;
}
// Channel-type fallback. Walk every selected key, not just the first,
// so a System tab that filters multiple system-flavoured ChatTypes
// still picks up fa-cog when one of the later keys carries the match.
// The Comment default only wins when every key falls into the
// generic-text bucket (Say / Yell / Shout etc.).
foreach (var chatType in tab.SelectedChannels.Keys)
{
var glyph = ResolveByChannelType(chatType);
if (glyph != FontAwesomeIcon.Comment)
return glyph;
}
// Last-resort name match for tabs that filter exotic ChatTypes the
// mapping above doesn't cover — keeps the System tab visually
// distinct even with a custom channel set.
if (tab.Name.Contains("system", StringComparison.OrdinalIgnoreCase))
return FontAwesomeIcon.Cog;
return FontAwesomeIcon.Comment;
}
private static FontAwesomeIcon ResolveByChannelType(ChatType type) =>
type switch
{
ChatType.TellIncoming or ChatType.TellOutgoing => FontAwesomeIcon.Envelope,
ChatType.FreeCompany
or ChatType.FreeCompanyAnnouncement
or ChatType.FreeCompanyLoginLogout => FontAwesomeIcon.Users,
ChatType.Linkshell1
or ChatType.Linkshell2
or ChatType.Linkshell3
or ChatType.Linkshell4
or ChatType.Linkshell5
or ChatType.Linkshell6
or ChatType.Linkshell7
or ChatType.Linkshell8
or ChatType.CrossLinkshell1
or ChatType.CrossLinkshell2
or ChatType.CrossLinkshell3
or ChatType.CrossLinkshell4
or ChatType.CrossLinkshell5
or ChatType.CrossLinkshell6
or ChatType.CrossLinkshell7
or ChatType.CrossLinkshell8 => FontAwesomeIcon.Link,
ChatType.Party or ChatType.CrossParty => FontAwesomeIcon.UserFriends,
ChatType.Alliance => FontAwesomeIcon.Users,
ChatType.NoviceNetwork or ChatType.NoviceNetworkSystem => FontAwesomeIcon.Users,
ChatType.PvpTeam or ChatType.PvpTeamAnnouncement or ChatType.PvpTeamLoginLogout =>
FontAwesomeIcon.Users,
ChatType.System
or ChatType.BattleSystem
or ChatType.GatheringSystem
or ChatType.Error
or ChatType.Notice
or ChatType.LootNotice
or ChatType.Echo => FontAwesomeIcon.Cog,
ChatType.CustomEmote or ChatType.StandardEmote => FontAwesomeIcon.Comments,
_ => FontAwesomeIcon.Comment,
};
}