fix(privacy): one rule for tab names, applied to all four surfaces

The header was the only place that knew a tab name can be a person. The sidebar,
the tab strip and a pop-out's window title drew the same "Player@World" string
untouched, so a screenshot of the default view still named the partner while the
messages underneath were anonymised. Guarding one surface out of four guards
nobody.

The rule sits in one place now and all four read it. Names are replaced rather
than blanked: a nameless tab in a strip of tabs is worse to use than a
placeholder, and the sidebar has no room to explain itself. The salt is drawn
fresh on every plugin load, the same reasoning the message path uses -- a stable
label would let two screenshots taken weeks apart be tied together.

The tab strip resolves once and both measures and draws that value. Measuring one
string and drawing another would have sized every tab wrong the moment the mode
came on, which is the kind of thing that looks like a layout bug and gets fixed
in the wrong place.
This commit is contained in:
2026-08-19 12:04:55 +02:00
parent 1604186aa1
commit b63e1eda9b
5 changed files with 79 additions and 13 deletions
+9 -1
View File
@@ -394,7 +394,15 @@ internal sealed class Sidebar
ImGuiUtil.Tooltip(HellionStrings.PinTab_PinnedTooltip);
if (expanded)
dl.AddText(origin + new Vector2(iconRight + 6f * scale, contentY), textAbgr, tab.Name);
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
+10 -2
View File
@@ -71,11 +71,19 @@ internal sealed class TopTabBar
!ReferenceEquals(tab, activeTab)
&& tab.UnreadMode != UnreadMode.None
&& tab.Unread > 0;
// Resolved once: measuring one string and drawing another would size
// every tab wrong the moment screenshot mode is on.
var label = Util.TabDisplayName.Resolve(
tab.Name,
tab.NameCameFromPartner,
Plugin.Config.ScreenshotMode
);
var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0;
var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero;
var width =
ImGui.CalcTextSize(tab.Name).X
ImGui.CalcTextSize(label).X
+ padX * 2f
+ (showUnread ? badgeSize.X + Metrics.TopTabUnreadInset : 0f);
var size = WidgetGeometry.IconButton(width, height);
@@ -102,7 +110,7 @@ internal sealed class TopTabBar
dl,
origin,
size,
tab.Name,
label,
selected,
hoverAmount,
surfaceActive,
@@ -75,17 +75,20 @@ internal static class ChannelHeader
var track = TrackRaw * scale;
var detailTrack = DetailTrackRaw * scale;
// Screenshot mode hides a name that came from a conversation partner:
// Screenshot mode replaces a name that came from a conversation partner:
// AutoTellTabsService builds those as "Player@World", and drawing one in
// tracked caps above a log whose messages are anonymised would give away
// in the header exactly what the log is hiding.
//
// Reading TellTarget or IsTempTab here would miss the case that matters
// most. StripTellBindingOnPromote clears both and keeps the name, so a
// promoted tell tab is called "Player@World" for good while carrying
// neither marker. The flag is set where the name is built and survives
// that.
// Same helper the sidebar, the tab strip and the pop-out title use, so
// one conversation shows the same placeholder everywhere rather than
// vanishing on one surface and staying put on three.
var namesAPartner = tab.NameCameFromPartner;
var shownName = TabDisplayName.Resolve(
tab.Name,
namesAPartner,
Plugin.Config.ScreenshotMode
);
// The tab icon for an auto-tell tab is derived from the partner and is
// stable across sessions, so it is three bits of linkable information on
@@ -95,11 +98,10 @@ internal static class ChannelHeader
Plugin.Config.ScreenshotMode && namesAPartner
? Dalamud.Interface.FontAwesomeIcon.Envelope
: Components.Sidebar.ResolveTabIcon(tab);
var showName =
mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner);
var showName = mode is ChannelHeaderMode.Full;
// ToUpperInvariant allocates, so only where the name is actually drawn.
var name = showName ? tab.Name.ToUpperInvariant() : string.Empty;
var name = showName ? shownName.ToUpperInvariant() : string.Empty;
Vector2 iconSize;
using (fonts.FontAwesome.Push())
@@ -84,7 +84,14 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow
// Visible label tracks the bound tab; the ###id stays slot-stable so
// ImGui keeps this slot's position/size across binds.
WindowName = $"{tab.Name}###hellion_popout_{_slotIndex}";
// The title bar is a surface too, and the header deliberately stays
// silent in this mode because the title already carries the name.
var label = Util.TabDisplayName.Resolve(
tab.Name,
tab.NameCameFromPartner,
Plugin.Config.ScreenshotMode
);
WindowName = $"{label}###hellion_popout_{_slotIndex}";
IsOpen = true;
}
+41
View File
@@ -0,0 +1,41 @@
namespace HellionChat.Util;
// TEST-MIRROR: Util/TabDisplayNameTests.cs
//
// One place that knows whether a tab name may be shown. Four surfaces draw it --
// the sidebar, the top-tab strip, a pop-out's window title and the channel header
// -- and before this each of them decided on its own, which meant three of them
// decided nothing at all.
//
// The name matters because an auto-tell tab is called "Player@World". In
// screenshot mode the message list anonymises every sender, so a tab name left
// alone puts the conversation partner back on screen in the one place a reader
// looks first.
//
// Replaced rather than blanked: a nameless tab in a strip of tabs is worse to use
// than a placeholder, and the sidebar has no room to explain itself. The salt is
// the same idea the message path uses -- fresh per plugin load, so two
// screenshots taken weeks apart cannot be tied together by a stable label.
internal static class TabDisplayName
{
private static readonly string Salt = new Random().Next().ToString();
internal static string Resolve(string name, bool cameFromPartner, bool screenshotMode) =>
Resolve(name, cameFromPartner, screenshotMode, Salt);
// Salt as a parameter so the rule can be tested without depending on a value
// that is random by design.
internal static string Resolve(
string name,
bool cameFromPartner,
bool screenshotMode,
string salt
)
{
if (!screenshotMode || !cameFromPartner)
return name;
var hash = $"{salt}{name}".GetHashCode();
return $"Player {hash:X8}";
}
}