using System;
using System.Numerics;
using AcDream.Core.Social;
namespace AcDream.App.UI.Layout;
///
/// Campaign FA slice FA3, D1: the Friends page's read-only roster — names
/// only, bound directly to the J4.1 owner
/// (RuntimeCommunicationState.Friends). Rebuilds on
/// change; polled every frame from
/// WHILE THE PANEL IS VISIBLE
/// (cheap — a single comparison when nothing
/// changed; see the scrollbar/rebuild-discipline paragraph below for the
/// fix-round visibility gate).
///
///
/// Actions LIVE as of 2026-08-13 (AD-79 retired for this page).
/// Add (name box → 0x0018), Remove (selected row → 0x0017),
/// and Appear Offline (CharacterOption 0x27 via the immediate
/// 0x0005 auto-save) are wired through — the
/// wire beneath existed end-to-end all along
/// (docs/research/2026-08-13-social-wire-completion.md §4). Send Tell
/// (0x10000516) remains the one inert button — not in the
/// 2026-08-13 order and needing the chat-tell seam; tracked in AD-79's
/// remainder.
///
///
///
/// Row template 0x2100005D/0x10000519 (FA3 live-mount probe);
/// resolves the row's own
/// name-text leaf — see that class's doc for why the deepest match is used
/// (gmFriendsUI is outside this campaign's decompiled scope).
///
///
///
/// Scrollbar (fix-round blast MF-1; FA4 carry-forward 3). The
/// ListBox 0x10000517 authors a direct sibling scrollbar,
/// 0x10000518 — wired to the
/// SAME way every other UiTemplateListBox consumer wires its own
/// scrollbar. Resolved via the built
/// (dat property 0x72, populated by )
/// rather than the hardcoded literal a prior revision of this file used —
/// same "prefer the DAT field over a duplicated constant" fix
/// ConfigOptionsPageController.Bind already applied (OP6 review N2,
/// feedback_prefer_dat_field_over_geometry), scoped to this page's
/// own subtree so a flat lookup can't grab a same-id sibling on another
/// page. Without it the list has NO wheel fallback
/// ( has no wheel handler) and is completely
/// unreachable past the box's visible extent.
///
///
///
/// Rebuild discipline (fix-round blast SF-2/SF-3).
/// only advances after a successful rebuild — a
/// transient resolver miss (
/// returning null) leaves the revision UNCONSUMED so the next
/// retries instead of latching an empty list until the
/// NEXT server-side change. also gates
/// this controller's on the panel's own visibility (its
/// IRetainedPanelController.OnShown/OnHidden hooks) — the
/// per-row template resolve takes the shared DAT lock, so no rebuild work
/// runs while the panel is closed.
///
///
public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
// 2026-08-13 gate (AD-79 retirement): the authored action widgets, roles
// probe-verified against the live DAT (ProbeSocialClickRouting label
// dump): Add / Remove / Send Tell buttons, the Appear Offline checkbox,
// and the name edit box gmFriendsUI's own Add path reads
// (Request_AddFriend @0x0048D240 reads the box, sends 0x0018, clears it).
private const uint AddButtonId = 0x10000514u;
private const uint RemoveButtonId = 0x10000515u;
private const uint AppearOfflineCheckboxId = 0x1000052Cu;
private const uint NameFieldId = 0x1000051Bu;
/// The live wire seams (2026-08-13, AD-79 retired): every
/// command below already existed end-to-end (builders, WorldSession
/// sends, Runtime commands, inbound parsers —
/// docs/research/2026-08-13-social-wire-completion.md §4); this page was
/// the only missing link. AppearOffline is CharacterOption 0x27 riding
/// the immediate 0x0005 auto-save — ACE pushes FriendStatusChanged to
/// everyone who friended you (its §1.4).
public sealed record Actions(
Action AddFriend,
Action RemoveFriend,
Func CurrentAppearOffline,
Action SetAppearOffline);
private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends;
private readonly Actions? _actions;
private readonly UiField? _nameField;
private readonly UiButton? _appearOfflineCheckbox;
private long _lastRevision = long.MinValue;
private uint _selectedFriendGuid;
private SocialFriendsPageController(
UiTemplateListBox listBox,
FriendsState friends,
Actions? actions,
UiField? nameField,
UiButton? appearOfflineCheckbox)
{
_listBox = listBox;
_friends = friends;
_actions = actions;
_nameField = nameField;
_appearOfflineCheckbox = appearOfflineCheckbox;
}
public static SocialFriendsPageController? Bind(
UiElement pageRoot,
FriendsState friends,
Func templateResolver,
Actions? actions = null)
{
ArgumentNullException.ThrowIfNull(pageRoot);
ArgumentNullException.ThrowIfNull(friends);
ArgumentNullException.ThrowIfNull(templateResolver);
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] SocialFriendsPageController: ListBox 0x{ListBoxId:X8} not "
+ "found — Friends page will not populate.");
return null;
}
listBox.TemplateResolver = templateResolver;
uint scrollbarElementId = listBox.ScrollbarElementId;
UiElement? scrollbarElement = scrollbarElementId == 0
? null
: UiElement.FindDescendant(pageRoot, scrollbarElementId);
if (scrollbarElement is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
$"[D.2b] SocialFriendsPageController: scrollbar 0x{scrollbarElementId:X8} "
+ "not found — the Friends list will not scroll.");
var nameField = UiElement.FindDescendant(pageRoot, NameFieldId) as UiField;
var appearOffline = UiElement.FindDescendant(pageRoot, AppearOfflineCheckboxId) as UiButton;
var controller = new SocialFriendsPageController(
listBox, friends, actions, nameField, appearOffline);
controller.WireActions(pageRoot);
controller.Refresh();
return controller;
}
private void WireActions(UiElement pageRoot)
{
if (_actions is not { } actions) return; // fixture callers stay inert
if (UiElement.FindDescendant(pageRoot, AddButtonId) is UiButton add)
add.OnClick = () =>
{
string name = _nameField?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name)) return;
actions.AddFriend(name);
// Retail clears the edit box on send (Request_AddFriend
// @0x0048D240's own post-send clear).
_nameField?.SetText(string.Empty);
};
if (UiElement.FindDescendant(pageRoot, RemoveButtonId) is UiButton remove)
remove.OnClick = () =>
{
if (_selectedFriendGuid != 0u)
actions.RemoveFriend(_selectedFriendGuid);
};
if (_appearOfflineCheckbox is { } checkbox)
{
// The same ToggleBehavior mirror discipline as the fellowship/
// allegiance checkboxes (the 2026-08-13 double-toggle fix).
checkbox.SuppressSelfToggle = true;
checkbox.OnClick = () =>
actions.SetAppearOffline(!actions.CurrentAppearOffline());
}
}
public void Tick()
{
if (_actions is { } actions && _appearOfflineCheckbox is { } checkbox)
checkbox.Selected = actions.CurrentAppearOffline();
long revision = _friends.Revision;
if (revision == _lastRevision) return;
Refresh();
}
/// The row template's LEFT name cell (template layout
/// 0x2100005D root 0x10000519). Its Online/Offline
/// PassToChildren states cascade into the RIGHT status grandchild
/// 0x1000051F, which authors the per-state 'Online'/'Offline' strings
/// and colors (retail's green Online) — installed-DAT probe 2026-08-14,
/// retail's gmFriendsUI row shape.
private const uint RowNameTextId = 0x1000051Au;
private const uint OnlineStateId = 0x10000054u;
private const uint OfflineStateId = 0x10000055u;
private void Refresh()
{
long revision = _friends.Revision;
_listBox.Flush();
bool allRowsResolved = true;
bool selectedStillPresent = false;
foreach (FriendEntry friend in _friends.Snapshot())
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
if (row is null) { allRowsResolved = false; continue; }
if (friend.Id == _selectedFriendGuid) selectedStillPresent = true;
// 2026-08-14 friends gate ("detects online/offline but the list
// does not update"): the old FindDeepest binding wrote the NAME
// into the STATUS cell (the deepest text IS the status
// grandchild) and never flipped the authored state machine, so
// the status column never showed anything. Bind the name to its
// own cell and flip the authored Online/Offline state — the
// cascade swaps the status cell's authored string + color.
if (UiElement.FindDescendant(row, RowNameTextId) is UiText nameText)
{
string name = friend.Name;
uint guid = friend.Id;
nameText.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
// 2026-08-13 gate: row click selects the friend the Remove
// button acts on (UiText.OnClick clears the display-text
// ClickThrough default — see its doc).
nameText.OnClick = () => _selectedFriendGuid = guid;
nameText.TrySetRetailState(
friend.Online ? OnlineStateId : OfflineStateId);
}
else
{
allRowsResolved = false;
}
}
if (!selectedStillPresent) _selectedFriendGuid = 0u;
// SF-3: only latch the revision once the rebuild actually reflects it —
// a resolver miss must not silently swallow a future revision bump.
if (allRowsResolved) _lastRevision = revision;
}
}