Fast Guild Invite - Revived

A WoW Classic AddOn to help recruit new members into your guild.

File Details

FastGuildInvite-v2.3.2

  • R
  • May 28, 2026
  • 5.74 MB
  • 614
  • 12.0.1+6
  • Retail + 2

File Name

FastGuildInvite-FastGuildInvite-v2.3.2.zip

Supported Versions

  • 12.0.1
  • 12.0.0
  • 11.2.7
  • 4.4.0
  • 3.4.3
  • 2.5.5
  • 1.15.8

<FGI> FastGuildInvite

[v2.3.2] (2026-05-28) — Retail empty-conversation-tab + chunk-2 whisper-echo leak fixed at the framework chokepoint: pre-bail at FloatingChatFrameManager's OnEvent so FCF_OpenTemporaryWindow is never called for FGI's own outgoing-whisper echoes

Background — the v2.1.x / v2.2.x sweep was downstream of the actual creation point

On retail with whisperMode = "popout" (or "popout_and_inline"), every CHAT_MSG_WHISPER_INFORM event arrives at two separate places in Blizzard's chat framework:

  1. FloatingChatFrameManager's OnEvent script (Blizzard_ChatFrameBase/Mainline/FloatingChatFrame.lua:2484-2519). This runs first. If no dedicated chat frame exists for the target (FCFManager_GetNumDedicatedFrames(chatGroup, chatTarget) == 0), it calls FCF_OpenTemporaryWindow(chatGroup, chatTarget) synchronously to create the popout tab, then manually re-fires the event onto that new frame.
  2. Each chat frame's own OnEvent — runs through ChatFrameUtil.ProcessMessageEventFilters, which is where FGI's fn.hideWhisper lives and discards the chat-line text.

The ordering is fatal for FGI's outgoing-echo suppression: the tab gets created before our filter ever runs. The text gets discarded by hideWhisper; the tab does not. That's the empty-conversation-tab bug, exactly the field-reported "FGI's whispers leave empty tabs behind."

The v2.1.x / v2.2.x family of fixes treated this reactively. fn.suppressConversationTabFor scheduled four C_Timer.After(0/0.1/0.5/...) sweeps of CHAT_FRAMES looking for frame.chatType == "WHISPER" and frame.chatTarget == ourTarget, then frame:Hide() (NOT FCF_Close — that tainted Blizzard's chat history on retail; see v2.2.0 notes). The sweep worked but ran AFTER tab creation, so even at 0s the user saw a ~1 render-frame flash before the tab was hidden. Worse, under rapid-fire invite cadence with long messages, ChatThrottleLib queues chunk 2 of a multi-chunk whisper for late dispatch; by the time chunk 2's echo arrives, our text-match list may have been pruned or the counter window expired, and the chunk leaks through the filter entirely — text into chat, tab created and not hidden.

Fix — replace FloatingChatFrameManager:GetScript("OnEvent") on retail with a pre-bailing wrapper

Per the XML at FloatingChatFrame.xml:700, FloatingChatFrameManager is documented as the "single point of entry for events which must fire once and only once." That's the chokepoint. v2.3.2 saves the original OnEvent script at addon-load time and replaces it with a wrapper that:

  • Only intercepts CHAT_MSG_WHISPER_INFORM and CHAT_MSG_BN_WHISPER_INFORM. Every other CHAT_MSG_* event the manager registered for (channel chat, system messages, etc.) passes through to the original unchanged.
  • Only intercepts WHISPER_INFORMs for FGI-active targets. Derives the key via fn:fullPlayerName(arg2), then gates on addon._whisperExpectedEchoes[key] > 0 (we're still expecting an echo from a chunk we sent) AND GetTime() - addon._whisperSentRecently[key] < 30s (staleness cutoff — if the counter has drifted from a long-ago send, bail safely rather than permanently swallow this target's incoming tabs).
  • Both gates true → swallow the event entirely. The original OnEvent never runs. FCFManager_GetNumDedicatedFrames is never consulted. FCF_OpenTemporaryWindow is never called. The tab literally never gets created. hideWhisper is wired into each chat frame's own event handling (not through the manager), so it still runs and still suppresses the chat-line text — the wrapper only prevents the tab-creation side effect.
  • All other paths pass through. Non-FGI whispers, the user's own manual /w to someone FGI never touched, recruit replies (which fire CHAT_MSG_WHISPER, not _INFORM, but the manager handles those too), the case where DB.realm.sendMSG is off — every one of these hits the return orig(self, event, ...) at the bottom of the wrapper, identical behavior to vanilla Blizzard.
mgr:SetScript("OnEvent", function(self, event, ...)
    if event == "CHAT_MSG_WHISPER_INFORM" or event == "CHAT_MSG_BN_WHISPER_INFORM" then
        if DB and DB.realm and DB.realm.sendMSG then
            local target = select(2, ...)
            if type(target) == "string" then
                local ok, key = pcall(fn.fullPlayerName, fn, target)
                if ok and key then
                    local expected   = addon._whisperExpectedEchoes[key] or 0
                    local sentAt     = addon._whisperSentRecently[key]
                    local recentSend = type(sentAt) == "number" and (GetTime() - sentAt) < 30.0
                    if expected > 0 and recentSend then
                        return  -- swallow; tab never gets created
                    end
                end
            end
        end
    end
    return orig(self, event, ...)
end)

Why this beats the v2.2.x sweep

  • Zero render-frame flash. The tab never gets created, so there's nothing to hide. The previous sweep had a ~1 frame visible flash before frame:Hide() could catch it; v2.3.2 has none.
  • Multi-chunk whispers covered automatically. The gate is _whisperExpectedEchoes[key] > 0. Every chunk sent via fn:sendWhisper increments this counter via fn.refreshWhisperHideDeadline; every chunk's echo arriving decrements via fn.hideWhisper. As long as we have unaccounted-for chunk echoes outstanding, the wrapper suppresses tab creation — regardless of how long ChatThrottleLib delayed chunk 2's actual send. The "chunk 2 leaks under rapid fire" symptom from v2.2.4 is structurally fixed here, not just papered over with a wider timing window.
  • No protected APIs called from inside the wrapper. The v2.2.0 attempt at the same architecture tainted Blizzard's chat history globals (MONSTER_SAY secret-string crashes during dungeon fights) because it called FCF_Close from inside hooksecurefunc. v2.3.2 doesn't call ANY protected API in the wrapper — it just returns early. No re-entry into secure code from insecure context, no taint cascade.

Defense-in-depth — the existing sweep is kept on retail

fn.suppressConversationTabFor (the 4-sweep) is not removed on retail in v2.3.2. The pre-bail is now the primary suppression mechanism, but the sweep stays as a fallback for the case where:

  • Another addon replaces FloatingChatFrameManager:SetScript("OnEvent") without preserving our wrapper (load-order race or hostile-overwrite scenarios)
  • A future Blizzard patch reshuffles the popout-creation flow such that the manager's OnEvent isn't the chokepoint anymore

In the steady-state case where the pre-bail does its job, the sweep is a no-op (the frame the sweep looks for was never created). It's belt-and-suspenders cost: a few C_Timer.After allocations per suppressed echo on a hot recruitment path. Negligible. Comment block on fn.suppressConversationTabFor updated to label it as defense-in-depth.

Classic-family unchanged

Classic Era / TBC / Wrath / Cata / Anniversary stay on the existing hooksecurefunc FCF_OpenTemporaryWindow + frame:Hide() path. That hook works correctly on those clients (the synchronous-with-creation timing catches the popout exactly when it's made; no bobbing as long as FCF_Close is avoided). The whisperMode popout behavior the empty-tab bug surfaces from is mostly a retail story; replacing classic's working path with the OnEvent intercept buys nothing and risks regression. installFloatingChatFrameManagerHook is retail-gated on gv.isRetail; the classic-family hook is gated on not gv.isRetail. The dispatcher in fn.installConversationTabSuppressionHook routes the install based on which client is loaded.

Files modified

  • functions.lua — new module-local installFloatingChatFrameManagerHook, _fcfMgrHookInstalled idempotency flag, fn.installConversationTabSuppressionHook rewritten as a retail-vs-classic dispatcher. Comment block on fn.suppressConversationTabFor updated to mark it as defense-in-depth on retail. Call site in FGI_Core.lua is unchanged — the same fn.installConversationTabSuppressionHook() call routes to the appropriate platform path internally.

[v2.3.1] (2026-05-27) — Retail taint fixes consulted against Blizzard's API docs: SetRaidTarget restored alongside FGI unit-frame submenu, profession-tooltip MoneyFrame crash root-caused to tip:Show() re-entering the secure layout from an insecure stack

Background — v2.2.4 reasoned without docs and got two retail bugs wrong

Two retail-only taint bugs were "fixed" in v2.2.4 by reasoning from the stack trace + analogy instead of consulting Blizzard's own implementation guides. Both fixes worked in the narrow "the immediate symptom stopped" sense but were architecturally wrong:

  1. SetRaidTarget taint on retail party/raid frames. v2.2.4 diagnosed this as "any Menu.ModifyMenu modification to a unit-frame menu inherently taints sibling element handlers" and excluded every unit-frame tag (MENU_UNIT_PARTY, MENU_UNIT_RAID_PLAYER, MENU_UNIT_TARGET, MENU_UNIT_FOCUS, etc.) from FGI's chat-menu integration on retail. SetRaidTarget started working again, but at the cost of removing the FGI submenu from right-clicking a portrait / party frame / raid frame on retail — a feature the addon explicitly advertised.
  2. Profession-tooltip MoneyFrame ADDON_ACTION_BLOCKED. v2.2.4's FGI_UnitTooltip path had a tip:Show() call inside the TooltipDataProcessor.AddTooltipPostCall callback that registers against Enum.TooltipDataType.Unit. After hovering a guildie, hovering a tradeskill recipe in the trade-skill window threw Interface\FrameXML\MoneyFrame.lua:155 attempt to perform arithmetic on a nil value followed by ADDON_ACTION_BLOCKED: AddOn 'FastGuildInvite' tried to call the protected function 'MoneyFrame_SetType()'. The narrow workaround that almost shipped was "don't hover guildies before opening trade-skills" or "register against fewer tooltip types" — both leaving the underlying re-entrancy alone.

Both bugs got the wrong root cause because the Blizzard API docs at F:\Blizzard API Docs were not consulted. The docs explicitly document the taint-safe pattern for both subsystems. v2.3.1's fixes are derived from those docs and a new Claude memory file (feedback_consult_blizzard_api_docs.md) now requires future taint / secure-API / framework bugs to grep F:\Blizzard API Docs first.

Fix 1: SetRaidTarget taint (Modules/FGI_ChatMenu.lua) — defer secret-value reads to OnClick

F:\Blizzard API Docs\Blizzard_Menu\11_0_0_MenuImplementationGuide.lua's *** Taint *** section (lines 416-419) states: "The menu system was designed with consideration to better support addon customization without taint consequences. Addons should always be able to insert elements at any position in a menu without imparting taint to any of the surrounding element handlers." The menu system is taint-safe for addon insertions by design — but only as long as the addon's generator callback doesn't itself touch a secret value, because doing so taints the generator's execution and that taint leaks to sibling handlers (like SetRaidTarget's protected click callback) that run after the menu is built.

FGI's v2.2.4 generator was calling nameFromContextData(contextData) inside Menu.ModifyMenu(tag, function(_, rootDescription, contextData) ... end). That function read contextData.unit and called UnitName() on it. On retail's unit-frame tags, contextData.unit is sometimes a Blizzard secret value (cross-realm tokens, instanced creature tokens, certain protected unit tokens). Reading the field + the UnitName() call at menu-build time tainted the generator's execution and that taint propagated to SetRaidTarget when the user later picked one of the raid-marker children.

v2.3.1 changes the menu-build flow so secret-value access is deferred to AFTER the menu has finished building:

  • MENU_TAGS restored to the full 10-tag list (Modules/FGI_ChatMenu.lua). MENU_UNIT_PLAYER, MENU_UNIT_FRIEND, MENU_UNIT_PARTY, MENU_UNIT_RAID_PLAYER, MENU_UNIT_RAID, MENU_UNIT_GUILD_MEMBER, MENU_UNIT_ENEMY_PLAYER, MENU_UNIT_TARGET, MENU_UNIT_FOCUS, MENU_UNIT_CHAT_ROSTER — every tag is re-registered on every client. The retail-only "exclude unit-frame tags" split is removed.
  • Menu.ModifyMenu generator callback no longer touches contextData. It captures the table by closure (buildFGIChildren(fgiSub, contextData)) and lets the click handlers resolve the name themselves. The generator's only job now is local fgiSub = rootDescription:CreateButton("FGI"); buildFGIChildren(fgiSub, contextData) — both of which are documented-safe insertions.
  • buildFGIChildren(parentDesc, contextData) — signature changed from (parentDesc, name). Each of the three children (FGI - Guild Invite, FGI - Blacklist, FGI - Unblacklist) calls local name = nameFromContextData(contextData) inside its own OnClick callback, AFTER the menu's secure-construction phase has finished. The menu is fully built and committed at that point; any taint the read might introduce is contained to FGI's own click execution and doesn't propagate.
  • nameFromContextData body wrapped in pcall — so a secret-value throw during the read just yields nil (silent click no-op) instead of the error tearing the click stack down. Defense-in-depth on top of the timing change.
  • attachMenuTooltip calls switched to generic text — the player name isn't known at menu-build time (because it's no longer resolved until click), so the tooltip bodies now say "this player" instead of interpolating the name. Same tooltip content, just generic phrasing.

Result: right-clicking a unit frame on retail now opens the standard Blizzard menu with the FGI submenu attached, and SetRaidTarget (set raid marker on the unit) works from the same menu. Both features coexist, which is the design Blizzard documents.

Fix 2: Profession-tooltip MoneyFrame crash (Modules/FGI_UnitTooltip.lua) — don't re-enter the secure layout from inside AddTooltipPostCall

F:\Blizzard API Docs\Blizzard_SharedXMLGame\Tooltip\TooltipDataHandler.lua:67 shows that Blizzard wraps every addon-registered AddTooltipPostCall invocation in securecallfunction:

securecallfunction(processor, self, tooltipData, ...);

This is the framework's explicit taint-containment boundary. The intent is documented elsewhere in the file: addon post-call handlers can add lines, change colors, etc. without their taint leaking to other tooltip events, because securecallfunction quarantines any taint introduced inside processor to that one call. The pipeline that Blizzard's own code uses (AddLine → queued relayout via TooltipDataMixin) stays inside the secure boundary.

FGI's v2.2.x post-call was calling tip:Show() at the end of enrich(tip). That call breaches the secure boundary: Show() on the shared GameTooltip doesn't go through the queued-relayout path; it triggers an immediate GameTooltip_OnShow / layout pass that runs in the insecure (addon-tainted) execution context outside the securecallfunction quarantine. The taint sticks to the shared GameTooltip frame and surfaces on the very next tooltip the player hovers — including ones FGI's post-call doesn't even fire on, because retail's GameTooltip is a shared frame across tooltip types. Hover a guildie (FGI post-call fires, tip:Show() taints the frame), then hover a tradeskill recipe (different tooltip type, FGI doesn't fire, but the frame is still tainted from the previous hover) — Blizzard's MoneyFrame_SetType reads the tainted frame state and errors out with the ADDON_ACTION_BLOCKED.

AddLine on the modern path already queues a relayout via Blizzard's pipeline, so the explicit tip:Show() is redundant on retail's TooltipDataProcessor flow. On legacy clients (Classic Era 1.15.x / TBC / Wrath / Cata), the older OnTooltipSetUnit hook script does need the explicit Show() because the legacy tooltip system doesn't auto-relayout after AddLine.

v2.3.1 gates the call:

if not gv.isRetail then
    tip:Show()
end

Result: hovering a guildie on retail still appends the FGI member-history lines (AddLine's queued relayout handles the visual update). Subsequent hovers on tradeskill recipes, mail items, professions UI, etc. no longer throw MoneyFrame ADDON_ACTION_BLOCKED. Classic-family behaviour is unchanged (the explicit Show() still fires on those clients because their tooltip pipeline genuinely needs it).

New behavioral memory: consult F:\Blizzard API Docs before designing taint / secure-API fixes

memory/feedback_consult_blizzard_api_docs.md (indexed in memory/MEMORY.md) instructs Claude to grep the Blizzard API/source docs before committing to a fix for any bug whose stack ends in Blizzard framework code with FGI named in a taint message. The memory specifically cites the v2.2.4 SetRaidTarget and v2.2.4 scanInterval.min as a function mistakes — both were directly answered by the docs and would not have shipped as blunt-instrument workarounds if the docs had been consulted. Going forward, "the menu system / tooltip framework just doesn't isolate addons" intuitions get treated as hypotheses to verify against the docs first, not facts.

[v2.3.0] (2026-05-27) — Legacy scan window (single-page v1.9.10-style UI, DB.global.useLegacyUI toggle)

New file: GUI/LegacyMainWindow.lua

Standalone recreation of the v1.9.10 single-page scan UI. Exposes addon.LegacyMainWindow (local alias LMW) with Open(), Close(), Toggle(), refresh, and setScanCooldown entry points. Stored in interface.legacyMainFrame — never shares interface.mainFrame or DB.global.mainFrame.

Key implementation notes:

  • Container: GUI:Create("ClearFrame"), geometry persisted to DB.global.legacyMainFrame ({point, relativePoint, xOfs, yOfs, height})
  • Invite-type dropdown: raw UIDropDownMenuTemplate frame "FGILegacyInviteTypeDrop" + UIDropDownMenu_Initialize / UIDropDownMenu_SetSelectedID / UIDropDownMenu_SetText — mirrors Modules/Scan.lua's modeDD pattern
  • Enable-filters: standard AceGUI CheckBox with :SetCallback("OnValueChanged", ...)
  • Progress bar: raw CreateFrame + CreateTexture proxy table with SetProgress / SetWidth / SetPoint / ClearAllPoints — AceGUI's ProgressBar is a standalone FULLSCREEN_DIALOG popup and cannot be embedded
  • Level-range spinners: raw CreateFrame via makeLvlSpinner()
  • Candidate list: 20-row scrollable list (refreshRows() / relayoutList()) with scroll bar; row count recalculated on vertical resize
  • Confirm-clear: lazy-created UIDropDownMenuTemplate via ensureClearDropdown()

Wire-up in existing files

  • functions.lua: LegacyMainWindow.refresh / setScanCooldown fan-out added at four sites: mt.__call (searchInfo metamethod), onListUpdate(), and both the clear branch and the C_Timer.NewTicker callback inside startScanCooldown
  • GUI/MainWindow.lua: compactModeIcon:SetScript("OnClick") checks DB.global.useLegacyUI; if true, opens the legacy window and closes the main window instead of toggling the compact tray
  • GUI/SettingsPanel.lua: useLegacyUI AceConfig toggle added to Appearance section (order=17), backed by DB.global.useLegacyUI
  • FGI_Core.lua: elseif str == "legacy" branch in Console:FGIInput dispatches to addon.LegacyMainWindow.Open()
  • All 5 TOCs: GUI\LegacyMainWindow.lua added after GUI\MainWindow.lua

[v2.2.5] (2026-05-27) — HOTFIX: v2.2.4 settings panel crash (scanInterval.min as a function), scan-interval minimum standardized to 8s across all versions

Critical: v2.2.4 made the entire FGI settings panel fail to open

v2.2.4 made the scanInterval range widget's min a function so it could return a per-version floor (8 on retail, 5 on classic). AceConfig's range widget requires min / max / step to be literal numbersAceConfigRegistry:ValidateOptionsTable rejects a function value with expected a number, got 'function: ...', and that validation failure aborts the render of the ENTIRE options table. The result: opening FGI's settings (via the compact tray gear, the minimap right-click, or ESC → Options → AddOns → FastGuildInvite) threw a Lua error and the panel never displayed. Field-reported immediately after the v2.2.4 push.

(desc, get, set, disabled, hidden, confirm, name, order CAN be functions in AceConfig. min / max / step on a range cannot — they're read once at validation time as numbers.)

Fix + simplification: 8-second scan interval minimum across all game versions

Rather than reintroduce per-version complexity (which would've needed two hidden-gated widgets to give retail a real 8-minimum slider track vs. classic's 5), v2.2.5 standardizes the floor at 8 seconds on every client:

  • fn.getMinScanInterval() (functions.lua) now returns 8 unconditionally (was gv.isRetail and 8 or 5). Kept as a function rather than inlined so a future per-version tweak stays a one-line change.
  • The scanInterval slider's min (GUI/SettingsPanel.lua) is a literal 8 again — the panel-crash fix. The slider track bottoms out at 8 on every client; get / set still clamp through fn.setScanInterval as belt-and-suspenders. desc reverted to a plain (non-function) string.
  • DB.global.scanInterval default bumped from 5 to 8 (FGI_Core.lua) so a fresh install doesn't start with a value below the slider's own minimum.
  • Existing users with 5 saved are auto-migrated to 8 on first login: the PLAYER_LOGIN hook calls fn.setScanInterval(DB.global.scanInterval), which clamps to the 8 floor and writes the clamped value back.

The 8-second floor is empirical (see v2.2.4 notes — 5s on retail reliably triggered the server /who rate-limit cascade behind the stuck-scan / timer-flicker / whisper-leak / decline-disappear symptoms). Applying it to classic-family too is conservative but doesn't hurt classic recruiting in practice, and the single consistent number is simpler to reason about than a version branch.

Dev-process note (not addon behaviour)

CLAUDE.md updated: the wow-version-replication.ps1 sync watcher should be started once at session start and left running (not stopped after each one-shot sync). Stopping it after every sync was the old habit and it let _retail_ go stale mid-session — testing a fix against stale retail code looks identical to "the fix didn't work," which has burned debugging time. The rule now also says to check for an already-running FGI-specific watcher first (don't double up) and that a watcher running for a different addon doesn't count.

[v2.2.4] (2026-05-26) — Retail scan-interval floor (root-cause fix for the stuck-scan / "timer stops at 4" / whisper-echo-leak / decline-disappear cascade), SetRaidTarget taint fix, account-wide template scope toggle, F5/F6 routed through visible buttons (v1.9.10 pattern), duplicate-whisper bug fix, whisperDelay removed, settings polish

The single root-cause fix that resolved four reported bugs

Field reports across v2.2.2 / v2.2.3 surfaced what looked like four independent problems on retail:

  1. Stuck scan — pressing F6 repeatedly returned the same 2-3 players from the same /who over and over; progress never advanced. Often coincided with /who results dumping into chat as yellow text instead of being silently consumed by the addon.
  2. "Timer stops at 4 and disappears" — the >> button's countdown counter would visibly tick down 5, 4, then jump to >> and become clickable again before reaching 0. Sometimes flickered between the number and >> 2-3 times before settling.
  3. Whisper echo leaks under rapid fire — for users with long recruitment messages that messageSplit chunked into multiple parts, the second chunk sometimes leaked into chat under rapid invite cadence, even with "Hide outgoing whisper echoes" enabled. Also sometimes left blank conversation tabs behind.
  4. Decline messages stop appearing — after some unknown amount of invites, X declines your guild invitation system messages stopped arriving in chat AND the decline counter stopped incrementing. Wait some time, both come back.

The investigation expanded the LibWho fork (Libs/LibWho/LibWho.lua + Libs/LibWho/LibWho_Retail.lua) with progressively more defensive plumbing — a Option A defensive isAddon=false/SetWhoToUi(false) reset at the top of every GetWho, an Option B watchdog timer that catches the "WHO_LIST_UPDATE never fires" case, manual result-buffer fetching in the watchdog to recover players from the suppressed-event case, removal of stale timeCallbackEnd schedules that were killing fresh cooldown tickers from later presses, removal of double-registration of WHO_LIST_UPDATE on retail, etc. (MINOR bumped 6→11 across this work to force /reload to actually pick up each iteration.)

None of those library-layer fixes were the root cause. They were downstream recovery patches. The real cause turned out to be the user-facing setting allowing too short a scan interval on retail.

DB.global.scanInterval defaulted to 5 seconds and the Settings slider's min was 5 — same as classic-family. But retail's server-side /who rate limit is tighter than classic's. At 5-second cadence on retail, the server starts returning cached identical responses (because we're firing faster than it wants to actually process the query), and WoW's client-side handling silently suppresses WHO_LIST_UPDATE when the result list is byte-identical to the previous response (the chat-render layer still emits the yellow result lines, but no event dispatches to addons). That cascade is the actual mechanism behind all four reported symptoms — every one of them is a downstream effect of "the addon's scan engine doesn't know the /who completed."

Fix shipped in v2.2.4:

  • New helper fn.getMinScanInterval() at functions.lua returns the per-version floor: 8 on retail, 5 on classic-family. The 8s value is empirical — user testing on retail with scanInterval = 8 shows the bug cascade does not reproduce; 5 reliably did.
  • fn.setScanInterval(n) clamps n up to the floor before applying, and writes the clamped value back to DB.global.scanInterval so the safe value persists.
  • The Settings slider's min, get, and desc are now version-aware (GUI/SettingsPanel.lua). On retail the slider can't go below 8; the description explains why.
  • The PLAYER_LOGIN hook already calls fn.setScanInterval(DB.global.scanInterval) at addon-init. Existing retail users with 5 saved are auto-migrated to 8 on first login after this patch — zero manual intervention required.

The Blizzard API docs (F:\Blizzard API Docs\Blizzard_APIDocumentationGenerated\FriendListDocumentation.lua) flag SendWho as HasRestrictions = true but don't expose the actual rate-limit value. The 8s floor is an empirical choice, not a documented constant.

LibWho fork hardening (still useful as belt-and-suspenders behind the root-cause fix)

The defensive plumbing added during the investigation isn't reverted — it provides resilience if any future change pushes us back near the throttle boundary, or if Blizzard tightens the limit further. Summary of what landed in Libs/LibWho/LibWho.lua (MINOR=11) and Libs/LibWho/LibWho_Retail.lua:

  • Defensive isAddon / SetWhoToUi cleanup at the top of every GetWho — if a previous query never completed (no WHO_LIST_UPDATE arrived: server throttle, FriendsFrame race, another addon ate the event), the new query forcibly clears the stale state before sending. Prevents results leaking to chat as yellow text from a previous in-flight query.
  • 3-second watchdog timer per GetWho — if WHO_LIST_UPDATE doesn't land within 3 seconds, the watchdog fires libWho.callback(whoQuery, results) with whatever C_FriendList.GetNumWhoResults / GetWhoInfo returns from the result buffer. Scan progress advances even when the event doesn't dispatch, and any players in the buffered response still get processed.
  • Multiple in-flight watchdogs are safe — removed the Cancel() of the previous timer on new GetWho. Each watchdog independently no-ops via the if not libWho.isAddon then return end gate once an earlier watchdog (or the real event) has cleared the flag.
  • Watchdog no longer schedules timeCallbackEnd — the deferred C_Timer.After(interval, timeCallbackEnd) from the watchdog was outliving the scan that scheduled it; it could fire mid-countdown of a fresh ticker from a later press, calling fn.startScanCooldown(0) and killing that ticker → "stops at 4" symptom.
  • timeCallbackEnd no longer touches the cooldown ticker at functions.lua. The same stale-clobber pattern applied in the healthy path: C_Timer.After(interval, timeCallbackEnd) from parseResults could fire well after the user had moved on. timeCallbackEnd keeps its other job (firing the "search unlocked" notification when DB.global.searchAlertNotify is true) but stops touching cooldown UI. The ticker started by fn.startScanCooldown(N) naturally counts down to 0 and self-clears at the last tick.
  • Classic-family whoFrame skips WHO_LIST_UPDATE registration on retail at Libs/LibWho/LibWho.lua. On retail only retailWhoFrame (from LibWho_Retail.lua) processes the event; on classic-family only whoFrame registers. Removed a double-dispatch race that was wasting cycles and producing confusing trace data during diagnosis.

Unified cooldown driver (fn.startScanCooldown)

Pre-v2.2.4, three independent code paths owned cooldown UI:

  • Compact tray's >> OnClick called cf.setScanCooldown(N) directly — only updated the compact view.
  • Scan tab's >> OnClick called ScanTab.SetCooldown(N) directly — only updated the main window view.
  • LibWho's timeCallbackStart called setCompactCooldown(N) AND setMainScanCooldown(N) via the per-1-second ticker — synced both views, but only fired when WHO_LIST_UPDATE actually dispatched.

Failure modes:

  • Click compact >> → only compact button shows the countdown until WHO_LIST_UPDATE arrived and timeCallbackStart synced the main view (often half a second to a few seconds of asymmetry).
  • If WHO_LIST_UPDATE was suppressed (the root cause above), the optimistic cooldown set at click time stayed frozen on whichever view's button was clicked, while the other view's button stayed at >>.
  • The safety C_Timer.After(interval+5, clear) in each OnClick was the only escape hatch when LibWho's ticker never started, but it only cleared the view its OnClick had set.

v2.2.4 consolidates these into one helper at functions.lua:

function fn.startScanCooldown(seconds)
    -- Cancel any in-flight ticker, set BOTH views' initial value,
    -- start a NewTicker(1, ..., seconds) that decrements both views in
    -- lockstep and self-clears at 0.
end

Both view OnClicks and LibWho's timeCallbackStart now route through this. A click immediately starts the countdown on BOTH views (no asymmetry). If timeCallbackStart lands later it re-syncs to the authoritative libWho:GetInterval() value. The safety C_Timer.After in OnClicks remains as a final no-op fallback (routed through fn.startScanCooldown(0)).

Duplicate-whisper bug — dead Type-2 sendWhisper-on-invite-confirm branch removed

Field report: a user in Whisper + invite mode reported that some recipients received the recruitment whisper twice (or 4 times for a 2-chunk message), even after a single invite cycle. Repro was sporadic — "not sure how I replicated this."

Cause traced to a vestigial code path at Modules/Scan.lua type == "invite" handler. The block dated back to the addon's original "invite first, message on confirm" Type 2 semantic where addon.msgQueue tracked the pending whisper to send when the server confirmed delivery. The current Type 2 path sends the whisper at invite-send time in fn:invitePlayer (in functions.lua), making the type == "invite" post-confirm whisper logic dead code in pure Type 2 flow. But it still fired a SECOND whisper whenever msgQueue happened to hold a stale entry from a prior Type 4 session — exactly the "I don't know how I reproduced this" pattern (mid-session mode switch from Type 4 → Type 2 + re-invite of someone who had been queued under Type 4).

Removed the entire Type 2 branch from the type == "invite" handler. addon.msgQueue is now exclusively a Type 4 "whisper on decline" queue. The auto_decline table that only existed to gate this dead path is also removed.

Companion fix: msgQueue accept-leak. Type 4 puts the name in msgQueue at invite-send time so a later decline can trigger the follow-up whisper. If the player ACCEPTS instead, no decline event fires and the entry leaked until /reload. The accept handlers at functions.lua (Classic CHAT_MSG_SYSTEM path + Retail GUILD_ROSTER_UPDATE diff) now clear msgQueue[name] / msgQueue[fullName] / msgQueue[normalizedName] so all three possible key shapes are caught. Bounded by single invite cycle, not session length.

SetRaidTarget taint fix — M+ raid markers no longer break

Field report: in an M+ run, the user right-clicked their tank to place a raid marker. WoW raised ADDON_ACTION_FORBIDDEN: AddOn 'FastGuildInvite' tried to call the protected function 'SetRaidTarget()'. The marker never placed and the rest of the unit popup was tainted for the session.

Stack trace pointed at Blizzard_UnitPopupShared/UnitPopupSharedButtonMixins.lua:2422SetRaidTargetIconSetRaidTarget. None of those are FGI code, so the taint had to be propagating from somewhere FGI touches the unit popup tree.

Modules/FGI_ChatMenu.lua uses Menu.ModifyMenu to inject an "FGI >" submenu into Blizzard's right-click context menus across 10 tags including MENU_UNIT_TARGET (which hosts the Set Marker submenu). Same taint family that bit us in v2.1.8 for MENU_UNIT_COMMUNITIES_* / SetGuildRankOrder — modifying a menu via Menu.ModifyMenu taints the entire menu's secure-call subtree, and any protected sibling submenu (SetRaidTarget, SetGuildRankOrder, etc.) inherits the taint.

Fix: retail-only menu-tag exclusion at Modules/FGI_ChatMenu.lua. The full 10-tag list stays for classic / TBC / Wrath / Cata / MoP (those clients use the legacy UnitPopupButtons API where taint doesn't propagate across menu siblings — the same code that's been there works fine). On retail, only three "safe" tags are modified:

  • MENU_UNIT_CHAT_ROSTER (chat right-click — the primary use case)
  • MENU_UNIT_FRIEND (friends list)
  • MENU_UNIT_GUILD_MEMBER (classic guild panel; the Communities panel was already excluded since v2.1.8)

Retail users lose FGI's "FGI >" submenu on unit-frame right-clicks (target, party, raid, focus, generic player popup, enemy player). FGI stays available via chat right-click, friends list, and the classic guild panel. Same shape of trade-off accepted in v2.1.8 for the Communities exclusion.

Audit of the rest of the codebase confirmed no other vectors of this family: hooksecurefunc("FCF_OpenTemporaryWindow", ...) is gated if gv.isRetail then return end (v2.2.0 fix), hooksecurefunc("SetItemRef", ...) is read-only observation that routes to FGI's own dropdown without touching Blizzard menus, ChatThrottleLib's library-level hooks are well-tested upstream.

whisperDelay setting and code path removed

v2.1.5 added a "Delay after whisper (seconds)" slider (0-10s, default 0) for Type 2 (Whisper + invite) mode — the idea was to give the candidate a moment to read the whisper before the invite popup appears.

Implementation wrapped the post-whisper invite in C_Timer.After(delay, doInvite) when whisperDelay > 0. That broke retail's C_GuildInfo.Invite for any user who set the delay above 0: by the time the C_Timer.After callback fires, WoW has already lost the hardware-event context that the invite needed, and retail raises ADDON_ACTION_BLOCKED on the protected call. The bug never surfaced for the default value of 0 (synchronous path), but any user who tuned the delay even slightly above 0 hit it.

Hardware-event gating is non-bypassable in WoW retail — no async wrapper preserves it. The whole feature was ripped out in v2.2.4 across functions.lua, GUI/SettingsPanel.lua, and FGI_Core.lua DB defaults. Type 2 now sends whisper + invite synchronously, back-to-back, on every client. The stale DB.global.whisperDelay field for users who had it set is harmless — nothing reads it anymore.

F5/F6 routed through visible buttons (v1.9.10 pattern)

v2.2.x introduced orphan hidden buttons (FGI_BindBtn_Invite, FGI_BindBtn_NextSearch, FGI_BindBtn_Announce) as the SetBindingClick targets — a clean architectural split between "where keybinds dispatch" (hidden orphan) and "where the visible button lives" (the >> / +(N) buttons on the compact tray and Scan tab). The orphan buttons called fn:nextSearch() / fn:invitePlayer() directly.

The split bypassed a critical UX gate: the visible >> button's OnClick has if cf.scanCooldown then return end. The keybind, going through the orphan, didn't see that gate. F6 mash during the cooldown window fired fn:nextSearch() repeatedly, queuing duplicate /who requests at the server faster than the rate limit would tolerate — the same cascade that motivated the 8-second floor.

v1.9.10 had the right architecture for this: SetBindingClick(key, interface.mainFrame.mainButtonsGRP.pausePlay.frame:GetName()). F6 dispatched directly to the visible button, the button's OnClick ran the gate, mashing during cooldown was silently swallowed. One button, one OnClick, one gate.

v2.2.4 reverts to v1.9.10's pattern at FGI_Core.lua applyKeybinds:

  • F5 / kb.inviteFGI_CompactInviteBtn (the visible compact invite button — already named since v2.1.8 specifically for this).
  • F6 / kb.nextSearchFGI_CompactScanBtn (the visible compact scan button — likewise).
  • Announce stays on FGI_BindBtn_Announce (no visible named announce button exists; orphan retained for this one).
  • Stale-binding cleanup matcher broadened to catch both CLICK FGI_BindBtn_* and CLICK FGI_Compact* so the v2.2.3 → v2.2.4 transition cleans up the now-stale orphan bindings on first /reload.
  • /fgi kbstate updated to report the new expected targets per key.

The compact frame is created at addon load and Show/Hide'd (never Release()d), so FGI_CompactScanBtn / FGI_CompactInviteBtn are stable name targets for the binding system — same persistence property v1.9.10 relied on with pausePlay.frame. Regular Button (not SecureActionButtonTemplate) means Click() from the binding system still dispatches the OnClick even when the compact frame is hidden (e.g., user is in main-window mode); the v2.2.3 lesson about :Hide() blocking Click() was specific to secure buttons.

Message template scope toggle (account-wide option)

Field request: a user maintaining recruitment templates across multiple characters on the same realm/faction wanted to edit the templates once and have them apply to every character, not per-faction-per-realm.

New toggle "Share templates across all characters" added to the Messages tab at GUI/SettingsPanel.lua. Default off (preserves legacy per-faction-per-realm storage at DB.factionrealm.messageList / curMessage). When on, storage switches to DB.global.messageList / curMessage (account-wide for this game version's SavedVariables).

Implementation:

  • New DB.global.messageScopeGlobal (boolean, default false), DB.global.messageList = {}, DB.global.curMessage = 1 defaults at FGI_Core.lua.
  • Single accessor fn.getMessageStore() at functions.lua returns the scope-appropriate parent table (DB.global or DB.factionrealm). Used by every read/write of messageList / curMessage in the Messages section (~18 sites in SettingsPanel.lua, replace_all migration) and by fn:getRndMsg.
  • One-time copy migration on false→true transition: if the global store is empty, the toggle's set callback copies the user's current factionrealm templates into the global slot so they don't start with no templates. If the global store already has entries (re-toggling after a previous enable), skip — idempotent.
  • Flipping back off doesn't touch either store. Decision is fully reversible; both stores are preserved.

Whisper-alert toggle labels clarified

Field request: the three recruit-whisper alert toggles (recruitAlertSound, recruitAlertScreenFlash, recruitAlertTabFlash) had labels like "Alert sound on recruit whisper" that didn't make it obvious the alert fires on the candidate's INCOMING reply, not on FGI's outgoing recruitment whisper. All three labels at GUI/SettingsPanel.lua now end with "response":

  • "Alert sound on recruit whisper response"
  • "Screen flash on recruit whisper response"
  • "Flash chat tab on recruit whisper response"

Description bodies unchanged (they already said "When a player you've recently whispered with FGI sends you an incoming whisper...").

Settings page polish — keybind and manual-trigger buttons sized to label

Both the Hotkeys keybinding widgets (inviteKey, nextSearchKey, announceKey) and the Manual triggers execute buttons (resetSettings, runVersionCheck, runSync) previously had width = "full", stretching them across the entire settings row. Removed width = "full" on all six — AceConfig now sizes them to their label text, putting the button reasonably close to the words that describe it instead of leaving a huge dead zone between label and button.

Decline-handler cleanup: dead UI_ERROR_MESSAGE branch removed

Modules/Scan.lua's pausePlayFilter registered both CHAT_MSG_SYSTEM and (on retail only) UI_ERROR_MESSAGE. The intent was dual-source coverage for decline detection — UI_ERROR_MESSAGE as the fallback when CHAT_MSG_SYSTEM carried a secret-string-tainted payload that playerHaveInvite couldn't parse.

Audit revealed the UI_ERROR_MESSAGE branch had never functioned. The retail-specific code path called pcall(playerHaveInvite, msg) before msg was assigned from the event's second vararg (local _, text = ...; msg = text). msg was nil at the pcall, playerHaveInvite(nil) raised an error every call, pcall caught it, the handler returned at if not name then return end. Decline detection on retail relied entirely on the CHAT_MSG_SYSTEM path — which proved reliable in field testing once the scan-interval throttle wasn't being triggered.

Worse, the broken UI_ERROR_MESSAGE branch fired for EVERY in-game UI error (Ability is not ready yet, Out of range, Not enough rage, Your target is dead, Can't do that while moving, etc.) and printed a "pcall FAILED (taint), exiting" debug line for each. Pure noise.

v2.2.4 removes the UI_ERROR_MESSAGE registration and the entire retail-specific block in the handler. CHAT_MSG_SYSTEM is now the sole event source on every client; the existing pcall guard at the common parse path catches genuine secret-string payloads. Net code reduction: ~70 lines.

[FGI decline-debug] prints remain in the file but are now gated behind addon.debug (was previously firing unconditionally). Available via /fgi debug if a future decline-related regression needs diagnosis.

Diagnostic prints behind addon.debug

The investigation that produced all the above generated a fair amount of in-line print() instrumentation:

  • [FGI] Scan dispatch (>>/F6) / [FGI] Scan BLOCKED by cooldown at Modules/compactFrame.lua — confirms F6/click reached the OnClick gate and whether the gate let it through.
  • [FGI scan-debug] callback query=X results=N progress=P queueLen=M at functions.lua searchWhoResultCallback — confirms LibWho handed results back to the addon and what the query / result count was.
  • [FGI libwho-debug] WHO_LIST_UPDATE event ... at Libs/LibWho/LibWho.lua + Libs/LibWho/LibWho_Retail.lua — confirms the event reached the handler and what libWho.isAddon / libWho.callback were at entry.
  • [FGI cd-debug] startScanCooldown(N) from <caller> at functions.lua fn.startScanCooldown — every entry to the cooldown driver with the source-file:line of the caller.
  • [FGI decline-debug] CHAT_MSG_SYSTEM ... at Modules/Scan.lua — every decline-shaped CHAT_MSG_SYSTEM message and the parse result.

All gated behind if addon.debug then. Silent in normal play; flip with /fgi debug if a future regression in this area needs to be triaged.

LibWho library version

Libs/LibWho/LibWho.lua MAJOR=FGI-WhoLib, MINOR bumped from 6 (v2.2.3) to 11 across the v2.2.4 work. Each iteration bumped MINOR because LibStub's library-versioning rule means a /reload only picks up the new code if MINOR is higher than the stored version — without the bump, the new code is dead on disk until the user fully logs out. Every edit to LibWho.lua in this release was paired with a MINOR bump for the same reason.

LibWho_Retail.lua is not a separate LibStub library — it GetLibrary("FGI-WhoLib") and patches functions onto the existing table at file load, so it picks up changes on every /reload. No version field on this file.


[v2.2.3] (2026-05-25) — Keybind architecture rewrite (SecureActionButton + SetBindingClick replaces custom OnKeyDown listener), centralized secret-string defense, "Keep open on Esc" toggle dup-buildup fix

Keybind architecture — back to WoW's secure binding system

v2.2.2 / v2.2.2-follow-up fixes for the "hotkeys die after typing in chat" symptom were all variations on the same theme: detect when ChatFrame1EditBox has a stale focus claim and let the keypress through. Each iteration (IsVisible() in v2.2.2, ChatEdit_GetActiveWindow() in v2.2.2-follow-up) chased a smaller window of "stale" state, but field testing kept finding cases that defeated the heuristic.

Root-causing it via web search and the wowpedia docs revealed v2.1.8's architectural choice was the actual problem. v2.1.8 replaced WoW's secure binding system with a custom EnableKeyboard(true) listener frame to dodge bindings.wtf drift, but per the documentation:

  • "if any non-propagating keyboard-enabled frame is visible, no keyboard bindings can be triggered" — our listener inherently competes with every other keybind in the game.
  • SetPropagateKeyboardInput is restricted in combat (patch 10.1.5+), so any state-flip in combat consumes keystrokes and blocks ALL other keybinds (chat +/-, action bar, movement). The user's report of "the - and + chat-tab buttons stopped working" was this manifesting.
  • The chat-focus signal (GetCurrentKeyBoardFocus) is unreliable on retail — stale claims persist past chat-close with no good way to detect "user is actively typing now".

A custom listener is the wrong tool for "F5 = invite" type bindings. The canonical pattern across the addon ecosystem (BindPad, Clicked, KeyUI, etc.) is the secure binding system: SetBindingClick on a SecureActionButtonTemplate Button. WoW's WorldFrame dispatches keystrokes to the bound button, the button's macrotext runs as a hardware event, and the addon never captures keystrokes globally.

v2.2.3 reverts to the secure binding system at FGI_Core.lua:

  • Three hidden SecureActionButton frames (FGI_BindBtn_Invite, FGI_BindBtn_NextSearch, FGI_BindBtn_Announce) with type=macro and macrotext pointing at /fgi invite, /fgi nextSearch, and the new /fgi announce slash command.
  • New fn.applyKeybinds() function reads DB.global.keyBind.{invite,nextSearch,announce} and applies via SetBindingClick, persists via SaveBindings(GetCurrentBindingSet()). Idempotent — clears stale bindings pointing at our buttons first, then re-applies the current desired set.
  • Called from PLAYER_ENTERING_WORLD (after bindings.wtf loads) and from the Settings keybind-widget set callback (every user edit).
  • Combat-protected: applyKeybinds defers to PLAYER_REGEN_ENABLED if called during InCombatLockdown, so combat-time edits via the Settings panel take effect the moment combat ends.

The entire FGI_KeybindListener block — EnableKeyboard(true) frame, OnKeyDown handler, focus-suppression logic, [kbdebug] per-keypress trace prints — is removed. Other keybinds (action bar, chat tab cycling, movement, -/+ chat expand/shrink) now work normally because we no longer capture keystrokes globally.

Gotcha discovered in field test — initial implementation called :Hide() on the secure buttons to keep them invisible, on the assumption that hidden buttons would still receive Click() from the binding system. They don't: with :Hide(), GetBindingAction(key) correctly reports the CLICK FGI_BindBtn_*:LeftButton action but pressing the key produces no result — the binding system silently no-ops on hidden buttons. Fix is the canonical pattern (BindPad, Clicked, KeyUI): keep the buttons technically visible but anchor them off-screen with SetAlpha(0) + SetSize(1, 1). Buttons also need RegisterForClicks("AnyDown", "AnyUp") to explicitly opt in to click dispatch.

The v2.1.7 drift problems that drove us off the secure system in v2.1.8 are mitigated by:

  • Buttons parented to UIParent (always present, never orphan-dropped).
  • fn.applyKeybinds() re-applies on every PLAYER_ENTERING_WORLD + every Settings edit.
  • The v2.2.1 stale-CLICK cleanup (purgeStaleClickBindings) still runs to scrub pre-v2.1.8 CLICK FGI_CompactInviteBtn entries.

/fgi kbstate is updated to report the new architecture: for each configured key, shows what GetBindingAction(key) returns and whether it points at the expected FGI_BindBtn_* button. Combat-lockdown status is also reported when active.

"Keep open on Esc" toggle no longer accumulates UISpecialFrames duplicates

Field report: the "Keep open on Esc" toggle in Settings didn't work — FGI windows stayed open on Esc regardless of toggle state.

Root cause: GUI/MainWindow.lua table.insert(UISpecialFrames, "FGIMainWindowEscProxy") ran unconditionally at file-load time, and fn.updateEscFrames (in functions.lua) only removed the FIRST matching entry per name. Across many /reloads, the table accumulated duplicate entries that updateEscFrames couldn't fully clean — so toggling failed to actually flip the Esc-close behavior on some windows. Also fn.updateEscFrames was never called at OnInitialize, so on every fresh session the saved DB.global.keepOpen value never got applied — only the file-load-time unconditional insert determined behavior.

Three fixes in v2.2.3:

  1. Idempotent insert at GUI/MainWindow.lua — file-load-time table.insert only fires if the name isn't already in UISpecialFrames. New /reload no longer appends a duplicate.
  2. Remove-all-occurrences in functions.lua fn.updateEscFrames — sweeps the entire UISpecialFrames table backwards-removing every occurrence of each FGI frame name before re-inserting once. Cleans up any accumulated dups from past versions.
  3. Call at OnInitializefn.updateEscFrames() runs once at addon-init after DB is wired, syncing UISpecialFrames state with the saved DB.global.keepOpen value. The toggle now persists correctly across /reload.

Also removed the dead FGIMainFrame entry from the name list — that string never matched a real frame (the main window's Esc proxy is FGIMainWindowEscProxy).

Centralized secret-string defense across all chat-event handlers

The retail "secret string" taint has produced multiple field-reported crashes across the addon's lifetime — v2.1.9 / v2.1.11 (UnitIsPlayer in member-history tooltip), v2.1.12 (whisper echo filter), v2.1.14 (auto-welcome whisper), v2.2.0 (FCF_Close taint into HistoryKeeper, MONSTER_YELL/SAY crashes), and now v2.2.3 (LibGuildRoster OnChatMsgSystem crash during dungeon runs). The fixes have all followed the same shape — pcall the throwing string operation — but applied site-by-site instead of via a shared helper, which is why a new crash kept appearing every couple of releases at a fresh chat-event handler.

Web search across the addon ecosystem (SilverDragon, Prat-3.0, ElkBuffBars, XPBarNone, Rarity all hit the same family) confirms there is no Blizzard API to detect whether a value is a secret string. The new "safe" APIs added in Patch 12.0 (RemoveContiguousSpaces, EscapeLuaPatterns, C_ColorUtil, C_DurationUtil, string.format/concat/join callable with secrets) all work on secret strings to produce other secret strings — they don't help with the match / equality / find / lower operations our chat handlers actually need. The pcall-probe pattern remains canonical.

v2.2.3 centralizes the defense as a shared fn.isSafeString helper at functions.lua:

function fn.isSafeString(s)
    if type(s) ~= "string" then return false end
    local ok = pcall(function() return s == "" end)
    return ok
end

Equality with empty string is the cheapest reliable probe — fast in Lua, bounded by chat-event rate, and any throw means the value is opaque to us. Sweep applied at every host-addon chat-event handler that touches the message:

  • Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua lib:OnChatMsgSystem — the originally reported crash site. Uses an inline pcall probe (rather than fn.isSafeString) because it's a vendored library — host-addon helpers shouldn't reach into the lib namespace, and the vendor-fix comment marks this for re-apply on future library updates.
  • functions.lua blackListAutoKick CHAT_MSG_SYSTEM handler — latent crash on retail (msg:match on the locale-fallback path ran without secret-string defense; blackListAutoKick is only fired on guildmaster-track installs, so most users wouldn't have hit it).
  • FGI_Core.lua CHAT_MSG_OFFICER handler — chain of msg:find / msg:match / msg:gsub for officer-chat command parsing. Probably never triggered in practice (officer chat is usually real player text) but defended anyway.
  • Modules/Announce.lua CHAT_MSG_CHANNEL handler — channelBaseName:lower() on a value that can be a secret string for protected-context channels (instanced group / cross-realm broadcasts on retail).

Handlers already retail-bailed or otherwise protected (fn.hideWhisper, fn.hideSysMsg, pausePlayFilter in Modules/Scan.lua, functions.lua:597 CHAT_MSG_SYSTEM guild-join detector) left as-is — they either return early on gv.isRetail or already pcall the relevant ops.

The helper's comment block documents the pattern so future chat-event handlers added to the codebase have a 1-line copy-paste defense at hand:

if not fn.isSafeString(msg) then return end

This isn't a new fix per se — it's the canonical industry-pattern, centralized so the next crash report doesn't surface a missed site.

Modified: functions.lua — new fn.isSafeString helper near safeLower; applied at the blackListAutoKick CHAT_MSG_SYSTEM handler. FGI_Core.lua — applied at the CHAT_MSG_OFFICER handler. Modules/Announce.lua — applied at the CHAT_MSG_CHANNEL handler. Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua — inline pcall probe at the top of lib:OnChatMsgSystem (vendor fix).

[v2.2.2] (2026-05-23) — Stale ChatFrame1EditBox focus suppression fix (retail hotkey-death), anti-spam value-shape compatibility sweep

Stale chat-edit-box keyboard focus killing FGI hotkeys on retail

Field-confirmed root cause for the long-running "FGI hotkeys silently stop working until manual rebind" report. The user pasted /fgi kbstate output during a broken-keybind window:

Keyboard focus held by: ChatFrame1EditBox (our OnKeyDown returns early on this)

with chat NOT actively open. ChatFrame1EditBox retains its GetCurrentKeyBoardFocus() claim after the chat input closes on retail — pressing Enter or Escape clears the visible cursor but the focus token sticks for an arbitrary duration (sometimes seconds, sometimes minutes). Our FGI_KeybindListener OnKeyDown handler bailed on any non-nil focus to avoid hijacking keys while the user was typing, which meant the stale claim silently killed every keybind until another focus transition (clicking another input field, opening chat again, rebinding via Settings — the field reporter consistently used the rebind path because it reliably triggered the transition) released the token.

Fix at FGI_Core.lua OnKeyDown: change the suppression condition from "focus holder exists" to "focus holder is actually visible". IsVisible() walks the parent chain, so a hidden edit box that claims focus (whether because it itself is :Hide()'d or its parent is) correctly reports false and we let the keypress through to our match logic. A diagnostic [kbdebug] key=X stale focus on Y (not visible) — passing through debug print confirms when the v2.2.2 retail quirk fires in the wild.

Edge case considered: if a future legitimate focus holder is visible but the user wants to ignore it for some reason, that's not handled. Not currently a problem — the existing fall-through to the configured-key match logic only fires for keys we know about, and GetCurrentKeyBoardFocus returning a visible frame means the user IS actively interacting with that frame, which is the case we still want to suppress.

Modified: FGI_Core.luaIsVisible()-gated focus suppression in FGI_KeybindListener OnKeyDown, plus a stale focus pass-through debug print.

Chat tooltip arithmetic crash on post-v2.2.0 anti-spam entries

Field report: a Lua error firing when hovering a player name in chat (with FGI chat tooltips enabled) — attempt to perform arithmetic on local 'epoch' (a table value) at Modules/FGI_ChatTooltip.lua:47. The locals dump showed epoch = {class="MONK", time=1779573374, level=90} — the new anti-spam table shape introduced in v2.2.0 for the Class + Level columns on the Anti-Spam tab.

Root cause: v2.2.0's fn:rememberPlayer schema upgrade (number → {time, class, level}) had read-site sweeps for fn.expireAntiSpam, the sync send block, and the AntiSpam tab's buildRows, but missed Modules/FGI_ChatTooltip.lua:211-213. That site reads the raw value out of DB.realm.alreadySended and passes it straight to formatRelative, which does now - epoch arithmetic — works on a number, throws on a table. The chat-tooltip path is read-mostly so the legacy / sync-from-older-peer (bare-number) case kept working, but any entry the user invited locally on v2.2.0+ tripped the error on first hover.

Fix at Modules/FGI_ChatTooltip.lua:211-220 — same defensive unwrap pattern used elsewhere: local ts = type(sent) == "table" and sent.time or sent. formatRelative stays as a generic time-helper that expects a bare epoch number; the schema-knowledge lives at the call site.

Audit pass also done — grep over every remaining alreadySended[...] read site confirmed all others are either boolean truthiness checks (if DB.realm.alreadySended[name]), nil-writes (deletes), or already type-checked. ChatTooltip was the only outstanding latent crash.

Modified: Modules/FGI_ChatTooltip.lua — anti-spam value unwrap before passing to formatRelative.

[v2.2.1] (2026-05-23) — Keybind double-fire fix (stale CLICK bindings from pre-v2.1.8 cleaned at PLAYER_ENTERING_WORLD), text-match echo suppression (chat-throttled multi-chunk leak), keybind diagnostic instrumentation + /fgi kbstate command

Multi-chunk whisper echo leak — text-match suppression

Field report on top of v2.2.0's counter + 2-second backup gate: a long recruitment message split into two chunks was sometimes leaking the second half ("...teach you the ropes! Set sail with us today!") into the user's chat frame, occasionally also opening a popup tab for the target. The user observed it intermittently — sometimes both halves hide cleanly, sometimes the second half leaks.

Root cause: under retail's chat-throttle, the second chunk's WHISPER_INFORM echo can be delayed past the 2-second _whisperSentRecently window v2.2.0 used as a backup to the expected-echoes counter. Once the counter hits zero (it gets decremented by the first chunk's echo and any bonus echo Blizzard's chat layer double-fires) AND the 2-second window has expired, the filter passes the second chunk through as "user manual reply." The trade-off was deliberate at v2.2.0 — earlier wide 60-second gates ate actual user manual replies — but it shipped with a known weakness on chat-throttled multi-chunk sends.

v2.2.1 fix in functions.lua: a new primary suppression gate based on exact text match against pending sent chunks. fn:sendWhisper records every chunk's exact post-transform body into addon._pendingSentChunks[key] (a per-target list with 60-second expiry on each entry) before calling SendChatMessage. fn.hideWhisper checks every incoming WHISPER_INFORM against that list; an exact p.text == incomingMsg match suppresses the echo, drops the entry, decrements the counter, and stamps the tab-suppression timestamp.

The match is timing-independent — it doesn't matter whether the echo arrives in 0.5 s or 50 s, the body is identical to what we recorded. The previous counter + 2-second logic stays as a fallback for sends that bypass fn:sendWhisper (FGI_Core.lua's auto-welcome path, any future direct callers) and for bonus echoes WoW double-fires beyond the recorded chunk count (those won't text-match because we only track one record per send, but the counter + 2 s catches them as before).

Edge case acknowledged: if the user manually whispers a recruit with literally the exact same text FGI just sent (extremely unlikely outside testing), the first echo gets eaten. Recoverable (user retypes). Acceptable.

Modified: functions.luafn:sendWhisper records each chunk's text in addon._pendingSentChunks before sending; fn.hideWhisper text-matches incoming WHISPER_INFORM against the pending list before falling through to the counter/timer gate. Expired entries pruned on every hideWhisper pass for the matching target.

Keybind double-fire — stale CLICK FGI_* bindings purged on every load

Field report: a user pressing the FGI invite hotkey (F5 in their setup) saw two players invited per single keypress, with the second invite firing ~70 ms after the first. Debug instrumentation confirmed only one OnKeyDown event was dispatching fn:invitePlayer, but the second invite still happened — the second source was WoW's secure binding system firing OnClick on FGI_CompactInviteBtn in parallel.

Root cause: their bindings.wtf carried a stale entry from a pre-v2.1.8 install — CLICK FGI_CompactInviteBtn:LeftButton bound to F5. v2.1.8 moved keybinds off the secure binding system to a custom FGI_KeybindListener OnKeyDown handler and shipped a one-shot cleanup pass to clear the old CLICK bindings. That cleanup used GetBinding(i) / GetNumBindings, which only enumerates BINDING_HEADER-registered actions — user-created CLICK bindings stored in bindings.wtf don't show up in that enumeration, so the cleanup silently no-op'd for everyone affected. With both paths active on each keypress, every F5 fired two invites.

Two fixes in FGI_Core.lua:

  1. GetBindingAction(key) instead of GetBinding(i) — that API reads the live binding for a specific key regardless of how it was registered. We iterate the keys we know about (DB.global.keyBind.{invite, nextSearch, announce}), check each, and SetBinding(key) to clear if the action is a CLICK FGI_CompactInviteBtn / CLICK FGI_CompactScanBtn. Idempotent — finds nothing on subsequent loads once cleaned. SaveBindings(GetCurrentBindingSet()) persists across sessions.

  2. Deferred to PLAYER_ENTERING_WORLD — Blizzard populates the live binding table from bindings.wtf between ADDON_LOADED and PLAYER_ENTERING_WORLD. Calling GetBindingAction during OnInitialize (which fires inside ADDON_LOADED for our addon) returns nil because the table isn't filled yet. PLAYER_LOGIN doesn't fire on /reload, so a one-time PLAYER_ENTERING_WORLD event registration (with immediate UnregisterAllEvents after the first fire) catches both cold-login AND /reload, AFTER bindings are loaded. Tested end-to-end on retail: cleared stale binding: F5 -> CLICK FGI_CompactInviteBtn:LeftButton debug line fires on first /reload after upgrade; subsequent /reloads show cleared=0.

Keybind diagnostic instrumentation — [kbdebug] lines + /fgi kbstate command

Two open keybind issues drove the investigation: the double-fire above, and a separate "binds work for ~5 min then silently stop until manual rebind" report. To distinguish between possible failure modes (listener frame dead, keyboard focus stolen, DB wiped, dispatch silently failing inside protected call) FGI_Core.lua gained:

  • Per-keystroke debug log — fires only when the incoming key's base matches the base key of one of the configured bindings (so movement / hotbar keys don't spam chat). Reports the incoming key, the assembled full-combo string, the focus holder if suppression kicked in, and which binding (if any) matched. Three lines max per relevant keypress.
  • OnShow / OnHide hooks on FGI_KeybindListener — catches the (unlikely but possible) case where something hides our listener frame, which would silently break all keybinds.
  • /fgi kbstate slash command — snapshot of the listener's current state: frame existence, shown/hidden status, keyboard-enabled flag, propagate-keyboard-input flag, current keyboard-focus holder (if any), and DB.global.keyBind contents. Runnable any time, not gated on debug mode. Diagnostic-only; not for end-users in normal operation.
  • [kbdebug] stale-binding cleanup ran (cleared=N) heartbeat — fires once per /reload-or-login, confirms the cleanup pass actually executed and what it found. Useful for verifying that future bindings.wtf drift doesn't sneak past us.

The "binds die after 5 min" investigation is still open — the leading hypothesis is that ChatFrame1EditBox retains its keyboard-focus claim after the chat input closes on retail, so GetCurrentKeyBoardFocus() keeps returning a hidden frame and our suppression bail-out kicks in on every subsequent keypress. Fix candidate (one-liner: only suppress on focus:IsVisible()) is staged but not yet shipped — waiting for a clean repro with the new debug instrumentation to confirm the hypothesis before making the behavioural change.

Modified: FGI_Core.luapurgeStaleClickBindings helper deferred to PLAYER_ENTERING_WORLD; new OnShow / OnHide / OnKeyDown debug prints on FGI_KeybindListener; new /fgi kbstate slash command.

[v2.2.0] (2026-05-23) — Anti-Spam re-check-and-invite action, recruit-whisper alerts, Guild Roster tab, periodic anti-spam expiry, retail Popup-mode taint + echo fix, Statistics tab period-totals fix, Reset Settings command, Filters tab shows legacy v1 rows

Reset Settings command + button — wipe configuration, keep data

Field report from a German recruiter: every class except Hunter was getting filtered out of their scans. They told us they had "no filters" — and the Filters tab UI confirmed it (the tab was empty). Root cause was a legacy v1 filter named "Inv" in DB.realm.filtersList that excluded every non-Hunter class via the v1 classFilter deny-list. The post-scan fn:filtered legacy path still honoured the deny-list correctly, but the v2 Filters tab UI silently skipped rendering legacy schemaVersion=nil entries — so users couldn't see + delete forgotten v1 rows from the tab. Workaround for them was FGI.DB.realm.filtersList.Inv=nil via /run.

Two fixes in v2.2.0 to prevent this class of bug repeating:

  1. New /fgi resetSettings slash command + Settings panel button (functions.luafn:resetSettings; FGI_Core.lua → slash dispatch; GUI/SettingsPanel.lua → "Reset settings (keep anti-spam + blacklist)" button in Settings → Debug section, with confirmation popup).

    Wipes: filter profiles, custom scans, scan groups, custom quiet zones, welcome messages, alert toggles, keybinds, gmPolicy, announce profiles — everything else.

    Preserves: DB.realm.alreadySended (anti-spam list), DB.realm.blackList, DB.realm.blackListRemoved, DB.realm.leave, DB.factionrealm.totals (lifetime stats), DB.factionrealm.history (invite log), DB.factionrealm.memberHistory (per-member join records). Use case: a forgotten legacy filter is silently breaking scans, but the user has months of accumulated anti-spam data they don't want to lose. The existing /fgi factorySettings slash command remains the nuclear option for a true full reset.

    Implementation: capture the keep-list, call AceDB ResetDB() (full reset to defaults from defaultSettings), then restore the captured tables. Also clears the in-memory state mirrors (removeMsgList, _whisperSentRecently, _recruitAlertFired, addon.search.tempSendedInvites, etc.) so the reset doesn't require /reload to feel clean — though /reload is still recommended so all the tab UIs re-read fresh state.

  2. Filters tab now renders legacy v1 rows (GUI/Tabs/Filters.luabuildRows). Previously if f.schemaVersion == 2 then silently skipped any row without the v2 marker. Now legacy rows render too, tagged with (v1) in the name column. The deny-list contents show as excl: ClassA, ClassB, ... so the legacy semantics are visible.

    The toggle (On checkbox) and delete (X icon) work normally for legacy rows. Clicking a legacy row does NOT populate the v2 edit form — the v2 schema (wantedClasses / wantedRaces whitelists) can't represent v1 deny-list semantics without a destructive shape migration, so we don't attempt the migration. Clicking a legacy row prints a chat hint telling the user to delete + recreate if they want to edit.

    Effect: users can now see + clean up forgotten v1 filters from the UI without needing /run.

Retail Popup-mode taint + echo fix

Two compounding bugs surfaced when "Hide outgoing whisper echoes" was enabled on retail with WoW's whisper mode set to Popup (Social → Chat → Whisper mode):

Bug A — chat-history taint crash. A user reported a Lua error firing during a dungeon fight: attempt to perform string conversion on a secret string value (execution tainted by 'fastguildinvite') from inside Blizzard_ChatFrameBase/Shared/HistoryKeeper.lua:35 (ChatHistory_GetToken). The crash fires on any chat event whose sender is a "secret string" (monster yells/says, filtered creature names, certain channel events). Once tainted, the failure is per-event for the rest of the session — every monster yell crashes the chat-history token builder.

Root cause: raw calls to ChatFrame_AddMessageEventFilter and to FCF_Close were leaking taint into Blizzard's chat dispatch tables. The Whisper Messenger addon hit the same family of bugs and documented the fix pattern publicly: route all protected chat-API calls through securecall, which isolates the caller's taint state from Blizzard's secure-execution context. Three call sites converted in functions.lua:

  • fn.updateWhisperEchoFiltersecurecall(ChatFrame_AddMessageEventFilter, "CHAT_MSG_WHISPER_INFORM", fn.hideWhisper) (and matching Remove).
  • fn.updateSystemMsgFilter — same wrap for the system-message filter pair.
  • closeDedicatedWhisperFramesForTargetsecurecall(FCF_Close, frame).

C_Timer.After-deferred sweep on retail kept as defence-in-depth (FCF_Close still doesn't run inside the chat event tick), but securecall is what actually isolates the taint. Comment block in functions.lua above fn.suppressConversationTabFor updated to reflect the real mechanism.

Bug B — user manual replies eaten by the echo filter. Field report: when a recruit replied and the user typed a manual response within ~60 seconds, the response echo was being suppressed too, so the user typed a reply and it appeared nowhere — chat-window-wise it looked like nothing happened. Cause: the echo filter's deadline gate was addon.removeMsgList[key] with a 60-second window. That window was sized to cover FGI's own multi-chunk recruitment whispers under chat-throttle delays, but it was wide enough to also catch the user's manual reply to the same recruit during an active conversation.

Replaced with a primary-plus-backup gate in fn.hideWhisper (functions.lua):

  1. Primary — expected-echoes counter. fn.refreshWhisperHideDeadline now increments addon._whisperExpectedEchoes[key] once per FGI-initiated SendChatMessage (per chunk for multi-chunk sends). fn.hideWhisper decrements the counter on each suppressed echo. When the counter hits zero, an arriving WHISPER_INFORM is necessarily a user manual reply, not an FGI echo, and the filter passes it through.

  2. Backup — 2-second "FGI just sent something" timestamp. Field testing showed Blizzard's chat layer occasionally double-fires for long whispers (a 2-chunk send producing 3 WHISPER_INFORM echoes), and the counter alone undercounted in that case so the bonus echo leaked through. addon._whisperSentRecently[key] is stamped at every FGI send; if the counter says "no more echoes expected" but the stamp is within the last 2 seconds, treat the echo as a delayed bonus chunk and suppress. The 2-second window is short enough that user manual replies (always multiple seconds later in any realistic recruitment workflow) pass through.

Edge case acknowledged in code comments: a user manually whispering a recruit within 2 seconds of an FGI send gets one echo eaten. Rare and recoverable (the message did send — it just doesn't appear in the user's chat window); the reverse of the more common frustrating case the wide 60s gate produced.

Tab-suppression gate tightened. Separately, fn.suppressConversationTabFor was previously called on every echo within the 60s removeMsgList window — which closed the popup the user was using mid-conversation. Now gated on the 2-second _whisperSentRecently timestamp, so only FGI's own send burst triggers tab suppression. Comment block at the call site documents the trade-off (an FGI chunk echo delayed past 2s by extreme chat-throttle would leak an empty popup — accepted as preferable to eating the user's active reply tab).

WhisperAlert duplicate-tab fix. Modules/WhisperAlert.lua previously force-opened a popup via FCF_OpenTemporaryWindow when a recruit replied, on the theory that WoW's default routing would send the reply inline (the v2.1.15 retail bail had this design). With the taint fix above, WoW's default routing now creates the popup natively, and the addon's force-open ran one frame later creating a SECOND tab for the same target. The force-open call was removed; the recruit-reply UX is now sound + screen flash + WoW's own natively-created popup tab.

Untested edge case: the dungeon-fight scenario that produced the original BugSack crash hasn't been re-tested at release time (no recent dungeon run to reproduce against). Field reports during v2.2.0 will surface any remaining secret-string crash; the securecall pattern is documented as the canonical fix for this family of bugs.

Modified: functions.lua — securecall wraps on ChatFrame_AddMessageEventFilter / ChatFrame_RemoveMessageEventFilter (in both fn.updateWhisperEchoFilter and fn.updateSystemMsgFilter); securecall(FCF_Close, frame) in closeDedicatedWhisperFramesForTarget; new counter-based echo gate (addon._whisperExpectedEchoes) with 2s _whisperSentRecently backup; 2-second _whisperSentRecently gate on fn.suppressConversationTabFor call from fn.hideWhisper; deferred 4-sweep retained as defence-in-depth. Modules/WhisperAlert.lua — removed the FCF_OpenTemporaryWindow force-open on incoming-recruit-reply; once-per-recruit-per-session alert gate via addon._recruitAlertFired, cleared by fn.refreshWhisperHideDeadline on re-invite.

Statistics tab — totals now react to the period dropdown

Field report: a user with 71 accepted invites in the lifetime totals saw "71 accepted" displayed for both "last 30 days" AND "last 24 hours" — the period dropdown was a visual control with no effect on the displayed numbers.

Cause: the Statistics tab's refresh() function read DB.factionrealm.totals.* directly, which are cumulative-lifetime counters with no per-period bucketing. The period dropdown's set callback existed but didn't trigger a recompute against the underlying DB.factionrealm.history per-event array.

Fix in GUI/Tabs/Statistics.lua:

  • New countSince(arr, startDate) helper iterates a history array (history.search, history.send, history.accept, etc.) and returns the count of entries with timestamp >= startDate.
  • refresh() now branches on the period selection: "All Time" continues to read the lifetime counters (cheap, no walk); any other period computes the start date from time() and falls through to countSince over each history array.
  • Forward declaration local refresh added above the period dropdown so its set callback can call into refresh immediately on change — previously the dropdown was defined before refresh and could only set state, not trigger a recompute.

Cost: one pass over each history array per period change. History arrays are pruned on a rolling basis and typically hold a few hundred to a few thousand entries even for heavy recruiters, so the walk is sub-millisecond. No DB schema change.

Modified: GUI/Tabs/Statistics.lua — new countSince helper, period-aware refresh(), forward declaration of refresh, dropdown set callback wired to call refresh().

Four-item QoL release driven by a feature-gap audit against CogwheelRecruiter. None of the items overlap with the in-progress v2.3.0 comm-overhaul work (feat/comm-overhaul branch); v2.2.0 ships on master alongside the v2.1.x line.

Periodic anti-spam expiry (no more stale entries until /reload)

Pre-2.2.0 behaviour: DB.realm.alreadySended entries were cleaned up exactly once per session, in OnInitialize. If a user ran a multi-hour recruitment session and an entry's expiry passed mid-session, the entry stayed in the table until the next /reload. In practice most users /reload often enough that this wasn't visible, but during long sessions it produced "Bob expired 2 hours ago but still doesn't reappear in scans" behaviour.

Fix: extracted the cleanup logic into fn.expireAntiSpam (functions.lua) and wired it from three new triggers in addition to the existing OnInitialize call:

  • 60-second C_Timer.NewTicker (FGI_Core.lua) — runs the cleanup on a low-frequency cadence so the table stays accurate during long sessions. Cheap (one pairs() walk over alreadySended); ticker runs for the addon's lifetime, no cancellation needed.
  • Scan-start trigger (functions.luafn:nextSearch) — runs the cleanup right before building the next query list. Without this, entries expiring during a long-running scan would keep filtering candidates out of the queue until the next ticker tick.
  • Anti-Spam tab show (GUI/Tabs/AntiSpam.lua) — runs the cleanup when the tab is opened so the list the user sees reflects current expiry rules, not state frozen at the last ticker tick.

fn.expireAntiSpam is idempotent and self-gating (early-returns when DB isn't wired yet), so all four call sites are safe to invoke from any context.

Modified: functions.lua — new fn.expireAntiSpam, scan-start call in fn:nextSearch. FGI_Core.luaOnInitialize now delegates to the function; new 60s ticker. GUI/Tabs/AntiSpam.luaOnShow hook calls the function before refresh.

Anti-Spam tab — Class and Level columns

Companion to the Re-check & invite action below: when the user is deciding which past candidate to follow up on, a flat list of names + "last invited" timestamps doesn't carry enough signal. They want to see who's likely still relevant — a level-60 Hunter is a different recruitment decision than a level-12 Priest, even if both happened to be in the anti-spam list. v2.2.0 adds two new sortable columns (GUI/Tabs/AntiSpam.lua) backed by a small schema change to the anti-spam value shape.

Storage shape — backwards-compatible upgrade. DB.realm.alreadySended[name] was a flat timestamp (number) before 2.2.0. New local writes via fn:rememberPlayer now store {time, class, level} (table) where class is the locale-stable class file token ("HUNTER" / "MAGE" / ...) captured from the scan-result entry's NoLocaleClass and level is the integer level from the same source. Old entries (written before the upgrade) and entries arriving from peers running pre-2.2.0 builds stay as plain numbers — every read site in the codebase (fn.expireAntiSpam, the sync send block, the Anti-Spam tab's buildRows) checks type(v) == "table" first and falls back to the bare-number path, so the upgrade is transparent.

Wire format unchanged. The sync send block at functions.lua extracts .time from any table values before packaging them into Sync.tablesForSync.alreadySended. Peers running older builds continue to receive a name → timestamp dict and decode it correctly. Class + level are local-only enrichment — no protocol bump, no version gate, no rollback path. Users running 2.2.0 see their own entries enriched as they invite; entries received from older peers show blank class/level until their owner re-invites that player on 2.2.0+.

Preservation on sync receive. Without a guard, the inbound REMEMBER sync handler (functions.lua — the msg.type == "REMEMBER" branch) and the accept-handler fallbacks at functions.lua (lines 644 / 774) would call the one-arg fn:rememberPlayer(name) form for any cross-peer broadcast and blank out class/level the local scan path had already captured. fn:rememberPlayer now reads the existing entry first and substitutes its class / level into the new write when the caller passed nil. Net: another guildie inviting a name you've already enriched locally refreshes the timestamp without losing the enrichment.

Call-site threading. fn:rememberPlayer(name, classToken, level) — the two new args are optional. Every call site that has a scan-result entry in scope now passes them:

  • fn:invitePlayer (functions.lua) — captures entry.NoLocaleClass, entry.lvl from list[i] at function entry, then threads them into all four invite-mode rememberPlayer calls (Type 1/2/3/4) and the noInv decline path. The Type 2 closure captures the values upfront so the deferred doInvite call (whisperDelay) reads from a stable upvalue rather than risking list[i] having shifted by the time the timer fires.
  • Modules/compactFrame.lua — Skip-with-remember path.
  • GUI/Tabs/Scan.lua — Skip-with-remember path.

Context-menu paths that only have a bare name in scope (FGI_Core.lua, Modules/FGI_ChatMenu.lua) continue to call the one-arg form; those entries get nil class/level and render blank in the tab.

Tab columns. Two new columns inserted between Name and Sent (GUI/Tabs/AntiSpam.lua):

  • Classcol.color = "class" so RowList's built-in class colouring kicks in (it reads entry.NoLocaleClass and looks up addon.color[CLASSTOKEN] to wrap the cell text). The cell value is the plain localized class name from LOCALIZED_CLASS_NAMES_MALE, so the sort byte-compares localized names directly — sort order matches the displayed labels.
  • Lvl — numeric. RowList's sort code detects numeric values on both sides and falls through to numeric compare; nil levels (legacy entries) sort to the nil-tail position.

Both columns are sortable by default (RowList sorts any column with a header unless sortable = false) and have headerTip entries describing the data source and the legacy-entry blank-cell case. Header rendering is brand-colour-coded by RowList's existing pattern.

Modified: functions.luafn:rememberPlayer signature + table storage; fn.expireAntiSpam type-checks value; sync send extracts .time; fn:invitePlayer captures entry once and threads class/level into all rememberPlayer calls. Modules/compactFrame.lua — Skip path. GUI/Tabs/Scan.lua — Skip path. GUI/Tabs/AntiSpam.luaclassDisplay helper, value unwrap in buildRows, two new column definitions with headerTip.

Anti-Spam tab — "Re-check & invite" per-row action

The Anti-Spam list IS the working history of recently-contacted candidates. Pre-2.2.0 there was no way to act on past entries — the only per-row action was "remove from anti-spam," which made the row re-eligible only when the next scan happened to cover that name's level / class / zone. For a recruiter who remembers "I whispered Bob a week ago, let me re-check his guild status," the workflow was unhelpful: manually type /who Bob, scroll results, hope they show up.

New per-row icon (GUI/Tabs/AntiSpam.lua) — refresh icon (Interface\Buttons\UI-RefreshButton) sits to the left of the existing remove icon. OnClick:

  1. Clears the player's entry from DB.realm.alreadySended and addon.search.tempSendedInvites so they're not filtered out downstream.
  2. Strips the realm suffix from the canonical "Name-Realm" key to get the bare name for /who's name filter.
  3. Fires libWho:GetWho('n-"Name"') via the existing LibWho wrapper.
  4. Prints a status line so users see the action took effect.

The existing LibWho callback (searchWhoResultCallback) handles the result: if the player is found and passes current filters (blacklist, quiet-zone, filter profile), they land in the scan queue and the user invites from there.

Two-step (re-check + invite from queue) rather than CogwheelRecruiter's one-click auto-invite. Reasons: matches FGI's existing "user confirms each invite" philosophy across the scan flow; avoids racing the async LibWho result with an immediate invite call; keeps the implementation small (the wholesale auto-invite version would need an in-flight name tracker and a hook in addNewPlayer).

Modified: GUI/Tabs/AntiSpam.lua — new action entry in the RowList actions array.

Recruit-whisper alerts (per-component opt-in: sound / screen flash / chat-tab flash)

Pre-2.2.0 FGI had zero CHAT_MSG_WHISPER listeners — only CHAT_MSG_WHISPER_INFORM for the outgoing-echo hide filter. When a recruit replied to a recruitment whisper, FGI did nothing: no notification, no chat-tab escalation, no log. Bulk recruiters who whisper 200+ candidates per session genuinely lose replies in the chat-frame noise.

New module: Modules/WhisperAlert.lua. Listens to CHAT_MSG_WHISPER; fires alerts when the sender is in DB.realm.alreadySended or addon.pendingInvites (= someone FGI has reached out to recently). Three independent alert components, each gated on its own settings toggle so users pick the combination they want:

  • SoundPlaySound(8960) = SOUNDKIT.READY_CHECK. Distinct from WoW's default whisper sound (12867) and from TOGProfessionMaster's 878 / 5274 alerts so users running both addons hear cues they can tell apart.
  • Screen flash — full-screen overlay frame (cached on addon._whisperAlertFlash) with Interface\FullScreenTextures\LowHealth texture at 50% alpha, tinted FGI brand-blue (TOGPM uses gold; deliberately different). UIFrameFlash(frame, 0.4, 0.4, 1.6, false, 0, 0) — two visible flashes, ~1.6 sec total.
  • Chat-tab flash — calls FCF_StartAlertFlash on the temp whisper window WoW creates for the incoming whisper. WoW already flashes these natively but the cue is subtle on retail; this re-triggers the alert flash and re-fires it if the user already glanced past. Deferred by 0.2 sec because CHAT_MSG_WHISPER fires BEFORE the temp window is registered in CHAT_FRAMES. Only visible when whisper mode is Conversation / Popup; no effect with Inline whisper mode.

Login-burst suppression: skips all three alerts in the first 10 seconds after PLAYER_LOGIN because retail / some servers replay recent whisper history on login, which would otherwise fire a flurry of alerts for already-handled conversations.

Per-conversation suppression: alerts fire only on the recruit's FIRST reply, not on subsequent messages in the same conversation. The user already knows the conversation is alive; subsequent dings/flashes are noise. Tracked via addon._recruitAlertFired[normalizedName] = true, cleared by fn.refreshWhisperHideDeadline whenever FGI sends an outgoing whisper to that name — so re-inviting a past recruit (via Anti-Spam re-check-and-invite or manual /fgi invite) re-arms the alert naturally.

Settings toggles (GUI/SettingsPanel.lua — General → Chat noise section, between sendMSG and systemMSG): recruitAlertSound, recruitAlertScreenFlash, recruitAlertTabFlash. All default-on. DB defaults declared in FGI_Core.lua's defaultSettings.global.

The chat-tab flash pairs especially well with "Hide outgoing whisper echoes" — your outgoing whispers don't open tabs (suppressed by the existing filter), so when a tab DOES open it's necessarily a real reply, and the escalated flash makes it impossible to miss.

Added: Modules/WhisperAlert.lua. Wired into all 5 TOCs (FastGuildInvite.toc, _BCC, _Wrath, _Cata, _Mainline) after Modules\Announce.lua. Modified: GUI/SettingsPanel.lua — three new toggles. FGI_Core.lua — three new global defaults.

Guild Roster — new tab with class / level / rank distribution

CogwheelRecruiter has a Guild tab showing class/level breakdown of own-guild composition for recruitment-strategy decisions ("we have 12 mages and 1 priest, recruit priests"). FGI's Statistics tab is FGI-activity-only (searches / found / sent / accepted / declined) and showed nothing about own-guild composition. v2.2.0 closes that gap using the vendored LibGuildRoster-1.0 library, which already maintains a live normalized-name dict with class / level / rank / online status and fires callbacks on roster changes.

New tab: GUI/Tabs/GuildRoster.lua. Registered at the end of the tab strip per user request (reorder pass deferred to a later UX cleanup). Three side-by-side breakdowns:

  • By Class — class-coloured display name → count, sorted alphabetically by localized class name. Uses LOCALIZED_CLASS_NAMES_MALE and RAID_CLASS_COLORS.
  • By Level — 5-level buckets ("1-5", "6-10", ...), only populated buckets shown, ascending.
  • By Rank — rank name → count, ordered by rankIndex so Guild Master shows first.

Top strip:

  • Summary — total members, online count (green), offline count (gray). Empty-state copy distinguishes "LibGuildRoster still stabilizing" (first 1-2 seconds after login) from "no members".
  • Online only checkbox — filters all three breakdowns to currently-online members.
  • Refresh button — re-runs the aggregation against the live snapshot.

Refresh triggers: tab show, refresh button click, Online only toggle, and live LibGuildRoster callbacks (OnRosterUpdated, OnRosterReady, OnMemberOnline, OnMemberOffline, OnMemberJoined, OnMemberLeft). One callback consumer cached on the tab module to prevent double-registration on re-renders.

Out of scope for v2.2.0 (deferred — user explicitly wanted a "small lift" for this release):

  • Saveable custom filter profiles (similar shape to the existing Filters tab).
  • Per-class checkbox filters.
  • Per-rank checkbox filters.
  • Histogram / chart visualizations (text counts only for v2.2.0).
  • Join-date tracking (LibGuildRoster doesn't carry it; would need our own persistence layer).
  • Level-change history.

Added: GUI/Tabs/GuildRoster.lua. Wired into all 5 TOCs after GUI\Tabs\Announce.lua and before GUI\SettingsPanel.lua. Modified: GUI/MainWindow.lua — new guildroster entry in TAB_DEFS, buildTabContent dispatch, and TAB_MODULE map.

[v2.1.15] (2026-05-21) — Sync per-peer accumulator cleanup + Scan.lua dead-end state cleanup (memory-leak audit, defensive) + retail whisper-popup regression fix

Retail incoming-whisper popups + outgoing reply tabs restored (v2.1.14 FCF hook regression)

Field-reported regression: after the v2.1.15 sync brought v2.1.14's changes to retail for the first time, with "Hide outgoing whisper echoes" enabled, retail normal-whisper traffic broke — incoming whispers from non-FGI partners stopped opening their popup, and the user's outgoing reply to them stopped opening a conversation tab. Toggling sendMSG off restored normal behavior, confirming the regression was in one of the two sendMSG-gated code paths.

Cause: v2.1.14's installConversationTabSuppressionHook installed a hooksecurefunc("FCF_OpenTemporaryWindow", ...) on every client family. The design comment claimed retail "doesn't route whisper popouts through FCF_OpenTemporaryWindow, so the hook is inert there" — empirically wrong. Retail DOES route some whisper popout creation through that API, and even though our hook had a deadline gate (addon.removeMsgList[key]) intended to only close FGI's own recruitment popouts, the user's report shows the gate isn't sufficient on retail. Without gv.isRetail blocking the install, every retail-client user with the hide-echo toggle on was getting non-FGI whisper popouts eaten.

Fix: early bail on gv.isRetail inside fn.installConversationTabSuppressionHook (functions.lua). Retail keeps its existing conversation-tab suppression via the 4-sweep fn.suppressConversationTabFor that fires per-suppressed-echo from fn.hideWhisper — that path is already gated on gv.isRetail and was never the source of this regression. The FCF hook is now classic-family-only, matching the original v2.1.14 design intent (the hook exists specifically because the 4-sweep approach causes visible chat bobbing on TBC / Wrath / Cata / Classic Era; retail doesn't have the bobbing problem so it doesn't need the hook).

The dead retail branch inside the hook callback (if gv.isRetail then pcall(fn.fullPlayerName, ...) end) is now unreachable but left in place — harmless, and a future contributor reading the callback shouldn't have to guess at the branch's missing context.

Diagnostic note for future investigation: the deadline gate inside the hook callback should have prevented this — non-FGI targets have no removeMsgList entry, so the hook should have bailed before calling closeDedicatedWhisperFramesForTarget. The fact that it didn't on retail suggests either (a) the deadline keying is collapsing more names than intended on retail, (b) closeDedicatedWhisperFramesForTarget is matching frames it shouldn't, or (c) retail's FCF_OpenTemporaryWindow timing puts the hook callback in a state where the close affects more than the target frame. Bailing the whole install on retail sidesteps all three without needing to root-cause which one (or which combination) is at play.

Modified: functions.luafn.installConversationTabSuppressionHook.

auto_decline marker and msgQueue Type 4 orphans now cleared on dead-end outcomes

Same audit pass as the Sync.cache / Sync.trusted work below, focused on the per-invite state tables in Modules/Scan.lua. Two leak patterns found, both small, both classic "we mark the entry but never get back to clear it" shapes:

auto_decline module-local table (Modules/Scan.lua:8) is populated when a recipient has auto-decline enabled — the marker gates a one-shot Type 2 follow-up whisper decision so we don't whisper someone whose game auto-rejected our invite. The marker was written into the table but never deleted, so across a multi-hour recruitment session every unique auto-decliner left a stale [name] = true entry. Bounded only by "how many distinct auto-decline targets exist on this realm", which on Trade-channel-scale recruiting can be hundreds. Fix: the Type 2 follow-up whisper 1-second timer now captures-then-clears the marker — single-use semantics. If the same player gets re-invited later in the session (anti-spam expired, found again by /who), the auto-decline event re-populates the marker on demand. Bounded growth → bounded.

addon.msgQueue Type 4 orphans (Modules/Scan.lua:9) — the Type 4 invite mode "queue a whisper to send if and only if the invite is declined" writes the target + message into msgQueue at invite time and consumes the entry on decline. Two dead-end outcomes were leaving orphaned entries:

  • auto_decline — recipient's auto-reject fired before our decline-handler saw a manual decline event, so we never consumed the queued whisper. Now cleared in both the retail UI_ERROR path and the classic-family CHAT_MSG_SYSTEM path (TBC / Wrath / Cata / Classic Era — the active path for this user's report).
  • not_found/who came back empty for that name, meaning the target doesn't exist on the server, meaning the queued whisper has no valid recipient. Now cleared in the not_found handler (by both name and the pendingKey Name-Realm form, since either could've been the original key).

The Type 4 + accepted-invite case (addon.msgQueue[name] not cleared when an invite is accepted instead of declined) is intentionally left alone — would need a per-invite timer or a join-event hook for a leak that's small enough not to justify the complexity.

Net: per-session growth of these two tables is now bounded by "concurrent in-flight invites", not "cumulative unique targets over the session lifetime". As with the Sync.cache / Sync.trusted change, the sizes are small in practice — this is defensive hygiene to remove variables from the stutter investigation, not a single smoking-gun fix.

Modified: Modules/Scan.luaauto_decline capture-then-clear in Type 2 follow-up timer; addon.msgQueue clears in auto_decline (UI_ERROR + CHAT_MSG_SYSTEM) and not_found handlers.

addon.Sync exposed as a diagnostic handle (no behavioural change)

To unblock the stutter investigation: previously the Sync table (state machine, send/receive buffers, per-peer caches, tablesForSync snapshot) was a file-local in functions.lua with no read path from chat. Dumping removeMsgList, msgQueue, and pendingInvites ruled those out as the leak, but the next question — "is a sync session stuck open, are chunks piling up in sendTable?" — couldn't be answered from /run without a code edit + /reload round-trip.

Sync is now also assigned to addon.Sync at the bottom of its initialiser block (functions.lua), so a chat-line dump like the following works:

/run local S=FGI.Sync local function n(t) local c=0 for _ in pairs(t or {}) do c=c+1 end return c end print("tgt",S.target,"st",S.state,"sT",#S.sendTable,"cch",n(S.cache),"tr",n(S.trusted),"as",n(S.tablesForSync.alreadySended))

Trade-off documented in the comment above the assignment: addon.Sync is now writeable from outside the module, but the rest of functions.lua's sync code reads via the file-local Sync upvalue. An external mutation to addon.Sync wouldn't change behaviour (it'd just break the diagnostic for that session). Acceptable for a diagnostic-only handle that's never expected to be assigned to in practice.

No DB schema change, no protocol implication. Same memory footprint as before — addon.Sync is a reference to the same table, not a copy.

Modified: functions.luaaddon.Sync = Sync after the Sync.state = Sync.stateTable[1] initialiser.

Sync.cache and Sync.trusted now reset on session close

User report: persistent game stutter after extended bulk-invite sessions, only cleared by /reload or relog. Symptom shape (stutter persists when idle, fixed by reload) is consistent with accumulating per-session state.

A memory-leak audit of the codebase (docs/FGI_BUGS.md) found that two tables in the sync subsystem grow across the addon's lifetime without ever being trimmed: Sync.cache (the receive-side memo of "what entries each peer already sent us, so we don't echo them back") and Sync.trusted (the per-player "is this person a real guild member" cache, to skip the guild-roster scan on repeat sync attempts). Both were initialised once at module load (functions.lua:3233-3234) and never cleared.

Sizes are small in practice — Sync.cache ~50-200 KB worst case for a 3-peer guild after a heavy session, Sync.trusted ~30-50 KB at most. Almost certainly NOT the root cause of the stuttering report (the audit didn't find a smoking-gun cause; the stutter likely lives elsewhere and we've asked the reporter for GetAddOnMemoryUsage / GetAddOnCPUUsage diagnostics to narrow it down). This change is defensive hygiene — landing it now removes the per-session growth so when we get diagnostic numbers back we're measuring everything else cleanly.

Implementation: restoreSyncDefaultValues now zeroes both tables at the end of every Sync.closeConnect. Trade-offs documented inline at functions.lua:

  • Sync.cache clear: next sync session with the same peer re-sends entries the peer already gave us. A few extra chunks of BULK bandwidth per cycle. Acceptable because (a) bandwidth is rarely the bottleneck on steady-state guilds, (b) v2.1.14's partial-success machinery means imperfect transfers still land data correctly.
  • Sync.trusted clear: next initSync from any peer re-walks GetNumGuildMembers() before accepting. Trivial CPU — guild rosters are at most a few hundred entries.

Net: small bandwidth + tiny CPU cost per next sync, in exchange for not accumulating across multi-hour sessions. Effectively /reload-equivalent reset behaviour, done automatically per session-close.

The v2.2.0 AceComm + AceCommQueue refactor will replace the entire manual chunking/ACK protocol and supersede these two tables. This change is just to keep memory clean until that lands.

Modified: functions.luarestoreSyncDefaultValues.

[v2.1.14] (2026-05-21) — Auto-welcome whisper hide fix, per-tooltip height hints, announce button countdown, sync diagnostic + partial-success reporting, announce activity-threshold cap 50 → 1000

Announce activity threshold cap raised 50 → 1000

User request: on very busy channels (peak-hour Trade, General-Barrens) the 50-message activity bypass fires too eagerly — 50 messages can scroll past in well under a minute, so the profile essentially treats every cooldown gate as bypassable. Users wanted to set higher thresholds like "only re-post if my line is really buried (≥300 messages)" but the field clamped at 50.

Two changes in GUI/Tabs/Announce.lua:

  • actInput:SetMaxLetters widened from 2 → 4 (the input field now accepts up to 4 digits).
  • Save-time clamp widened from math.min(50, ...)math.min(1000, ...).

The cap is a pure UI sanity bound — the activity value is only consumed in one place (Modules/Announce.lua:230) where a too-large value just makes the bypass branch always false, falling back to the cooldown timer. The 60 s hard floor (FGI_ANNOUNCE_MIN_COOLDOWN) still gates repost rate regardless of activity threshold. No DB schema change, no protocol implication (activity is per-profile local config, not synced).

The Act-field hover tooltip now reads "Range 0-1000" and mentions the 60 s floor explicitly.

Auto-welcome whisper now hides like recruitment whispers

Field-reported bug: with "Hide outgoing whisper echoes" enabled on retail, the per-member-join welcome whisper ("Welcome to the guild, X!") still echoed into chat as To [X]: Welcome to the guild, X! for every new join — even though the toggle was on and recruitment-mode whispers WERE being hidden.

Cause: the auto-welcome whisper at FGI_Core.lua:347-356 called SendChatMessage / C_ChatInfo.SendChatMessage directly, bypassing fn:sendWhisper and therefore never refreshing the addon.removeMsgList[fullName] deadline that fn.hideWhisper consults. The filter saw the echo with no active deadline → fell through → echo displayed.

Fix: new public helper fn.refreshWhisperHideDeadline(name) (functions.lua) extracts the deadline-write into a callable function. Both fn:sendWhisper and the auto-welcome-whisper path now go through it. Side benefit: cleans up duplicated retail pcall-around-fullPlayerName boilerplate.

Whisper hide window bumped 10 → 60 seconds

Field-reported bug: on retail, fast bulk-invite sessions (≥18 whispers in rapid succession) leaked roughly half the echoes despite the toggle being on. The whisper hide is deadline-based — each SendChatMessage call stamps removeMsgList[fullName] = GetTime() + WINDOW and fn.hideWhisper only suppresses while now < deadline. The previous 10 s window was shorter than retail's server-side chat-rate throttle delay: when the throttle queued our 2nd / 3rd / Nth whisper for late dispatch, the eventual WHISPER_INFORM echoes arrived 15-30 s after the corresponding SendChatMessage call, by which time the deadline had expired.

Bumped FGI_WHISPER_HIDE_WINDOW to 60 s in functions.lua. Documented trade-off: a manual whisper to the same target within 60 s of FGI's whisper is also hidden — rare in recruitment workflow since recruits are strangers.

Per-tooltip height hints

Field-reported UX bug: when the compact tray sat near the top of the screen, every tray tooltip flipped to BOTTOMLEFT (below the button), even tooltips that would have fit fine above. Root cause: addon.Tooltip.Owner(frame) used a single fixed 250 px budget for the "is there enough room above the frame" gate — the gate fired on tooltips that only needed ~80 px just because the conservative budget couldn't fit. The compact tray's help "i" tooltip genuinely needs ~400 px (it's a wall of icon descriptions) and that one needs to flip; the others don't.

Fix: addon.Tooltip.Owner(frame, tooltipHeight) (functions.lua) now accepts an optional per-tooltip height hint. Defaults to 250 px when omitted so every existing call site behaves unchanged. Wired up in Modules/compactFrame.lua with realistic hints per tooltip: 100 (resize grip), 200 (counter strip), 120 (scan / invite / expand), 250 (announce, has dynamic profile-status block), 400 (help "i"), 80 (gear), 150 (close X), 130 (per-row icons). makeTitleIcon accepts an 8th parameter that forwards to the helper. Net effect on the tray: only the help "i" flips to BOTTOMLEFT when near the top of the screen; everything else stays anchored above.

Announce button countdown overlay

User request: mirror the scan >> button's cooldown UI on the announce horn — when you fire announce, replace the icon with a seconds countdown until the next eligible profile is ready, matching the per-profile cooldown set in the Announce tab.

Implementation:

  • New Announce:GetNextReadyIn() in Modules/Announce.lua — returns the shortest remaining across active profiles (0 when any profile is eligible).
  • New Announce:StartCooldownTicker() — fire-and-forget 1 Hz C_Timer.NewTicker that refreshes both views every second and self-cancels at remaining == 0. Called from Announce:Send after any successful post, and once at module-load + 3 s so /reloads mid-cooldown resume the countdown.
  • New MainWindow:SetAnnounceCooldown(remaining) in GUI/MainWindow.lua and cf.setAnnounceCooldown(remaining) in Modules/compactFrame.lua — each adds a centred font-string overlay to its horn icon. While remaining > 0: hide the icon texture, show the countdown, set an OnClick guard. At 0: restore.

Conversation-tab popout suppression now covers classic-family too

Field-reported bug: on TBC Classic, with "Hide outgoing whisper echoes" enabled, the whisper-conversation tab popup still appeared even though the chat-line echo was suppressed.

Cause: v2.1.12 restricted fn.suppressConversationTabFor (the 4-sweep FCF_Close chain) to retail only after the same 4-sweep approach was observed causing chat-frame bobbing on TBC. That fix was overcorrected — TBC users lost tab suppression entirely.

Fix: re-introduced fn.installConversationTabSuppressionHook (functions.lua) — a hooksecurefunc("FCF_OpenTemporaryWindow", ...) that fires synchronously with the popout creation on classic-family clients (Classic Era / TBC / Wrath / Cata, where the popout DOES route through that API). Hook checks our removeMsgList deadline for the target; if active and sendMSG is on, calls closeDedicatedWhisperFramesForTarget(chatTarget) to close the freshly-created frame ONCE — no sweep, no bobbing. Retail's path doesn't fire the hook so the existing retail-only multi-sweep keeps doing its job. Hook installed idempotently from FGI_Core.lua OnInitialize.

Sync timeout 10 → 90 s + partial-success reporting

Field-reported bug: on TBC Classic with three active FGI recruiters, every full multi-chunk sync session ended with <FGI> Sync failed: X timed out — Y (may be in a raid, instance, or combat) even though the user's anti-spam / blacklist lists were actively gaining entries from peers.

Two issues, both fixed:

  • Misleading message. The lists were gaining entries from FGISYNC_PREFIX_G REMEMBER broadcasts (the per-invite single-message sync, separate from the multi-chunk full-sync handshake). The multi-chunk sync was timing out mid-transfer, but the per-chunk apply pass at functions.lua:4027-4051 was correctly applying each chunk's contents to DB.realm[Sync.type] as it arrived. The "failed" print didn't distinguish "transferred 0 chunks then timed out" (real failure) from "transferred N chunks then timed out" (partial success).

    Fix: added Sync.chunksReceived counter (initialised in the Sync table at functions.lua, reset in restoreSyncDefaultValues, incremented on each MULTI_* chunk in the ESTABLISHED handler). closeConnect now branches on this counter: > 0 routes to a new fn.onSyncPartial(partner, chunks) callback that prints <FGI> Sync interrupted with X — received N chunk(s); will resume on next sync in amber. The queued re-broadcast on session close still fires automatically — the next sync session picks up where the interrupted one left off via Sync.cache.

  • Timeout too tight for ChatThrottleLib BULK. Sync data chunks are sent at BULK priority — they yield to ALERT and NORMAL traffic on CTL, so a busy channel can produce 15-90 s gaps between BULK chunk dispatches. Each chunk receipt resets the timeout, but if the inter-chunk gap exceeds the timeout, the session dies. The previous 10 s value was firing closeConnect within the first one or two chunks.

    Fix: bumped FGI_MAXSYNCHWAIT from 10 → 90 s in FGI_Constants.lua. 90 s gives BULK starvation events enough headroom to resolve without killing the session. Note: the underlying CTL bandwidth ceiling (~800 bytes/s across all addons) is a fundamental limit; in a heavily congested channel some sessions will still time out and the partial-success path applies.

Timestamp prefix on debug log

fn.debug (functions.lua) now prepends [HH:MM:SS.ms] (grey, wall-clock + ms precision) to every line. Useful for diagnosing async issues (sync chunk-to-chunk gaps, whisper-echo deadline behaviour, announce cooldown transitions) — WoW's own chat-window timestamp omits ms, so we have to provide it in the message body.

Forward-looking note

A v2.1.15+ refactor to migrate sync from raw ChatThrottleLib + manual chunking/ACK protocol to AceComm-3.0 + AceCommQueue is on deck. AceCommQueue (Ian's library, already shipped in _classic_era_/Interface/AddOns/AceCommQueue-1.0/) serializes SendCommMessage per (prefix, dist, target) so AceComm's automatic chunking can't interleave with concurrent messages — addressing a different class of bug than today's BULK starvation. Switching to AceComm replaces ~300 lines of manual chunking/ACK code in functions.lua with SendCommMessage + RegisterComm + callback-on-complete; AceCommQueue then layers on top to bound chunk ordering. Out of scope for v2.1.14 (substantial protocol change); tracked as a v2.1.15+ work item.

Modified:

  • FGI_Constants.luaFGI_MAXSYNCHWAIT = 90.
  • functions.luafn.refreshWhisperHideDeadline, FGI_WHISPER_HIDE_WINDOW = 60, addon.Tooltip.Owner(frame, tooltipHeight), Sync.chunksReceived + fn.onSyncPartial, fn.installConversationTabSuppressionHook restored as a real hook, fn.debug timestamp prefix.
  • FGI_Core.lua — auto-welcome-whisper path calls fn.refreshWhisperHideDeadline before its direct SendChatMessage.
  • Modules/Announce.luaAnnounce:GetNextReadyIn, Announce:StartCooldownTicker, post-Send ticker kick, module-load delayed kick.
  • GUI/MainWindow.lua — announce icon gains a cooldown font-string overlay + MainWindow:SetAnnounceCooldown; OnClick guard.
  • Modules/compactFrame.luamakeTitleIcon gains tooltipHeight param; per-tooltip height hints throughout; cf.setAnnounceCooldown mirror of the main-window helper; OnClick guard.

[v2.1.13] (2026-05-21) — Guild policy expanded to officers

Officers can now push guild policy

User request: "Folks want to update the current GM-only messages expanded to include officers." The v2.1.5 Guild Policy feature (forced recruitment message + minimum anti-spam retention, broadcast to every FGI user in the guild) was gated on select(3, GetGuildInfo("player")) == 0 — only the Guild Master could push or edit the policy. Officers saw the policy in their settings but couldn't modify it.

The gate widens in v2.1.13 to include officers, where "officer" is defined as any rank with promote-rank permission (CanGuildPromote()). The choice of CanGuildPromote over alternatives:

  • CanEditOfficerNote() — sometimes granted to recruiter / veteran ranks that shouldn't have policy-edit authority.
  • CanGuildInvite() — every FGI user has this by definition (FGI requires it to invite), so the gate would functionally be "anyone using FGI", which over-grants.
  • CanGuildPromote() — the cleanest officer signal. Promote-rank permission is the WoW permission flag most strongly tied to officer-level trust, and is rarely granted to junior ranks.

LibGuildRoster tracks rankIndex and rankName per member but doesn't itself classify ranks as officer vs not (because WoW has no built-in officer flag — only permission sets). The per-player Blizzard API CanGuildPromote() is the canonical check; LibGuildRoster's role is reduced to displaying the setter's rank name in lock notices (Locked by guild policy (set by Xxxx (Officer Council))).

Gate consolidation

Five repeating local r = select(3, GetGuildInfo("player")); r == nil or r ~= 0 blocks across the four policy widgets (toggle, message, anti-spam dropdown, push button) replaced with two module-level helpers in GUI/SettingsPanel.lua:

  • canEditGuildPolicy()IsGuildLeader() or CanGuildPromote(). Defensive nil-guards on each API; fails closed if both are missing on some future client variant. The 4 widget disabled callbacks call not canEditGuildPolicy(); the inline "Only the X can change this" tooltip texts check the same predicate.

  • policySetByLabel(p) → builds "Xxxx (rankName)" by looking up the setter's rankName in LibGuildRoster's roster. Falls back to bare name + cached setByRank from the policy payload if the roster lookup misses (setter offline / not yet seen in local roster / cross-realm). Used in the two lock notices on clearDBtimes (anti-spam expiry slider) and bodyEditor (recruitment-template editor).

Wire-protocol additions

The GMPOLICY broadcast payload gains a new optional setByRank field carrying the pusher's rank index. Receivers in functions.lua capture it into DB.factionrealm.gmPolicy.setByRank so the policySetByLabel helper can fall back to a "(Guild Master)" suffix when the LibGuildRoster lookup misses.

Forward-compatible both directions:

  • Pre-v2.1.13 receiver gets a payload with setByRank → ignores the unknown field, behaviour unchanged.
  • v2.1.13+ receiver gets a payload from a pre-v2.1.13 pusher → setByRank is nil, the label helper falls back to the bare name (still accurate, just less descriptive).

The receipt-print line on the receiver side now appends (Guild Master) when setByRank == 0: Guild policy updated by Pimptasty (Guild Master).

Conflict resolution

Pure last-write semantics, same as v2.1.5: whichever push lands most recently is the active policy, regardless of who pushed it. The setBy field provides accountability — if the GM disagrees with an officer's push, they can just push their own. No "GM-priority" override mechanism was added; the existing audit trail is sufficient.

Copy revisions

  • gmPolicyHeader desc — rewritten to mention officers and call out the last-write semantics.
  • gmPolicyAntiSpam desc — was "Officers cannot set an anti-spam expiry shorter than this." Reworded to "All guild members using FGI will be unable to set an anti-spam expiry shorter than this value." The old "Officers" was used loosely for "recruiters" and would conflict with the new officer-as-policy-pusher model.
  • Inline gate tooltips — "Only the Guild Master can change this setting" → "Only the Guild Master or officers (rank with promote permission) can change this setting".
  • clearDBtimes lock notice — "Minimum locked by GM policy" → "Minimum locked by guild policy"; setter label uses policySetByLabel(p).
  • bodyEditor lock notice — same renaming + policySetByLabel(p).

DB key gmPolicy left alone (renaming would require schema migration; the user-facing copy already calls it "Guild Policy"). Internal message type GMPOLICY also unchanged.

Modified:

  • GUI/SettingsPanel.luacanEditGuildPolicy / policySetByLabel helpers added at module scope; 4 widget disabled callbacks rewritten; 4 desc closures rewritten; push button stamps setByRank into payload and DB.
  • functions.lua — GMPOLICY receiver captures setByRank; receipt print adds "(Guild Master)" suffix on rank 0; stale "Non-GMs cannot push back" comment updated.

[v2.1.12] (2026-05-21) — Settings + main-UI tooltip audit, retail multi-part whisper-echo hide (UI-014), conversation-tab popout suppression, whisper-filter secret-string defense (UI-015)

Retail whisper-filter secret-string defense (UI-015)

User report after extended bulk-invite session: "Hide outgoing whisper echoes" worked fine for the first ~20 invites, then silently stopped working — every subsequent recruitment whisper echoed into chat as To [Recipient]: ... despite the toggle still being on. The user's chat log showed a burst of achievement broadcasts immediately before the regression started.

Same family of bug as v2.1.9's UI-011 (Scan.lua) and v2.1.11's UI-013 (FGI_UnitTooltip.lua). On retail, the CHAT_MSG_WHISPER_INFORM target field can carry a Blizzard "secret string" value when the event ultimately traces back to a protected context (achievement broadcasts, raid warnings, M+ death notifications). fn.hideWhisper was passing that target straight into fn:fullPlayerName(name), whose first call (name:find("%-")) throws "attempt to perform string operation on a secret string value" on the secret. The throw from inside a chat-filter callback leaks the message (filter returns no value, treated as false → message displays), and is consistent with the user's symptom of cumulative degradation after a high-traffic period containing protected-context events.

The defense is the same pattern UI-011 / UI-013 established:

  • fn.hideWhisper (functions.lua) now pcalls fn.fullPlayerName(name) on retail. If the call throws (because name is a secret value), the filter returns false (let the message through) instead of dying — the chat-line for that one event leaks, but the filter survives the rest of the session.
  • The conversation-tab sweep helper closeDedicatedWhisperFramesForTarget also hardened: every chatTarget:lower() op on each chat frame is wrapped in a retail-aware safeLower(s) helper so a secret-string chatTarget on any single frame can't throw from inside one of the four scheduled C_Timer.After callbacks.

Classic-family clients skip both pcall overheads — secret strings don't exist on those versions.

Modified: functions.luafn.hideWhisper and closeDedicatedWhisperFramesForTarget. New safeLower helper.

Conversation-tab popout suppression

User request: "I have WoW set up to open a new tab on a whisper chat conversation. I want this for real conversations, but FGI's recruitment whispers are leaving a litter of empty tabs even though the echo filter hides the text. Can the tab not open for FGI's whispers but still open for real responses?"

The popout is WoW's standard behaviour when Social options → Chat → Whisper mode → Conversation is set — every new whisper target gets its own dedicated chat frame on first WHISPER_INFORM. The echo filter hides the chat-line text but the tab itself remains, so a bulk-invite session leaves dozens of empty tabs.

Attempted two designs before landing on the working one:

  1. C_Timer.After(0, ...) after each send (functions.lua fn:sendWhisper, pre-fix) — capture hadDedicatedFrame before sending, schedule a next-tick close if no frame existed. Failed on retail: the popout's frame setup wasn't complete by the next tick, and FCFManager_GetDedicatedFrame returned nil so the close found no frame.
  2. hooksecurefunc("FCF_OpenTemporaryWindow", ...) — hook the frame-creation API directly so the close runs synchronously with creation. Failed because modern retail (TWW 11.x) routes whisper popouts through a different code path; the hook never fires for retail's popout.
  3. (Working) Iterate CHAT_FRAMES directly. Drop the hook, drop the time-of-send capture. When fn.hideWhisper suppresses an echo, call fn.suppressConversationTabFor(target). That helper does an immediate sweep plus three scheduled sweeps (0 s, 0.1 s, 0.5 s) of CHAT_FRAMES, finding any frame with chatType == "WHISPER" + chatTarget == ourTarget and calling FCF_Close on it. The four sweeps catch the popout regardless of when retail's chat pipeline creates the frame. The match predicate skips DEFAULT_CHAT_FRAME (no chatType) and user-created custom frames (multiple chat-type registrations rather than a single chatType), so only auto-generated popout tabs match.

Real responses from the recruit go through CHAT_MSG_WHISPER (not _INFORM), don't refresh the deadline, and don't trigger the sweep. Their popout sticks. Recruitment whispers suppressed; real conversations preserved.

Known trade-off: if the user is already in an active conversation with the recruit (pre-existing tab for that exact name), FGI's sweep closes that tab too — both share the same chatType+chatTarget match. For recruitment workflow this is rare (recruits are strangers; if you're already chatting with them, you wouldn't normally re-whisper them through FGI), so the simpler design without "had pre-existing tab" capture was kept. Adding the capture is one DB field if a user reports the trade-off matters.

Gated on DB.realm.sendMSG so users who WANT the popout for every whisper (echo suppression off) keep it.

Modified: functions.luafn.suppressConversationTabFor, closeDedicatedWhisperFramesForTarget (helper, internal). fn.hideWhisper calls the suppressor on each suppressed echo. Updated the sendMSG toggle's desc in GUI/SettingsPanel.lua to mention the tab-closing behaviour.

Retail multi-part whisper-echo hide fixed (UI-014)

User report: with "Hide outgoing whisper echoes" enabled on retail, a recruitment whisper long enough to split into two SendChatMessage calls (>255 bytes after NAME / GUILD / GUILDLINK expansion) only hid the first echo — the second echo leaked into the chat frame.

The v2.1.8 design queued one entry per split chunk in addon.removeMsgList[fullName] and the CHAT_MSG_WHISPER_INFORM filter popped the head 1:1 with arriving echoes. Reading the code, the queue logic is correct (push msg1, push msg2; pop msg1, pop msg2), and single-part whispers worked on every client. Repro on retail showed the second echo slipping past with the queue still holding its entry — root cause not pinned to a specific line, but plausible candidates are a retail-specific filter-invocation timing difference, or the CHAT_MSG_WHISPER_INFORM event dispatching differently for the second of a paired-send burst.

Rather than continue debugging the queue's failure mode, switched to a deadline approach that's robust against any miscount:

  • addon.removeMsgList[fullName] is now a GetTime() deadline (number), not a list of messages.
  • Every SendChatMessage call in fn:sendWhisper refreshes the deadline to GetTime() + FGI_WHISPER_HIDE_WINDOW (10 s).
  • fn.hideWhisper checks GetTime() < deadline. Within the window: hide. After: fall through.

Trade-off documented in functions.lua: a manual whisper to the same target within 10 s of FGI's whisper would also be hidden. For a recruitment workflow this is rare (the recruit is being whispered FROM FGI; the user is unlikely to manually whisper them inside that window).

Modified: functions.luafn.hideWhisper, fn:sendWhisper. New constant FGI_WHISPER_HIDE_WINDOW = 10. No DB schema change; same sendMSG toggle, same removeMsgList lifecycle from an external perspective.

Settings panel tooltip audit (5 fixes)

User report: the "Level range priority" dropdown on the General tab had no hover tooltip (the desc was rendered as a permanent inline label via descStyle = "inline", so hovering the chevron did nothing). Audit of GUI/SettingsPanel.lua found four other widgets missing their desc entirely:

  • levelRangePriority (GUI/SettingsPanel.lua:723) — restructured to match the Guild-tab pattern. Added a new levelRangePriorityHeader (FGI_TooltipHeader section header) above the dropdown, carrying the full Strip-vs-Filters explanation. The dropdown itself was renamed to "Source" with a short desc pointing at the header above. User feedback was that the Strip-vs-Filters explanation belonged on a section header (like every other Guild-tab section header carries its topic's explanation), not on the widget chevron.
  • autoWelcome (Guild → Welcome on join) — added a desc explaining it posts the welcome message to guild chat on member join, fires only for joins while you're online.
  • autoWelcomeWhisper (Guild → Welcome on join) — added a desc explaining the private whisper, pairs with or replaces the guild-chat welcome.
  • setNote (Guild → Notes on invite) — added a desc explaining the public-note auto-write, the 31-char cap, the Retail restriction.
  • setOfficerNote (Guild → Notes on invite) — added a desc explaining officer-note auto-write, the rank-permission requirement, the 31-char cap.

Audit confirmed every other option in the panel (~76 widgets across General, Messages, Guild, Bnet, Appearance, Hotkeys, Advanced, Debug) already had appropriate desc text.

Main-UI help "i" tooltip audit (3 fixes)

Audit of the per-tab help text in TAB_HELP (GUI/MainWindow.lua:59-212) against the actual tab implementations found drift on two tabs:

  • Scan tab — help text described strip controls as >> / +(N) / Clear / Mode, missing the Sel All and +(N)sel multi-select buttons (added in v2.1.5). Counter description listed F/S/A/X, missing the D (Declined) counter. Lvl readout description referenced "Default level range priority" setting; actual setting name is "Level range priority" (no "Default" prefix). Queue rows description didn't mention the multi-select checkbox column. Fixed all four.
  • Anti-Spam tab — help text said entries auto-prune after the period set in Settings → Main → 'Clear DB after'. Actual setting name is "Anti-spam expiry". Fixed.
  • CustomScan / Filters / Blacklist / History / Statistics / QuietZones / Announce — verified accurate against current implementations.

Compact-tray tooltip audit (3 fixes)

Audit of Locale/enUS.lua tooltip strings consumed by Modules/compactFrame.lua found two cases where the tooltip described the opposite of the code's behaviour:

  • L["rowDeclineTooltip"] (Locale/enUS.lua:176) — claimed Decline only added the player to Anti-Spam if "Remember skipped" was enabled. Verified at functions.lua:1684-1697: the Decline path (fn:invitePlayer(noInv=true)) always records to anti-spam, unconditionally. The comment at line 1689 confirms rememberSkipped "never actually fired on the Skip button, only here on Decline." Rewrote the tooltip to describe the actual behaviour.
  • L["compactToggleTooltip"] (Locale/enUS.lua:190) — text described toggling INTO compact mode ("Click to shrink the main window..."). The + button it labels lives only on the compact tray and switches BACK to the full window (the opposite direction). Rewrote.
  • L["rowSkipTooltip"] (Locale/enUS.lua:175) — accurate default-case description but missing the conditional. Compact tray Skip handler at Modules/compactFrame.lua:702-721 does gate anti-spam recording on DB.global.rememberSkipped. Added the conditional mention.

Also: compact-tray help "i" (Modules/compactFrame.lua) said "Hover the F:S:A:X counters" — actual counter strip has five letters including D (Declined). Updated to F:S:A:X:D.

Other compact-tray tooltips (scan / invite strip buttons, row Invite, row Blacklist, announce horn, settings gear, close X, counter-strip hover) all verified accurate against current implementations.

Non-English locale files (ruRU, deDE, frFR, esES, esMX, ptBR, itIT, koKR, zhCN, zhTW) keep their existing strings until a translator updates them — standard FGI pattern per CLAUDE.md.

[v2.1.11] (2026-05-20) — Retail unit-tooltip secret-value crash on dungeon zone-in (UI-013)

Retail FGI_UnitTooltip.lua secret-value crash fixed (zoning into a dungeon / hovering instanced content)

User report: zoning into a retail M+ dungeon stacked the following error twice on the way in:

bad argument #1 to 'UnitIsPlayer' (Usage: local result = UnitIsPlayer([unit, partyIndex]).
  Secret values are only allowed during untainted execution for this argument.)
[fastguildinvite/Modules/FGI_UnitTooltip.lua]:48: in function <...UnitTooltip.lua:43>
[fastguildinvite/Modules/FGI_UnitTooltip.lua]:97: in function <...UnitTooltip.lua:96>
[C]: in function 'securecallfunction'
[Blizzard_SharedXMLGame/Tooltip/TooltipDataHandler.lua]:67: ...
[Blizzard_GameTooltip/Mainline/GameTooltip.lua]:997: in function 'SetWorldCursor'
[Blizzard_UIParent/Mainline/UIParent.lua]:1274: ...

Same family of bug as v2.1.9's UI-011 Scan.lua secret-string crash, different vector. On retail Blizzard wraps certain unit tokens in protected "secret values" (M+ portals, instanced creatures, cinematic NPCs, the dungeon zone-in fly-in cursor, etc.). Our TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, ...) callback at Modules/FGI_UnitTooltip.lua:96 runs from securecallfunction for every tooltip event, including the secret-token ones. The first unit-API call in enrich()UnitIsPlayer(unit) at line 48 — throws "Secret values are only allowed during untainted execution for this argument" because the secret token can't be passed to a unit-API call from our addon-tainted context.

The if not unit then return end guard above the failing call doesn't trip because the secret-value is truthy (it's not nil, it's a sentinel object that Blizzard renders as <no value> in BugSack dumps but is internally a magic table).

Fix: pcall every unit-API call on retail. If any of UnitIsPlayer / UnitIsInMyGuild / UnitName / UnitLevel raises on a secret token, the wrapper returns false / nil / 0 and enrich() bails silently — the same outcome it would have if the unit weren't a guildie. Classic-family clients don't have secret unit tokens so they skip the pcall overhead and call the API directly.

Modified: Modules/FGI_UnitTooltip.lua — four safeUnit* wrappers around the four unit-API calls, gated on gv.isRetail. No behaviour change on guildie tooltips (they still get the "FGI: joined N days ago" / "Was level X at join" lines on retail and classic alike).

Architectural note: Both UI-011 and UI-013 are instances of the same pattern: a Blizzard secure-execution surface (CHAT_MSG_SYSTEM, TooltipDataProcessor) hands our addon a protected value, and a unit-/string-API call against that value throws. The defense is always the same — pcall the call on retail, silent bail on failure. Any future module that consumes secret-eligible data (unit tokens, system message strings, item links from secure events) needs the same wrapper. Pattern logged in docs/DEV_NOTES.md.

[v2.1.10] (2026-05-20) — Announce keybind, icon hitbox parity across main window + compact tray

Third addon-managed keybind: "Fire announce profiles"

Settings → Main → Hotkeys now has three rows: Invite, Next-scan, and the new Announce. Same addon-managed OnKeyDown listener architecture introduced in v2.1.8 — DB is the source of truth, WoW's binding system is not touched.

  • New DB slot: DB.global.keyBind.announce (per-character, defaults to nil) in FGI_Core.lua:652
  • Dispatch in the FGI_KeybindListener OnKeyDown handler at FGI_Core.lua:939-941 — matches the captured canonical key string against kb.announce and calls addon.announce:Send() on a hit. Hardware-event taint context is preserved (same path as invite / next-scan dispatch), so SendChatMessage from inside the announce flow stays inside the secure-execution model.
  • New keybinding row in GUI/SettingsPanel.lua:1496-1517announceKey table parallel to the existing inviteKey / nextSearchKey rows. set callback writes DB only (no SetBindingClick / SaveBindings).
  • Hotkeys header description updated to mention three actions.

Icon hitbox parity across main window + compact tray

User report: settings gear was only clickable in the centre, not on the edges. Two root causes addressed:

  1. Blizzard Interface\Icons\* textures render with ~8% transparent padding on every edge, so the visible art only covers ~84% of the button it lives on. Users perceive that padding as the icon's edge but it isn't part of the click target — only the central ~17×17 of a 20×20 button "felt" clickable. Fixed by cropping SetTexCoord(0.08, 0.92, 0.08, 0.92) on the Interface\Icons\ textures so the art fills the button edge to edge.
  2. The button hit-rect matched the visible 20×20 box exactly. Even with the texture filling the box, the click area was tight. Fixed by SetHitRectInsets(-2, -2, -2, -2) on every bottom-row / title-row icon, giving 2 px of click slop in every direction. The compact tray's title-row icons sit ICON_GAP px apart and the main window's row icons sit 3 px apart, so the 2 px slop stays inside the gap and can't collide with neighbour click areas.

Both fixes applied to:

  • Main window (GUI/MainWindow.lua) — gear, announce horn, help "i", compact-mode minus. Gear and horn get both fixes (Blizzard Icons atlas); help-i and compact-mode minus only get the hit-rect inset because they live in Interface\Common\ and Interface\Buttons\ respectively and don't carry the 8% padding.
  • Compact tray (Modules/compactFrame.lua) — makeTitleIcon and makeRowIcon helpers now apply the hit-rect inset unconditionally and crop TexCoord conditionally via a new isPaddedIconTexture() check on the texture path. The closeBtn (X glyph, no texture) also gets the hit-rect inset so it feels the same to click as the gear / help / announce / expand buttons next to it.

Net effect: every clickable icon in FGI now has a hit area that matches the visible icon plus 2 px of slop on every side. The gear and horn icons look subtly larger because the TexCoord crop removes the padding.

Modified:

  • FGI_Core.luakeyBind.announce default, OnKeyDown announce dispatch, header comment updated
  • GUI/SettingsPanel.luaannounceKey row, header desc updated
  • GUI/MainWindow.luaSetHitRectInsets on helpIcon + compactModeIcon
  • Modules/compactFrame.luaisPaddedIconTexture helper, hit-rect inset + conditional TexCoord crop in makeTitleIcon and makeRowIcon, hit-rect inset on closeBtn

[v2.1.9] (2026-05-20) — Retail Scan.lua secret-string crash fix

Retail Scan.lua secret-string crash fixed (CHAT_MSG_SYSTEM during M+ / raid)

User report (post-v2.1.8 push): 19 stacked errors during a Mythic+ dungeon on retail:

attempt to perform string conversion on a secret string value (execution tainted by 'FastGuildInvite')
[FastGuildInvite/Scan.lua]:22: in function <FastGuildInvite/Scan.lua:11>
[FastGuildInvite/Scan.lua]:208: in function <FastGuildInvite/Scan.lua:132>

Root cause in Modules/Scan.lua:132-208 — the pausePlayFilter:SetScript("OnEvent", ...) handler that intercepts CHAT_MSG_SYSTEM (looking for invite-decline / not-found patterns) passed the raw msg straight into playerHaveInvite(). On retail, CHAT_MSG_SYSTEM messages can carry protected "secret string" name references (M+ death notifications, achievement broadcasts, raid-warning targets, etc.). The first strfind / strsub call inside playerHaveInvite on those messages throws the "attempt to perform string conversion on a secret string value" error and marks FastGuildInvite as the tainting addon. The handler fires for every CHAT_MSG_SYSTEM event, so during a M+ run with dozens of broadcasts the same error stacked 19 times.

Fix: pcall the playerHaveInvite() call on retail. Normal (non-secret) CHAT_MSG_SYSTEM messages still flow through to the invite-decline / not-found matcher as before; secret-string messages get silently dropped (we don't care about them — they're never invite-related). Classic-family clients skip the pcall overhead since they don't have secret strings on CHAT_MSG_SYSTEM.

Latent bug noted but not fixed in this pass: the retail-specific UI_ERROR_MESSAGE branch at Modules/Scan.lua:138-200 calls pcall(playerHaveInvite, msg) while msg is still nil (the msg = text assignment happens later at line 203, after the retail-only return). That branch silently no-op's on retail, but invite-related events on retail go through CHAT_MSG_SYSTEM rather than UI_ERROR_MESSAGE anyway, so the broken branch hasn't caused user-visible issues. Flagged for cleanup in a later release alongside a wider Scan.lua handler refactor.

Modified: Modules/Scan.lua — wrapped the fall-through playerHaveInvite(msg) call in a retail-aware pcall.

[v2.1.8] (2026-05-20) — Member-history tooltips, blacklist popup restored, multi-select invite gate fix, addon-managed keybinds, whisper-echo mute fixed on retail, scan progress-bar paints 100%, Communities-menu taint fix, UI minimum-size overlap fix, Modules/ reorg

Member-history tooltips

FGI now persists per-member metadata when a player joins your guild and surfaces it in two tooltip contexts: WoW's native unit tooltip (mouseover, guild roster panel, unit frames) and a brand-new standalone tooltip when you hover a player name in chat. Both default-on, each toggleable independently in Settings → Guild → Member-history tooltips.

  • Storage layer in Modules/FGI_MemberHistory.lua (new file). Schema: DB.factionrealm.memberHistory[normalizedName] = { joinedAt = epoch, lvlAtJoin = number }. Populated from FGI_Core.lua's existing LibGuildRoster OnMemberJoined callback — wrote-through immediately for joinedAt, deferred 2.5 s for the level read so the guild roster has time to settle (mirrors the 2.5 s welcome-message delay in the same callback). addon.MemberHistory:get(name) is the public lookup the tooltip files use.

  • Native unit tooltip enrichment in Modules/FGI_UnitTooltip.lua (new file). Hooks GameTooltip:SetUnit via the modern TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, ...) on retail and the legacy OnTooltipSetUnit script hook on Classic / TBC / Wrath / Cata. Adds 1–2 lines to the native tooltip when the unit is a current guildie with member history: FGI: joined N days ago, and (when their level has grown) Was level X at join (now Y). Pre-FGI members get no extra lines (silent — we have no data on them).

  • Standalone chat-hyperlink tooltip in Modules/FGI_ChatTooltip.lua (new file). Hooks OnHyperlinkEnter / OnHyperlinkLeave on every chat frame (and re-hooks at PLAYER_LOGIN to catch frames created late). Parses |Hplayer:Name-Realm:...|h links and builds a full standalone tooltip — class-coloured name, level / class / rank, online status + zone, public note, plus FGI's member history, blacklist status, anti-spam history, and ex-member flag. Uses a dedicated FGI_ChatTooltipFrame (own GameTooltipTemplate instance, not the shared GameTooltip) so addons that hover a unit while the user hovers a chat link can't collide. Anchored beside the chat frame the hyperlink lives in, on whichever side of the screen has more room — keeps the chat content readable. The initial cut of this module missed a SetOwner(UIParent, "ANCHOR_NONE") call before ClearLines / AddLine / Show, so the tooltip rendered as an empty invisible frame and the hover felt like it did nothing. Fixed mid-pass; the rest of the module is unchanged.

  • Two new toggles in GUI/SettingsPanel.lua under the new Member-history tooltips header in the Guild sub-page: showMemberTooltip and showChatTooltip, both default on.

  • DB defaults in FGI_Core.luashowMemberTooltip = true, showChatTooltip = true added to the global defaults block.

  • Backfill policy: pre-v2.1.8 members get no history entry (FGI only knows about joins that fire OnMemberJoined while it's loaded). Tooltips silently render no extra lines for them. A future release could seed a firstObservedAt row by iterating the live guild roster on first run; intentionally deferred.

Blacklist popup restored (FGI_BLACKLIST / FGI_BLACKLIST_CHANGE ghost-name crash and silent no-op)

User report: blacklisting any player produced attempt to index field 'FGI_BLACKLIST' (a nil value) at functions.lua:797. Root cause: the v1 → v2 strip removed two StaticPopupDialogs definitions (FGI_BLACKLIST and FGI_BLACKLIST_CHANGE) that lived in the deleted blackList.lua, but left the three call sites pointing at the dead names. The FGI_BLACKLIST site (guildKickStaticPopupDialogs["FGI_BLACKLIST"].add(name)) crashed hard because .add was a method on the deleted dialog. The two FGI_BLACKLIST_CHANGE sites (chat right-click menu legacy fallback in FGI_Core.lua:142, and the /fgi blacklist <name> slash command at FGI_Core.lua:1004) silently no-op'd because StaticPopup_Show returns nil for undefined dialog names — players using the non-fast-blacklist path got no confirmation popup at all.

Fix in functions.lua — restored StaticPopupDialogs["FGI_BLACKLIST"] with the same kick / skip semantics the v1 definition had:

  • text rewritten at show-time with the head-of-queue name ("Blacklisted player %s is in your guild!")
  • button1 = "Kick" calls GuildUninvite(name) via pcall, marks the name in data2 (session "already prompted" set), pops it from data, shows the next queued name if any
  • button2 = "Skip" does the same minus the kick — still records the name in data2 so we don't re-prompt this session
  • guildKick(name) now dedupes against both data (active queue) and data2 (session set) before appending; shows the popup if it was the first add
  • Forward-declared local showNextKickPrompt so the OnAccept / OnCancel callbacks can call it for queue-drain (defined after the dialog table to keep the dialog literal compact)

Fix in FGI_Core.lua:142 (chat right-click "FGI - Black List" with fastBlacklist off) and FGI_Core.lua:1014 (/fgi blacklist <name> slash command with no reason and fastBlacklist off) — both call sites now route through the modern addon.UI.ShowBlacklistConfirm(entry, onDone) (defined in GUI/UI.lua) which is the v2 reason-input dropdown. The chat-menu site preserves the original "advance the scan queue if the blacklisted player was the head of the queue" semantics by passing an onDone callback that calls fn:invitePlayer(true). The slash-command site has no scan-advance need, so it passes just the name.

Why the popup was ever called from the slash command path: the v1 design used StaticPopupDialogs["FGI_BLACKLIST_CHANGE"] as a "you blacklisted X without giving a reason — want to enter one now?" prompt. Same idea as the modern UI.ShowBlacklistConfirm, just rendered differently. Routing the slash command to the modern path keeps the UX coherent — typing /fgi blacklist Bob with no reason and fastBlacklist off now opens the same reason-input dropdown the right-click chat menu opens, instead of silently no-op'ing.

Modified files:

  • functions.luaStaticPopupDialogs["FGI_BLACKLIST"] definition + showNextKickPrompt helper + guildKick rewrite
  • FGI_Core.lua — two call sites swapped from StaticPopup_Show("FGI_BLACKLIST_CHANGE", ...) to addon.UI.ShowBlacklistConfirm(...)

Keybinds migrated to addon-managed OnKeyDown listener (bypass WoW's binding system entirely)

User report (v2.1.8 regression, not seen on v2.1.7's fix): the Hotkeys keybind worked after clearing and re-adding in Settings → Main → Hotkeys, but stopped firing once the user closed the settings panel. Re-binding made it work again until the next panel close. Environment-specific — the maintainer's local test of v2.1.7 didn't repro this; another player did.

Root cause was almost certainly AceConfigDialog's keybinding widget interacting with Blizzard's secure binding system on hide. The widget wraps Blizzard's keybinding primitives, and at panel-close it can run a binding-state restore that resets the in-memory binding for the captured key. SetBindingClick from the Hotkeys row's set callback set the binding correctly, SaveBindings persisted it to bindings.wtf — but the close-time state restore wiped the in-memory binding for the rest of the session. /reload brought it back via OnInitialize re-applying; within the same session, only re-binding fixed it.

A mid-cycle defensive-reapply attempt (3 hooks on OnInitialize / PLAYER_ENTERING_WORLD / AceConfigDialog:Close) covered the reported trigger but left the system fundamentally fragile — anything in WoW or another addon that touches the binding state can still drop us, and we'd need to keep adding hooks for every new trigger discovered.

Fix in this release: stop using WoW's binding system entirely. FGI keybinds are now dispatched by an OnKeyDown handler on a hidden frame the addon owns. The dispatch path:

  1. Storage (unchanged): DB.global.keyBind = { invite = "F6", nextSearch = "SHIFT-F7" }. AceConfigDialog's keybinding widget captures user input as before and writes the canonical "ALT-CTRL-SHIFT-KEY" string into DB; the set callbacks in GUI/SettingsPanel.lua only write to DB now and do not call SetBindingClick / SetBinding / SaveBindings.
  2. Listener in FGI_Core.lua: a hidden FGI_KeybindListener frame parented to UIParent with EnableKeyboard(true) and SetPropagateKeyboardInput(true). The OnKeyDown handler reconstructs the canonical key string with modifier prefixes (ALT-CTRL-SHIFT- in alphabetical order, matching the AceConfigDialog widget's format), matches against DB.global.keyBind.{invite, nextSearch}, and dispatches to fn:invitePlayer() or fn:nextSearch() on a hit. Propagation stays on so the game's normal key handling continues afterward (movement / spells / chat keys behave unchanged).
  3. Suppression: when GetCurrentKeyBoardFocus() returns non-nil — chat EditBox while typing, AceConfigDialog keybinding widget while capturing, any other addon's input field — the handler early-returns. The user can type and rebind without firing the action.
  4. Hardware-event taint: OnKeyDown runs in a hardware-event taint context, the same way Frame:OnClick does, so calling protected functions like C_GuildInfo.Invite from inside the handler stays within that context and the secure-execution model accepts the call. This is the same mechanism SetBindingClick was using internally via Click() dispatch — we just do it directly.
  5. Migration cleanup: OnInitialize iterates GetBinding(i) once per session and clears any WoW bindings that still point at FGI_CompactInviteBtn / FGI_CompactScanBtn from v2.1.7-era SetBindingClick calls. Without this, the post-upgrade first session would see both the stale WoW binding and our new listener fire for the same keypress, double-invoking the action. Idempotent — subsequent loads find nothing.

What's gone:

  • The previous addon.reapplyKeybinds helper and its three hook registrations (OnInitialize / PLAYER_ENTERING_WORLD / AceConfigDialog:Close) — no longer needed because nothing in WoW's binding system is involved in our keybinds anymore.
  • SetBindingClick / SetBinding / SaveBindings calls from the settings-panel keybind set callbacks — DB is now the only persistence path.

WoW's native Key Bindings panel never shows FGI bindings (matches the v2.1.5 design — bindings live inside the addon's Hotkeys panel only).

Modified files:

  • FGI_Core.lua — OnInitialize cleanup block + FGI_KeybindListener frame setup; addon.reapplyKeybinds helper removed
  • GUI/SettingsPanel.lua — both keybind set callbacks reduced to DB writes only

"Hide outgoing whisper echoes" toggle now actually hides them on retail (fn.hideWhisper text-match was too strict)

User report: the "Hide outgoing whisper echoes" toggle in Settings → Main → Chat noise is checked but FGI's recruitment whispers still echo to chat as To [Player]: ... on retail. The user confirmed it isn't a third-party-addon conflict — they disabled everything except FGI + Ace3 and the echoes still leaked through.

Root cause in functions.lua:1485 (fn.hideWhisper, the registered CHAT_MSG_WHISPER_INFORM filter): the v1.x logic was an exact text-match between the event's message text and the pre-send copy stored in addon.removeMsgList[key]. Only when both matched would the filter return true (suppress the echo). On Classic the post-send event text equals the pre-send stored text byte-for-byte, so the match succeeded reliably. On retail the chat pipeline can normalise the event text in subtle ways (hyperlink whitespace, special-character handling, etc.) so the comparison silently failed for every echo despite the queue being correctly populated. Echo through.

Fix: drop the text comparison; match on target name only.

  • fn.hideWhisper now resolves the canonical fullPlayerName(name) key from the event's target arg, looks up addon.removeMsgList[key], and if there's at least one queued message, pops the head and returns true. Multi-part whispers (split by fn:messageSplit for messages > 255 bytes) are inserted in order and the echoes arrive in the same order, so head-pop drains the queue 1:1 with the echoes for that target.
  • Surgical scope is preserved: removeMsgList is keyed by the names FGI is currently sending to (populated in fn:sendWhisper immediately before each SendChatMessage / C_ChatInfo.SendChatMessage call), and entries are cleared by the head-pop as echoes drain. Manual whispers to players FGI isn't currently tracking still echo normally.
  • No DB schema change; same sendMSG toggle, same removeMsgList lifecycle. Only the filter's accept-or-reject decision changed.

Modified: functions.luafn.hideWhisper body.

Scan-progress bar now paints at 100% on completion (was disappearing prematurely)

User report: the orange scan-progress fill in the bottom status bar "pops up, then goes away" — appears during scanning, then disappears the moment the scan finishes without ever showing the fill at 100%. Reads as a glitch.

Root cause in GUI/MainWindow.lua:279 (MainWindow:SetScanProgress): the hide gate was not total or total <= 0 or not done or done >= total. The done >= total clause meant the moment the last query's response came in (incrementing progressDone to equal progressTotal), the texture got hidden — before any tick painted the bar at the 100% fraction. The visible sequence was effectively 80% → hidden, with the 100% paint never happening because the only call site (MainWindow:RefreshStatusBar driven by ScanTab.Refresh) only fires AFTER progressDone updates, by which point the gate had already kicked in.

Fix in GUI/MainWindow.lua:

  • SetScanProgress: hide gate relaxed to total <= 0 or done <= 0 (hide only when no scan has been started this session, e.g. after Clear). Added math.min(1, ...) clamp on the fraction so done > total (shouldn't happen but defensive) paints at 100% rather than overflowing. The bar now paints at 100% when the final query completes and stays at 100% until the next scan starts.
  • RefreshStatusBar: added a "Scan complete" status-text branch when onScan and total > 0 and done >= total. The user sees v2.1.8 | Scan complete | 5 / 5 queries alongside the full bar after their scan finishes, rather than the bar vanishing and the text reverting to just the version string.
  • The bar clears under the same conditions as before: switching away from the Scan tab (the onScan gate fails), starting a new scan (the new progressTotal triggers a 0% repaint that fills back up), or clicking Clear (progressTotal = 0 → bar hides).

Modified: GUI/MainWindow.luaSetScanProgress and RefreshStatusBar.

UI minimum-size overlap fix (main-window Scan tab + compact tray)

User report: at default size, both the main-window Scan tab and the compact tray showed widget overlap — specifically, the F:n S:n A:n X:n D:n counters bleeding into adjacent widgets. Pre-v2.1.8 minimum-size constants hadn't been updated as new strip widgets piled in over the v2.0+ feature cycle.

Scan tab fix in GUI/Tabs/Scan.lua:

  • Counters fontstring had only a LEFT anchor (LEFT to modeDD's RIGHT) and extended rightward unbounded. At typical 2-3 digit values, the text reached x≈700 px while the RIGHT-anchored lvlContainer (120 px wide) started at x≈694 px (strip_width 820 − 126) — overlapping by ~26 px on every scan with any activity.
  • Added a RIGHT anchor on counters pinning to lvlContainer's LEFT − 8 px. The fontstring now self-clips at the level-readout boundary regardless of width.
  • Bumped ScanTab.MIN_WIDTH from 840 to 900 so typical 2-3 digit counter values fit comfortably without clipping at the new safety boundary. Worst-case 4-digit values still clip cleanly at the boundary instead of overlapping the Lvl labels.

Compact tray fix in Modules/compactFrame.lua:

  • The 32-wide invite button held a +(N) label anchored CENTER. When the queue count grew past single digits the label extended past the button's frame in both directions (+(99) ≈ 42 px wide on a 32 px button, overflowing 5 px each side). The LEFT overflow crossed into the counter-area boundary 6 px to its left — visual overlap between the button label and the counter text.
  • Bumped invite button width from 32 to 48 px (matches the main UI's INV_BTN_W constant) so +(NNN) labels fit inside the frame without overflow.
  • Moved counters' RIGHT anchor from -182 to -198 to clear the wider invite button while keeping the 6-px safety gap to its left edge.
  • Bumped MIN_WIDTH from 300 to 380 so the counter area still accommodates typical 2-digit "F:99 S:99 A:99 X:99 D:99" text after the wider right-side reservation. 3-4 digit pathological values still clip cleanly inside the counter area as before; they no longer collide with the invite button.

Modified files:

  • GUI/Tabs/Scan.luaScanTab.MIN_WIDTH constant + counters RIGHT anchor wired after lvlContainer exists
  • Modules/compactFrame.luaMIN_WIDTH constant, invite-button width, counters' RIGHT anchor offset, comment-block update

Multi-select invite hardware-event-gate bug fixed

User report: clicking the +(N)sel button on the Scan tab to invite multiple checked players produced a stream of [ADDON_ACTION_BLOCKED] AddOn 'fastguildinvite' tried to call the protected function 'Invite()' errors. The first invite would fire correctly, every subsequent one in the same click frame was refused.

Root cause: the v2.1.5 design (GUI/Tabs/Scan.lua:477-493 pre-fix) iterated every selected player in a single OnClick handler and called fn:invitePlayer(false, idx) synchronously for each one. WoW's protected-function gate requires a real hardware event (mouse click or keypress) for each call to C_GuildInfo.Invite (retail) or GuildInvite (Classic-family). The user's mouse click is one hardware-event credit — consumed by the first invite. Every invite after that in the same click frame fails the gate and fires ADDON_ACTION_BLOCKED. Timers don't help (timer callbacks aren't hardware events), coroutines don't help, and the same constraint applies on every WoW version. The feature as designed was unbuildable — there is no API path that fires N invites from a single user gesture.

Fix in GUI/Tabs/Scan.lua:474-503 (the +(N)sel button's OnClick handler): the button now invites one selected player per click, scanning the candidate list from the tail forward so the highest-index selected row goes first (invite removes the row, so taking from the tail keeps subsequent click-target indices stable for the rows we'll fire on the next click). The selection state is decremented per click (deselect-by-name before the invite call), so the live counter in the button label tracks "how many invites left to fire."

Same hardware-event-per-invite reality as the compact tray's regular +(N) button. The selection feature is still useful — the user checks the rows they want, hits the button repeatedly, and the counter ticks down — but each invite costs one click, on every version. The button's label is now "Invite next selected" and the tooltip explains the one-per-click constraint.

Modified: GUI/Tabs/Scan.lua+(N)sel button OnClick and tooltip body.

Communities-menu registration removed (root-cause fix for SetGuildRankOrder ADDON_ACTION_FORBIDDEN)

User report: opening the modern Communities guild roster panel, opening a member's rank dropdown, and picking a new rank produced [ADDON_ACTION_FORBIDDEN] AddOn 'FastGuildInvite' tried to call the protected function 'SetGuildRankOrder()'. FGI never appears in the stack trace — it's entirely Blizzard_Communities/GuildRoster.lua and Blizzard_Menu/Menu.lua — but the taint check names FGI as the addon that polluted the menu's secure tree.

Own-it framing. The bug isn't "Blizzard's rank flow taints us mid-call"; it's "FGI registered Menu.ModifyMenu callbacks against the two menus that uniquely contain protected-call subtrees, and that registration was a mistake." MENU_UNIT_COMMUNITIES_GUILD_MEMBER and MENU_UNIT_COMMUNITIES_WOW_MEMBER are the only menus in the v2.0+ MENU_TAGS list where rank-change dropdowns live as child submenus. Anything FGI's callback does inside those menus — adding a single rootDescription:CreateButton("FGI") is enough — writes addon-taint into the menu's root description, and every child submenu inherits it, including the rank-change dropdown whose Blizzard code then can't call SetGuildRankOrder() cleanly. There's no safe way to modify a Blizzard menu that contains protected-call subtrees; the only correct action is to not modify those menus.

Fix: both Communities tags removed from Modules/FGI_ChatMenu.lua's MENU_TAGS. FGI's chat-menu callback no longer runs in the Communities guild-roster context, so its taint is never written into a menu that hosts a rank-change dropdown. We're not "in the flow" anymore — by construction. FGI integration is preserved everywhere else: chat (MENU_UNIT_CHAT_ROSTER, the original ask), party (MENU_UNIT_PARTY), raid (MENU_UNIT_RAID_PLAYER / MENU_UNIT_RAID), friends list (MENU_UNIT_FRIEND), the classic-style guild panel (MENU_UNIT_GUILD_MEMBER), target (MENU_UNIT_TARGET), focus (MENU_UNIT_FOCUS), and enemy player (MENU_UNIT_ENEMY_PLAYER).

Cost: no FGI submenu when right-clicking a member in the modern Communities guild panel. Trade-off is correct — adding it back via Menu.ModifyMenu would re-introduce the taint vector for any officer with rank-change permissions. If users miss the Communities right-click integration enough to justify it, a future release could mimic the submenu via a custom widget that doesn't touch the secure menu tree; deferred until there's demand.

Modules/ reorganisation

The addon root had grown to 18 .lua files mixing bootstrap, core, hooks, persistent stores, and feature modules. Reorganised into a two-tier layout:

  • Root (bootstrap / foundation): init.lua, FGI_Constants.lua, FGI_Compatibility.lua, FGI_APICompat.lua, FGI_Core.lua, functions.lua. These define the addon namespace, version detection, API wrappers, and the core invite / scan flow that everything else builds on.
  • Modules/: 13 files moved here — every feature module, hook, persistent-store module, and one-shot utility. Moved files: Announce.lua, compactFrame.lua, customInterface.lua, debug.lua, dump.lua, FGI_ChatMenu.lua, FGI_ChatTooltip.lua (new), FGI_MemberHistory.lua (new), FGI_ScanGroups.lua, FGI_UnitTooltip.lua (new), history.lua, intro.lua, Scan.lua.
  • Unchanged: GUI/, Libs/, Locale/, fonts/, img/ directories. Files inside those folders are unaffected.

Mechanics:

  • 10 existing tracked files moved via git mv (preserves blame history).
  • 3 new v2.1.8 files (FGI_MemberHistory.lua, FGI_UnitTooltip.lua, FGI_ChatTooltip.lua) created directly inside Modules/.
  • All 5 TOC files (FastGuildInvite.toc, _BCC, _Wrath, _Cata, _Mainline) updated to reference each moved file as Modules\filename.lua (Windows backslash, matches Blizzard TOC convention).
  • Load order preserved — the TOC reorder respects the existing dependency sequence (history before ChatMenu before ScanGroups before Core, etc.).
  • wow-version-replication.ps1 watcher copies the new layout to _retail_ / _anniversary_ / _classic_ trees automatically; ghost root files in the destination trees (including a stray security.lua left over from v1.x in _retail_ / _anniversary_) cleaned up by hand since the watcher is additive only.
  • Markdown link paths in CHANGELOG.md updated for the 13 moved files (30 link rewrites in one pass with sed). Historical CHANGELOG entries from v1.x and early v2 contain link refs to long-removed files (mainFrame.lua, statistic.lua, antiSpamList.lua, etc.) — those were already broken before this reorg and were left alone.
  • CLAUDE.md updated with a new "Repository layout (v2.1.8+)" bullet documenting the convention, plus a cleanup of the stale "Look at existing files first" bullet that mentioned ghost files (statistic.lua, mainFrame.lua, guild.lua) which haven't existed since v1.x.

Other

  • CLAUDE.md records that the user runs BugSack + BugGrabber and doesn't need to be asked to enable Lua errors. Saves a round-trip on every future bug report.
  • Lint cleanup in Modules/FGI_ChatTooltip.lua: removed unused trailing params from the OnHyperlinkEnter / OnHyperlinkLeave handler signatures. Lua silently discards extra args from the script's caller, so the shorter signature is functionally identical and silences the language-server unused-local hint that the leading-underscore convention didn't suppress.

[v2.1.7] (2026-05-19) — Byte-aware chat-message editors, keybind drop-out fix, docs overhaul

Live byte counter on chat-message template editors

WoW's SendChatMessage is hard-capped at 255 bytes per message, not 255 characters. The existing FGI editors enforced a SetMaxLetters(255) letter cap, which is wrong for non-ASCII users: a Cyrillic template hits the byte limit at ~127 letters (UTF-8 is 2 bytes per Cyrillic character) but the editor would silently accept input up to 255 letters, then split or get refused at send time. Korean and Chinese users are similarly affected (3 bytes per character). On top of that, FGI's three placeholder substitutions (NAME, GUILD, GUILDLINK) inflate the on-wire byte count well beyond what the user sees in the editor — GUILDLINK in particular expands to ~120 bytes for the club-finder hyperlink.

This release adds a live byte readout to the chat-message editors, with full placeholder-expansion accounting, so the number on screen is the same number SendChatMessage will see at send time.

  • New helper fn:estimateSentBytes(template, mode) in functions.lua:1353. Mirrors fn:msgMod's substitution order (NAME → GUILDLINK → GUILD) without firing its side effects (no chat prints on missing guild link). Worst-case sentinel for NAME is 24 bytes (cross-realm "Playernamename-Realmname"); GUILD reads the live GetGuildInfo("player") result; GUILDLINK reads the cached link from DB.factionrealm.guildLinks or falls back to a 120-byte placeholder. Literal-escapes % in cached link content before passing to gsub so an unfortunate club-finder link can't break the estimator. Returns (bytes, status, chunkCount) where status is one of "ok", "overflow" (single-mode, exceeds 255), "split" (split-mode, fn:messageSplit chunks it into N whispers), or "faildrop" (split-mode, single token > 255 bytes — fn:messageSplit would silently drop the message).

  • New custom AceGUI widget FGI_TooltipInputBytes in GUI/SettingsPanel.lua:402. Cloned from FGI_TooltipInput with a byteLabel fontstring added on the labelButton row, right-anchored. Updates via OnTextChanged on every keystroke and on programmatic SetText (so AceConfig redraws populate the initial state correctly). Grey under 80 % of the budget, amber 80–100 %, red over 100 %. Hardcoded to "single" mode (no fn:messageSplit fallback) because the editors that use it — Welcome message and Welcome whisper — go direct to SendChatMessage. SetMaxLetters(0) so non-ASCII users aren't blocked at 256 letters; the byte readout reports the true budget and the existing send-time clip is the final safety net.

  • Wired up two single-line editors to use FGI_TooltipInputBytes (one-line dialogControl change each):

  • Extended desc callbacks on two multiline editors to include a hover-checkpoint byte readout:

    Both use "split" mode since the content is sent via fn:sendWhisperfn:messageSplit. Multiline editors use AceConfig's stock MultiLineEditBox widget from Ace3 (not vendored by FGI) so a live in-widget counter isn't trivially achievable; the desc annotation refreshes on every hover, which is the natural editing rhythm for template-body work — type a block, hover the label to check, adjust.

  • Live byte counter on the Announce tab msgInput in GUI/Tabs/Announce.lua:284. Added a msgBytes fontstring anchored BOTTOMRIGHT to msgInput's TOPRIGHT, in the same row as the "Message" label. Updates on OnTextChanged. Replaced the SetMaxLetters(255) letter cap with SetMaxLetters(0); the byte readout reports the actual budget. The "Message body" tooltip was updated to reference the byte readout instead of claiming "the input stops accepting keystrokes at 255".

  • Locale keys added to Locale/enUS.lua (other locales fall through to the English defaults — translations welcome):

    • byteCountOk = "%d / 255 bytes"
    • byteCountOverflow = "%d / 255 bytes — exceeds chat limit"
    • byteCountSplit = "%d bytes — sends as %d whispers"
    • byteCountFailDrop = "single word exceeds 255 bytes — message won't send"

Documentation overhaul

  • README.md repurposed as a player-facing quickstart. The previous version was a long feature list that duplicated docs/Curseforge_Description.html and carried several stale claims: v1 minimap behavior (left-click invite / shift-click pause / right-click main window, all wrong for v2), the removed DB.global.rememberAll setting, a "Start Scanning" button that no longer exists, ianjplamondon-cyber as maintainer (should be Pimptasty), "Message Preview" and "Custom Filter Logic" claims with no code backing, and a Sync-shares-invited-players claim that understated the actual scope (sync also covers blacklist, leave list, and tombstones). The new README is ~55 lines: a one-paragraph intro, a 6-step quickstart that maps to the actual v2 UI flow (minimap → Filters tab → >> button → + per row), a markdown table of all 11 slash commands (the old README was missing /fgi intro, /fgi dump, /fgi debug, /fgi resetWindowsPos), a pointer to in-game settings, and a Discord-only support link.

  • docs/Curseforge_Description.html Community section now links to Discord instead of GitHub, matching README.md. The "older patch notes are kept in CHANGELOG.md" line removed its GitHub link too — CHANGELOG.md ships with the addon, so users can read it directly without leaving the game.

  • .pkgmeta cleanup:

    • Added docs/ to ignore — all dev-docs (DEV_NOTES.md, FGI_BUGS.md, Feature_Improvements.md, RETAIL_SUPPORT_DESIGN.md, the various *-plan.md files, and Curseforge_Description.html) now excluded from the CurseForge zip in one rule.
    • Added explicit *.ps1 alongside the existing **/*.ps1 for root-level redundancy in case the packager's glob semantics differ from Python's.
    • Removed the redundant DEV_NOTES.md and FGI_BUGS.md individual lines (now covered by the new docs/ rule).
  • CLAUDE.md updated with a new bullet documenting README.md's role and update triggers (only when something user-facing changes: new/removed slash commands, new keybinds, changed minimap behavior, new top-level tabs, removed features, or credits/maintainer changes — not every patch). The bullet also codifies the Discord-only-external-link rule across both player-facing docs.

Keybind drop-out bug fixed (re-targeted bindings to real visible buttons)

User report: the configured Invite next player / Start next /who scan hotkeys work for a while, then silently stop firing. The dead state survives /reload and a full client logout — only clearing and re-adding the keybind in Settings → Main → Hotkeys restores it (until the next drop). Reported on Retail; visual confirmation via repro video of spammed key presses producing no invite/scan action.

Root cause: v2.1.3 introduced a hidden-orphan-button pattern that v2.1.5's "v1-style" claim quietly preserved. init.lua created two hidden Button frames named FGI_InviteBindBtn and FGI_NextSearchBindBtn with parent = nil, and SetBindingClick targeted those names. The OnClick handlers dispatched to FGI.functions[fnName] via a runtime lookup.

The pattern diverges from v1.9.10's actual approach in two material ways. First, v1 bound the key directly to the real visible Invite / Pause-Play buttons on the main frame (interface.mainFrame.mainButtonsGRP.invite.frame:GetName() and the matching pausePlay), not to a separate hidden orphan. Second, v1's button OnClick was the same handler driving the mouse-click path, so the keybind and the mouse-click took identical code routes — there was no second hidden surface to keep alive.

Retail's binding resolver treats parent = nil orphan frames as transient — they're not part of any UI hierarchy. Mid-session (the exact trigger is observational, but the user's repro showed it consistently under spam-key conditions), the resolver drops the named frame from the active binding set. SaveBindings(GetCurrentBindingSet()) from the Settings panel set callback had already written the (now-stale) entry to bindings.wtf, so the next /reload or login restores it from disk — but the binding still resolves to a frame the resolver considers transient and silently fails to fire. Only re-running the Settings panel set callback re-pushes the binding into the active set fresh (until the next drop), which exactly matches the user's repro of "clearing and re-adding the keybind fixes it, until it stops again."

Fix in this release:

  • compactFrame.lua names the two visible compact-tray buttons globally as FGI_CompactInviteBtn (the +(N) invite button) and FGI_CompactScanBtn (the >> scan button). Previously both were anonymous (CreateFrame("Button", nil, title)). Behavior unchanged — these are the same buttons with the same OnClick handlers; they just have global frame names now so SetBindingClick's named-frame resolver can target them.
  • init.lua — the createBindButton helper and the two hidden-orphan-frame creations were removed entirely (~10 lines). The replaced comment block now points readers at the compact-tray buttons.
  • FGI_Core.lua:820OnInitialize's keybind re-application now calls SetBindingClick(key, "FGI_CompactInviteBtn") and SetBindingClick(key, "FGI_CompactScanBtn") instead of the deleted orphan names.
  • GUI/SettingsPanel.lua:1441, 1466 — the Hotkeys rows' set callbacks target the same renamed buttons.

The compact-frame buttons are created at file-parse time inside compactFrame.lua's do-blocks, so they exist as named globals the moment the addon loads. OnInitialize fires on ADDON_LOADED, which runs after every file is parsed, so by the time the keybind re-application block runs the targets are guaranteed to exist even though compactFrame.lua appears later in the TOC than FGI_Core.lua. The binding now resolves to a parented-and-anchored visible Button frame, which Retail's binding resolver retains in the active set across the full session.

Migration: users with the old orphan-frame entries in their bindings.wtf will have those stale entries silently dropped by WoW at next login (the names no longer resolve). OnInitialize then re-applies the binding to the new button names. No user action required; the in-memory binding is correct from the first login after the update. The bindings on disk get rewritten cleanly the next time the user changes a binding or WoW saves bindings on its own (e.g. closing the native Key Bindings panel).

[v2.1.6] (2026-05-19) — Compact tray bottom-half anchor flip fixed

User report: when the compact tray is positioned in the bottom half of the screen and the queue has ≤5 entries, inviting from the front pinned the bottom row in place and pushed the title bar UP one row per invite. In the top half the v2.0.8 fix worked as intended (title bar stayed put, frame shrank downward).

Root cause: WoW's built-in StartMoving / StopMovingOrSizing mover silently re-anchors a moving frame to whichever screen quadrant it ends up in. The compact tray's two OnDragStop handlers (compactFrame.lua:148 on the title bar and compactFrame.lua:219 on the counter strip) called cf:GetPoint(1) immediately after StopMovingOrSizing and persisted whatever anchor WoW chose. Dragging the tray below the center of the screen left cf:GetPoint(1) returning a bottom-anchored format (e.g. "BOTTOMLEFT" relative to "BOTTOMLEFT"). After that, cf:SetHeight() in cf.refresh() shrunk the frame relative to the bottom anchor — pinning the bottom row and pushing the title bar up.

The v2.0.8 PLAYER_LOGIN handler already had the right conversion inline at compactFrame.lua:750 — read cf:GetTop() / cf:GetLeft(), ClearAllPoints, re-anchor as TOPLEFT relative to UIParent BOTTOMLEFT in screen coords, persist. That kept the anchor invariant correct on fresh logins but didn't survive any subsequent drag.

Fix in compactFrame.lua:139: extracted that conversion into a saveTopLeftAnchor() helper, called from both OnDragStop handlers and from the PLAYER_LOGIN restore. Re-anchoring TOPLEFT to the same screen coords is idempotent, so it's safe to call from any state.

local function saveTopLeftAnchor()
    if not DB then return end
    local top, left = cf:GetTop(), cf:GetLeft()
    if not (top and left) then return end
    cf:ClearAllPoints()
    cf:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
    DB.global.compactFrame = DB.global.compactFrame or {}
    DB.global.compactFrame.point         = "TOPLEFT"
    DB.global.compactFrame.relativePoint = "BOTTOMLEFT"
    DB.global.compactFrame.xOfs          = left
    DB.global.compactFrame.yOfs          = top
end

After v2.1.6, the tray's anchor invariant is enforced after every drag-stop AND on every login, so cf:SetHeight() always shrinks the frame downward regardless of which screen quadrant the user dropped the tray in.

[v2.1.5] (2026-05-19) — GM policy, whisper-invite delay, multi-select queue, v1-style keybinds

Four user-requested features + a real fix for the Retail keybind warnings the v2.1.3 attempt introduced.

Feature 2 — GM Override / Force Settings (Guild Policy)

The Guild Master can now broadcast a forced recruitment message body and a minimum anti-spam retention to every FGI user in the guild. Officers can lengthen retention beyond the floor but cannot drop below it, and they can't edit the message body or pick a different template while a forced message is in effect.

  • DB default at FGI_Core.lua:528: DB.factionrealm.gmPolicy = nil. Active policy is a table { active, message, antiSpamMin, setBy }.
  • Message override at functions.lua:1462: fn:getRndMsg() short-circuits to policy.message when gmPolicy.active and gmPolicy.message ~= "". The local messageList is untouched -- when the policy clears, recruiters fall back to their own templates instantly.
  • Sync via the existing FGISYNC_PREFIX_G addon channel. Push button at GUI/SettingsPanel.lua:937 encodes a GMPOLICY message and broadcasts via ChatThrottleLib:SendAddonMessage("NORMAL", FGISYNC_PREFIX_G, data, "GUILD"). Receive handler at functions.lua:3265 stores the incoming policy into DB.factionrealm.gmPolicy and prints a notification line (gated on not addonMSG and not muteSync -- the polarity matches every other print site in the file; the Copilot draft had this inverted and I fixed it before commit).
  • GM detection via select(3, GetGuildInfo("player")) == 0 (rank 0 = Guild Master). The Guild Policy widgets in the Guild tab are disabled = function() ... end (visible-but-locked) for non-GMs rather than hidden, so everyone in the guild can see the active policy and who set it.
  • clearDBtimes floor at GUI/SettingsPanel.lua:1052: get returns max(stored_v, gmPolicy.antiSpamMin) so the dropdown visually reflects the floor even when the user's stored value is shorter. The stored value isn't mutated -- if the policy is cleared the user reverts to their original choice. v = 1 ("Never expire") is the most restrictive option and is never bumped.
  • Message-body lock at GUI/SettingsPanel.lua:1418: currentMessage and bodyEditor both extend their disabled predicate to gmPolicy.active and gmPolicy.message ~= nil. bodyEditor.desc becomes a function appending an orange "Locked by GM policy (set by ...)" note when active.

Feature 4 — Whisper-to-invite delay

  • New DB.global.whisperDelay = 0 default at FGI_Core.lua:613.
  • Mode 2 (Whisper + invite) path at functions.lua:1487 now wraps the post-whisper invite block in C_Timer.After(delay, doInvite) when whisperDelay > 0. When delay == 0 the inner function runs synchronously (unchanged from prior behaviour).
  • Slider in GUI/SettingsPanel.lua:1373: range 0-10 seconds, step 0.5, under the Messages section as order 2 (right under the messagesHeader tooltip). A short delay (1-2s) lets the candidate read the whisper before the invite dialog appears.

Feature 5 — Multi-select scan queue with batch invite

  • addon.search.selected = {} table appended to the addon.search definition at functions.lua:20 and cleared in fn.clearSearch at functions.lua:2562. Keyed by player name (same key the RowList row uses).
  • New checkbox column prepended to the queue RowList at GUI/Tabs/Scan.lua:709. key="selected", width=20, boxSize=14, onToggle writes to addon.search.selected[entry.name].
  • Two new strip buttons:
    • Sel All (48 px) -- toggles between "all selected" and "none selected" based on current count. Anchored left of the Mode dropdown.
    • +(N)sel (64 px) -- batch-invites every checked queue entry via fn:invitePlayer(false, idx), iterating highest index first so removals don't shift earlier indices. Clears addon.search.selected and refreshes after.
  • ScanTab.MIN_WIDTH bumped from 720 to 840 to fit the new buttons + the existing strip widgets.
  • ScanTab.Refresh() updates the +(N)sel label live as boxes are checked/unchecked.

Feature 1 — %GUILD% and %GUILDLINK% placeholders surfaced in tooltip

Doc-only change at GUI/SettingsPanel.lua:1352. messagesHeader.desc now mentions %GUILD% (replaced with the guild name in <> brackets) and %GUILDLINK% (Retail-only clickable guild recruitment link) alongside the existing %player% and date-format placeholders. The substitution logic in fn:msgMod was already in place from earlier releases -- this just stops users from discovering the placeholders by accident.

Keybinds: real fix replacing the v2.1.3 Bindings.xml approach

v2.1.3 tried to use Bindings.xml + BINDING_HEADER_* / BINDING_NAME_* globals so FGI keybinds would show up in ESC > Options > Key Bindings > AddOns > FastGuildInvite. On Retail (11.x) Blizzard removed <Binding> from the XML schema entirely, so the declared actions became silent no-ops and the file emitted Unrecognized XML: Binding warnings on every /reload. The Copilot draft attempted to suppress the warning by removing Bindings.xml from _Mainline.toc but kept the file on disk -- which is the wrong fix because (a) WoW auto-loads Bindings.xml from the addon root regardless of TOC reference, and (b) the bindings still didn't actually work on Retail.

v2.1.5 reverts to v1.9.10's pattern verbatim: runtime SetBindingClick(key, frameName) against hidden named Button frames. Works on every WoW version (Classic, TBC, Wrath, Cata, Retail) because SetBindingClick is a runtime API call, not an XML declaration.

  • Hidden Button frames at init.lua:21-49: FGI_InviteBindBtn and FGI_NextSearchBindBtn. Each has an OnClick that looks up FGI.functions:invitePlayer() / :nextSearch() at call time (not bind time) because functions.lua hasn't loaded yet when init.lua runs. By the time the user actually presses the bound key, the methods exist.
  • DB.global.keyBind at FGI_Core.lua:615: { invite = nil, nextSearch = nil } defaults. Persists the user's chosen keys across /reload.
  • Hotkeys settings rows at GUI/SettingsPanel.lua:1024: same two type="keybinding" rows from v2.1.3, but the get reads DB.global.keyBind.{invite,nextSearch} and the set clears the previous key (SetBinding(prev)), writes the new one (SetBindingClick(key, "FGI_InviteBindBtn")), and persists via SaveBindings(GetCurrentBindingSet()). The descriptive blurb updated to mention the bindings live only in this panel (not in WoW's native Key Bindings) -- matches v1 behaviour.
  • OnInitialize re-apply at FGI_Core.lua:806: replays SetBindingClick from DB.global.keyBind on every login. SetBindingClick is transient (doesn't survive /reload by itself) so the replay is necessary.
  • Bindings.xml at addon root is now an empty stub file containing only a comment explaining why -- WoW auto-loads any Bindings.xml from an addon root, and an absent file generates Couldn't open warnings. The stub satisfies the auto-load without declaring any <Binding> elements (which would error on Retail).
  • TOC references removed from all 5 .toc files (FastGuildInvite.toc, _BCC.toc, _Cata.toc, _Mainline.toc, _Wrath.toc).
  • BINDING_HEADER_FASTGUILDINVITE and BINDING_NAME_FGI_* globals removed from init.lua. They're meaningless without an XML declaration.

Trade-off (same as v1): FGI keybinds don't appear in WoW's native ESC > Options > Key Bindings panel. Users configure them only inside the addon's Hotkeys settings. v1 shipped this way for years and nobody complained -- recruiters go to the addon's UI to bind FGI hotkeys, not WoW's panel.

[v2.1.4] (2026-05-19) — System-message mute sledgehammer restored

Third v1 toggle restoration in two days. systemMSG was the v1.9.10 toggle ("Выключить системные сообщения" / "Turn off system messages") that registered a CHAT_MSG_SYSTEM filter unconditionally returning true -- dropping every system message that would otherwise reach the chat frame. The DB.realm.systemMSG flag survived the v2 strip and the SettingsPanel toggle existed, but the filter registration (v1's updateMsgFilters() calling ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", fn.hideSysMsg)) was lost when settings.lua was deleted. The toggle was inert.

Restored wiring

  • fn.hideSysMsg at functions.lua:2846 was already intact from v1 -- a one-line callback that returns true to drop every CHAT_MSG_SYSTEM event. No edit needed.
  • fn.updateSystemMsgFilter at functions.lua:1399 -- NEW. Mirrors fn.updateWhisperEchoFilter (today's v2.1.3 work): idempotent ChatFrame_AddMessageEventFilter / ChatFrame_RemoveMessageEventFilter against CHAT_MSG_SYSTEM keyed on DB.realm.systemMSG. Safe to call from any state.
  • OnInitialize call at FGI_Core.lua:797 -- runs fn.updateSystemMsgFilter() once after DB wiring alongside fn.updateWhisperEchoFilter(), so the persisted toggle state survives login without /reload.
  • SettingsPanel toggle at GUI/SettingsPanel.lua:1014 -- renamed from the misleading "Show invite system messages in chat" (inverted polarity vs. v1) to the honest red-labelled "Mute ALL WoW system messages (sledgehammer)". Description is a multi-line WARNING block listing each category of system message that goes silent (server errors, online/offline notices, manual /who results, etc.) so users don't toggle blindly. set callback now calls fn.updateSystemMsgFilter() for live apply.

Polarity matches v1 semantics: systemMSG = true registers the filter (mute). The v2 toggle's previous label suggested the opposite reading; no migration needed since the flag has been a no-op in v2 so any persisted value carries no behavioural meaning users have observed.

Classic / TBC / Wrath / Cata path is identical to retail. No gv.isRetail gates -- chat-event filters work the same on every client.

[v2.1.3] (2026-05-19) — Outgoing-whisper echo suppression restored + keybinds back

Two user-requested restorations of v1 features the v2 strip lost.

Outgoing-whisper echo suppression

v1's sendMSG toggle hid the CHAT_MSG_WHISPER_INFORM echo line ("To [Player]: ...") for whispers the addon itself sent during bulk invites. The flag persisted into v2 but was relabeled to a misleading "Show /who results in chat" (the v2 strip dropped settings.lua's updateMsgFilters() wiring and the new SettingsPanel toggle was reskinned by someone who didn't trace the flag back to its actual behaviour). The toggle did nothing in v2.

Restored end-to-end:

  • fn.hideWhisper at functions.lua:1353 — the chat filter callback was never deleted in the strip; survived intact. Matches outgoing whisper text against addon.removeMsgList[fullPlayerName(name)] and drops the matching CHAT_MSG_WHISPER_INFORM event. Multi-part whispers (fn:messageSplit) match-and-remove each segment individually so the keyed table can't grow unbounded.
  • fn:sendWhisper at functions.lua:1397 — already conditionally populated removeMsgList[key] when DB.realm.sendMSG was true. Also intact from v1; no edit needed.
  • fn.updateWhisperEchoFilter at functions.lua:1378 — NEW. Idempotently ChatFrame_AddMessageEventFilter / ChatFrame_RemoveMessageEventFilter against CHAT_MSG_WHISPER_INFORM based on current DB.realm.sendMSG. Replaces v1's updateMsgFilters() (which the v2 strip deleted along with settings.lua).
  • OnInitialize call at FGI_Core.lua:796fn.updateWhisperEchoFilter() runs once after DB wiring so the persisted toggle state is applied at login without /reload.
  • SettingsPanel toggle at GUI/SettingsPanel.lua:1000 — renamed from the misleading "Show /who results in chat" to "Hide outgoing whisper echoes" with an honest description. The chatHeader section renamed from "/who chat output" to "Chat noise". set callback now calls fn.updateWhisperEchoFilter() after writing DB.realm.sendMSG so flipping the toggle takes effect live.

Classic / TBC / Wrath / Cata path is identical to retail — fn.hideWhisper doesn't gate on gv.isRetail and the filter registration goes through the same ChatFrame_AddMessageEventFilter API on every client.

Native keybinds restored

v1 had two keybinds (Invite, Next-search) configured inside the legacy KeyBind tab in the old settings popup; both went with the popup in v2.1.0. v2 plan §Implementation order step 10 specifies the replacement: WoW-native Bindings.xml + BINDING_HEADER_* / BINDING_NAME_* globals so bindings live under ESC → Options → Key Bindings → AddOns → FastGuildInvite rather than buried in a custom in-addon panel.

  • Bindings.xml at addon root, listed at the bottom of every TOC (FastGuildInvite.toc, FastGuildInvite_BCC.toc, _Cata.toc, _Mainline.toc, _Wrath.toc). Two <Binding> elements:
    • FGI_INVITEFGI.functions:invitePlayer() (head-of-queue invite)
    • FGI_NEXTSEARCHFGI.functions:nextSearch() (next /who tick)
    • Both category="ADDONS" and header="FASTGUILDINVITE". runOnUp="false" so key-up doesn't re-fire.
  • Binding labels in init.lua:23-27BINDING_HEADER_FASTGUILDINVITE = "FastGuildInvite", BINDING_NAME_FGI_INVITE = "Invite next player", BINDING_NAME_FGI_NEXTSEARCH = "Start next /who scan". Defined early so the binding panel has the strings whenever the user opens it.
  • In-addon convenience at GUI/SettingsPanel.lua:1024 — new Hotkeys section on the Main page with two AceConfig type = "keybinding" rows. get returns GetBindingKey(action), set clears the prior key (SetBinding(prev)), applies the new key (SetBinding(key, action)), and persists via SaveBindings(GetCurrentBindingSet()). Both surfaces edit the same underlying binding — setting a key in one place updates the other. Account-wide by default (saves to ACCOUNT_BINDINGS unless the user has toggled WoW's binding panel to character-specific).

[v2.1.2] (2026-05-18) — Scan status-bar gated to Scan tab

User-reported polish: kicking off a scan and switching to (say) Blacklist still painted the orange progress fill on the status bar and showed Scanning N% | X / N queries in the status text. The status bar is part of the AceGUI Frame's chrome — visible on every tab — so the scan-progress paint bled into tabs where it wasn't relevant. v2.1.1 gates the scan-progress content to self.activeTab == "scan" while leaving the status bar (and its version-string default) intact on every tab. Scanning itself is untouched.

New MainWindow:RefreshStatusBar()

Added in GUI/MainWindow.lua between SetScanProgress and Open. Single source of truth for status-bar paint:

  • Reads addon.search.progressTotal / progressDone and self.activeTab.
  • If activeTab == "scan" AND total > 0 AND done < total → paints version | Scanning N% | X / N queries text and calls SetScanProgress(done, total) to show the orange fill.
  • Otherwise → resets text to bare addon.version and calls SetScanProgress(0, 0) to hide the texture.

Scan tick consolidated to one call

GUI/Tabs/Scan.lua's ScanTab.Refresh() previously had a dual-fire block: built status text via a local buildStatusText() and called both SetStatusText + SetScanProgress itself. Replaced with a single addon.MainWindow:RefreshStatusBar() call so all gating logic lives in one place. Removed the now-unused buildStatusText() helper (~25 lines).

Tab-switch refresh

OnGroupSelected in GUI/MainWindow.lua now calls self:RefreshStatusBar() after swapping activeTab, so switching from Scan → Blacklist mid-scan immediately clears the orange fill and percentage text, and switching back to Scan re-paints them on the next tick.

LICENSE file added

New top-level LICENSE file at the addon root. Same "All Rights Reserved" structure as the Recount addon's LICENSE: copyright lines naming the original author (Knoot0279, through 2025-11-22 — the day this repo's cb89eca "Initial Commit." landed) and the current maintainer (Pimptasty, from that date forward), followed by the standard reservation-of-rights paragraph and a closing paragraph noting this is a maintained fork. Not listed in .pkgmeta's ignore: block so the BigWigs packager picks it up automatically and ships it in the CurseForge zip. No code changes — purely a credit/copyright addition.

Welcome-spam on retail — third-pass fix in LibGuildRoster-1.0

v2.0.8 vendored the lib with a one-shot wasInitialized flag; v2.1.0 added STABLE_THRESHOLD = 2 (two consecutive same-total GUILD_ROSTER_UPDATE events before flipping initialized). Tested green on classic, regressed on retail: in a guild of ~200, a /reload produced ~20 welcomes in [Guild] plus a flood of "No player named 'X' is currently playing." whisper attempts and "The number of messages that can be sent is limited..." throttle errors.

Root cause: on retail the server can send several GUILD_ROSTER_UPDATE events at the same partial total before the full roster arrives. STABLE_THRESHOLD = 2 then "stabilizes" on a partial snapshot, and the next event with the real roster diffs the remaining members as fresh joins. The threshold heuristic is unfixably racy here — there's no count-based signal that says "the stream is definitely complete now."

Fix in Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua:

  • OnMemberJoined no longer fires from the post-rebuild diff. The diff still drives OnMemberOnline (existing-member-comes-online), which has never been the misfire source.
  • OnMemberJoined now fires exclusively from OnChatMsgSystem's "X has joined the guild" branch. The chat-system message is the authoritative server signal — it only fires when a real join happens, never during login roster population. NormalizeName already handles both Classic ("Player") and retail cross-realm ("Player-Realm") forms.
  • RequestGuildRoster() is still triggered from the chat-system handler so IsInGuild / GetMember / GetAllMembers consumers reflect the new member, but the join callback no longer waits for that round-trip.
  • STABLE_THRESHOLD = 2 stays, now solely gating OnRosterReady and the OnMemberOnline diff (so existing-member-comes-online events don't fire during the login stream).
  • MAJOR, MINOR bumped from 1 to 2 so LibStub picks this copy over any pristine vendored copy in another addon.

Trade-off: a join that happens while the user is offline does not fire OnMemberJoined on next login (the chat-system message was sent before PLAYER_LOGIN, so this client never received it). Acceptable — the alternative is the welcome-everyone bug we're fixing.

Marked with FGI vendor fix inline alongside the existing markers.

[v2.1.0] (2026-05-18) — v2 UI Overhaul Complete: Legacy Strip + Welcome-Spam Fix + Note-API Taint Guard

The final phase of the v2 UI overhaul (see docs/v2.0-plan.md §Implementation order Phase 11 + docs/phase11-plan.md). Removes every legacy popup-soup file now that the modern UI handles all surfaces, recovers logic the v2 strip left stranded, and ships two retail/Anniversary fixes.

Removed: 15 legacy UI files (~5000 lines)

Deleted in one strip pass, with their TOC entries removed from all 5 .toc files:

  • mainFrame.lua, settings.lua, guild.lua, credits.lua, keybindings.lua (the 5 root files of the legacy main UI)
  • antiSpamList.lua, blackList.lua, customList.lua, filtersFrame.lua, quietList.lua, inviteHistory.lua, statistic.lua (legacy sub-panels migrated to v2 tabs)
  • logs.lua, message.lua, searchByLocation.lua (single-toggle / data-only files folded into AceConfig or absorbed into the scan engine)
  • synch.lua (was already entirely inside a --[[ ... ]] block comment; deleted as dead text)

Net diff for the strip commit alone: 37 files changed, +561 / -5874 lines.

Entry points retargeted to v2 paths

  • Minimap LMBaddon.MainWindow:Toggle() (or interface.compactFrame:Show/Hide per pickOpenView()).
  • Minimap RMBaddon.SettingsPanel:Open() (Blizzard's ESC > Options > FastGuildInvite via Settings.OpenToCategory on retail / InterfaceOptionsFrame_OpenToCategory on classic).
  • /fgi show → routes through pickOpenView() for compact-vs-main, then addon.MainWindow:Open(). The fn.showAddon indirection is inlined and deleted.
  • /fgi nextSearchfn:nextSearch() directly (was clicking interface.mainFrame.mainButtonsGRP.pausePlay.frame).
  • /fgi resetWindowsPos → resets only the surviving frames (interface.dumpWindow, interface.debugFrame, interface.compactFrame) plus DB.char.frames.mainWindow = nil so the next :Open() re-centers.
  • /fgi v2 removed entirely (was the dev alias for the v2 preview during phase rollout; redundant now that v2 is the only UI).

Recovered: logic the strip left stranded

The legacy files held more than just UI. Three concrete cases of live code paths that lost their source-of-truth and were recovered:

  • fn.history aggregator lived in statistic.lua. After deletion, scan/invite events called fn.history:onSearch() / :onSend() / :onAccept() / :onDecline() / :onDeclineAuto() / :onFound() / :onLeave() / :joined() / :logInvite() / :trim() against nil, crashing on the next scan with attempt to index field 'history' (a nil value) at functions.lua:2746. Recovered as a new kept logic file history.lua at addon root, registered in all 5 TOCs after functions.lua. The legacy graph-UI refresh side (refreshTotals, drawGraph, refreshHistoryPage) was dropped — the Statistics and History tabs refresh themselves via their own OnShow paths. Every method addon.DB-nil-guarded.
  • addon.syncUI state holder + four sync callbacks (fn.onSyncStarted, fn.onSyncSuccess, fn.onSyncFailed, fn.onSyncNobody) lived in settings.lua's legacy Sync sub-page. GUI/SettingsPanel.lua:1129's runSync button reads addon.syncUI.{inProgress, resultText, manualClick, setResult} to render its label / disabled state. Appended to functions.lua (sync callbacks live near fn.startSync which is also in functions.lua). The legacy AceGUI button-refresh path is dropped; only the AceConfigRegistry-3.0:NotifyChange("FastGuildInvite") path remains.
  • fn.showAddon lived in mainFrame.lua as a one-call-site router used by /fgi show. Inlined into FGI_Core.lua's slash-command dispatch rather than recreated.

Locale orphan sweep

Across all 7 locale files (enUS, ruRU, zhCN, zhTW, frFR, deDE, koKR): 165 unused L["key"] entries removed. Generated by diffing the deleted-file L["..."] references against current live-code references, then per-key-confirming that ASCII keys also weren't reached via L.key accessor syntax.

Caught + corrected during the sweep: Locale/summary.lua reads L["Автор"] / L["Имя"] / L["Перевод"] / L["Тестирование"] / L["Черный список"] for the credits table. The initial sweep dropped them as orphan because the live-key grep excluded all of Locale/. Restored across all 7 locales with their original translations. Memory [[project-locale-summary-reads-keys]] notes the gotcha for future passes.

Tooltip consistency pass

11 raw GameTooltip:SetOwner(...) call sites in v2 GUI code (compactFrame.lua, GUI/MainWindow.lua, GUI/RowList.lua, GUI/UI.lua, GUI/Tabs/{Announce,Filters,Scan}.lua) all sat in dead else fallback branches: if addon.Tooltip and addon.Tooltip.Owner then addon.Tooltip.Owner(self_) else GameTooltip:SetOwner(self_, "ANCHOR_*") end. The else branches never fire because addon.Tooltip.Owner is defined in functions.lua:452 which loads before every v2 GUI file. Simplified each site to a direct addon.Tooltip.Owner(...) call — every tooltip in the main UI now goes through the auto-flipping helper. Net -45 lines, no behavioural change.

Orphan widget classes deleted from Libs/GUI.lua

Five custom AceGUI widget classes that were only ever instantiated by the deleted legacy files: FilterButton (fn:FiltersInit), TCheckBox (legacy filters/settings panels), TKeybinding (keybindings.lua), ProgressBar (mainFrame.lua), TButton (legacy settings popup widgets). Removed in line-range deletions, file went from 1659 → 596 lines (-1063). Surviving custom widgets: ClearFrame (dump/debug), GroupFrame (dump), TLabel (intro).

Welcome-message spam on retail — second-pass fix in LibGuildRoster-1.0

v2.0.8 vendored the LibGuildRoster-1.0 library to centralize join/leave detection, with a wasInitialized guard intended to suppress welcomes during the initial roster build. The guard required only ONE successful GUILD_ROSTER_UPDATE to flip — but on retail, GUILD_ROSTER_UPDATE fires multiple times after PLAYER_LOGIN and any RequestGuildRoster() call as the roster streams in across events. Event 1 captures a partial roster (e.g. 50 currently-online members), sets initialized = true. Event 2 brings the full guild (e.g. 200 members). The diff between event 2's full roster and event 1's partial roster treats the 150 newly-visible members as fresh joins, fires OnMemberJoined for each, and our welcome handler sends a welcome message + whisper for every one of them.

Fix in Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua:

  • Added lib.stableCount / lib.previousTotal / lib.STABLE_THRESHOLD = 2.
  • In OnGuildRosterUpdate, while not self.initialized, increment stableCount when the new total matches the previous total (and > 0); reset to 0 otherwise. Only set initialized = true and fire OnRosterReady once stableCount >= STABLE_THRESHOLD.
  • During the stabilization phase each rebuild still updates self.roster silently — no transition callbacks fire.
  • After stabilization, normal diff logic resumes; real mid-session joins fire OnMemberJoined immediately (the guard skips the already-stable path).

Marked with the FGI vendor fix inline comment alongside the existing vendor fixes (RequestGuildRoster wrapper, recentlyLeft dedup, nil-branch for the documented OnMemberJoined callback) so a future re-vendor diff is obvious.

ADDON_ACTION_FORBIDDEN: SetNote() taint guard

User report on TBC / Anniversary: opening the in-game guild panel and editing a member's note via the right-click "Edit Note" path raised ADDON_ACTION_FORBIDDEN AddOn 'FastGuildInvite' tried to call the protected function 'SetNote()'. Stack trace pointed at Blizzard_StaticPopup_Game/GameDialogDefs.lua:1855 (the Blizzard popup's EditBoxOnEnterPressed), not at FGI code — Blizzard attributed the protected call to FGI because of taint propagation.

Root cause: on every Classic-family client that exposes C_GuildInfo.SetNote (TBC 2.x patches, Wrath, Cata, Anniversary, and Retail), the legacy GuildRosterSetPublicNote / GuildRosterSetOfficerNote entry points have been backed by the protected C_GuildInfo.SetNote. They look like the old API but internally call the protected modern one. Our pcall(GuildRosterSetPublicNote, ...) raises the forbidden-action event and leaves taint that propagates to the user's later in-game guild-panel actions; pcall suppresses the error message but doesn't grant secure context.

Fix in two places:

  • FGI_APICompat.lua — added noteAPIsRestricted() probe (C_GuildInfo and C_GuildInfo.SetNote and true or false). API.SetPublicNote and API.SetOfficerNote short-circuit at the probe before touching the legacy entry points when it returns true.
  • functions.lua:738 (fn:setNote) — early-return at the top when C_GuildInfo.SetNote exists, so the roster walk + CanEditPublicNote checks don't fire either. The join-time trigger at functions.lua:589 also skips the addon.API.GuildRoster() refresh + 5s timer when notes are restricted.

Behaviour after the fix: Real Classic Era 1.15.x (no C_GuildInfo.SetNote) still supports auto-note. TBC / Wrath / Cata / Anniversary silently skip — no taint propagation, no spurious blame on the user's manual guild-panel edits. Retail unchanged (already guarded by gv.isRetail).

Other cleanups

  • /fgi v2 slash command removed along with its help-text line. /fgi show is the single entry point now.
  • Dead constants in FGI_Constants.lua: removed FGI_DEFAULT_SEARCHINTERVAL, FGI_SEARCHINTERVAL_MAX, FGI_FILTERSLIMIT, FGI_MAXWHOQUERY, FGI_BLACKLIST_MAX (defined, no readers).
  • DB.global.scanFrameChilds SV default removed from FGI_Core.lua (legacy mainFrame child-visibility toggles; no live readers).
  • addon._pickOpenView public exposure removed (was for the inlined-then-deleted fn.showAddon; the inner pickOpenView local is still used).
  • Title bar strips the v2.0 (preview) qualifier — now reads "FastGuildInvite".
  • Historical comment prune throughout the codebase: Phase N, v2.0.X:, v1.x, legacy popup, legacy main UI, until Phase 8 retires references removed or reworded to describe current behaviour. Three documentation-style files (init.lua:41's git-tag stripping example, intro.lua's CURRENT_UPDATES user-facing release notes, and SettingsPanel.lua:1289's Pimptasty contributor bio) keep their version references intentionally — they're either current behaviour or historical fact.
  • DB.global.keyBind default removed (the Invite / Next-search keybind feature was deleted; see entry below).

Removed: Invite and Next-search keybinds

The two keybinds the addon previously offered were configured inside a dedicated KeyBind tab inside the legacy settings popup. With that popup retired, the keybinds went with it — both actions are one click away on the visible +(N) invite button and >> scan button on every view. Net code removed: keybindings.lua (54 lines), fn:SetKeybind (19 lines in functions.lua), bootstrap calls in FGI_Core.lua, DB.global.keyBind default, and the use keybinds analytic. If a user had keybinds saved in their SavedVariables, the orphan keys are silently ignored (AceDB doesn't error on unknown keys).

TOC files

No ## Version: edits — the BigWigs packager substitutes FastGuildInvite-v2.3.2 from the git tag at release-build time.


[v2.0.9] (2026-05-16) — Phase 9: Announce Feature (Profile-Based, v2 Main-Window Tab)

Announce feature shipped per docs/v2.0-plan.md §Announce, redesigned twice during development before landing on the final shape:

  1. Plan's design — per-channel configuration with one message per channel, settings in an AceConfig sub-page.
  2. First iteration — profile-based model (N named profiles, each with multi-channel selection), still in an AceConfig sub-page rendering each profile as an inline group.
  3. Final — same profile-based model, but moved to a dedicated Announce tab on the v2 main window (alongside Filters / Blacklist / Anti-Spam / Custom Scan / etc.) using the addon's standard RowList component. The AceConfig sub-page is now a small pointer that opens the new tab.

The move to a v2 tab came from a string of user feedback that the AceConfig version couldn't deliver: multi-select dropdown for channels (AceConfig can only render inline checkboxes for multiselect), compact horizontal form strip on 2-3 lines (AceConfig's flow layout adds widget padding), columned list view of saved profiles with sortable headers and per-row Active checkboxes (AceConfig has no RowList equivalent). The v2-tab approach inherits all of that for free from the existing pattern Filters / Blacklist / etc. use.

New file: Announce.lua

  • Module shape mirrors Scan.lua / synch.lua — non-UI logic file at addon root, registered in all 5 TOCs (FastGuildInvite.toc, _BCC, _Cata, _Mainline, _Wrath) right after synch.lua. Module table exported as addon.announce (lowercase, matching the convention addon.search, addon.searchInfo).
  • Profile lifecycle:GetProfiles(), :GetProfile(idx), :CreateProfile(name), :DeleteProfile(idx), :DuplicateProfile(idx). Each profile is { id, name, enabled, message, cooldown, activity, channels = { [key] = true } }. The id is a stable time-prefixed string generated at creation (time() .. "_" .. random(1, 999999)); state is keyed off it so profile renames don't break per-(profile, channel) cooldown tracking and deletes can clean up just that profile's state entries.
  • Eligibility gate (Announce:IsEligible(profile, channelKey, now)) — two-layer check per (profile, channel):
    1. now - lastPosted >= FGI_ANNOUNCE_MIN_COOLDOWN — hardcoded 60-second floor enforced regardless of the profile's cooldown setting. Defence in depth: a corrupted SV or sync payload cannot drop below this.
    2. now - lastPosted >= profile.cooldown OR (profile.activity > 0 AND msgsSinceLastPost >= profile.activity) — normal cooldown elapsed, OR the channel's activity threshold has been exceeded (your line has scrolled off-screen). Activity bypass is opt-in per profile (default activity = 0 disables it).
  • time() not GetTime() for lastPostedGetTime() resets to ~0 on every /reload, which would let users bypass cooldowns by reloading; using Unix epoch keeps the stored value meaningful across reload boundaries.
  • Announce:Send(opts) — iterates every Active profile, then iterates each profile's selected channels. For every (profile, channel) that passes the eligibility gate, calls SendChatMessage(profile.message, "CHANNEL"|"GUILD"|"OFFICER", nil, idx?) and updates state[profile.id .. ":" .. channelKey]. Skips silently when:
    • Master toggle is off (DB.factionrealm.announce.enabled == false).
    • ChatThrottleLib.Frame.size > 50 — Grouper-style queue-health guard, nil-checked so the lib's absence degrades to direct SendChatMessage with no guard.
    • The channel is no longer joined (GetChannelName(name) returns nil) — silent no-op rather than logged error.
    • Officer chat is selected but the player isn't an officer (CanEditOfficerNote() returns false) — proxy check for "can post to officer chat" in classic-era.
    • Two profiles can target the same channel and both fire in a single click if both pass their own per-(profile, channel) gate. Acceptable v2.0 behaviour — the user clicks the horn manually rather than the addon firing on a timer, so the cooldown's real purpose is rage-click suppression rather than preventing auto-spam.
  • Activity-counter event handlers — module-scoped frame registers CHAT_MSG_CHANNEL, CHAT_MSG_GUILD, CHAT_MSG_OFFICER. Channel handler reads arg9 (channelBaseName) from the documented event payload. Counter is bumped for every Active profile that has the channel selected — so a chat tick on LookingForGroup bumps the activity counter for every profile that posts there.
  • Announce:GetStatus() — returns an array of { profileName, eligible, remaining, channelCount } for the horn-icon tooltip on the v2 main window and the compact tray. Per-profile aggregate: eligible = true if ANY of the profile's selected channels can fire now; remaining is the minimum seconds until any channel becomes eligible.
  • Announce:HasActiveProfiles() — used by the AceConfig "Send now" button's disabled callback and the horn-icon tooltip to decide whether clicking will do anything. An "active" profile must be Active + have at least one channel selected + have a non-empty message body.

New constant: FGI_ANNOUNCE_MIN_COOLDOWN = 60

Added to FGI_Constants.lua. Referenced in two places:

  • AceConfig cooldown slider's min value in GUI/SettingsPanel.lua (client-side enforcement).
  • Runtime eligibility gate in Announce:IsEligible() (defence-in-depth — fires even if a value bypassed the slider via sync or manual SV edit).

New DB defaults

In FGI_Core.lua:

  • DB.factionrealm.announce = { enabled = false, profiles = {} } — configuration shared across same-faction-realm characters. The profiles array is empty until the user clicks "+ New profile" on the settings sub-page. No migration code needed — Phase 9 ships fresh in v2.0.9, so there's no released data to migrate.
  • DB.char.announce.state = {} — per-character runtime state, lazily populated with state["<profileId>:<channelKey>"] = { lastPosted = 0, msgsSinceLastPost = 0 } on first touch. Per-character scope is deliberate: an alt swap should not import another character's cooldowns (the plan flagged this as acceptable v2.0 behaviour). DeleteProfile walks state and removes orphaned profileId:* entries so the table doesn't accumulate dead keys across many delete/recreate cycles.

New v2 main-window tab: Announce

New file GUI/Tabs/Announce.lua, registered in all 5 TOCs after GUI/Tabs/CustomScan.lua. Registered as the 9th tab in GUI/MainWindow.lua's TAB_DEFS (after Quiet Zones), with help-tooltip content in TAB_HELP.announce and a TAB_MODULE.announce = "AnnounceTab" mapping for the resize-floor logic. MIN_WIDTH = 720, MIN_HEIGHT = 400 — same floor as Filters.

Modeled directly on GUI/Tabs/Filters.lua — same strip-on-top + RowList-below pattern, same idioms (placeLabelAbove, attachTooltip, Save-button-greyed-on-empty-name from v2.0.8, click-row-to-load-into-form). The multi-select dropdown for channels is the same UIDropDownMenuTemplate + info.keepShownOnClick checkable-items pattern Filters uses for Classes / Races.

Top form strip (78 px tall, 2 rows):

  • Row 1: [Profile Name] (140 px) [Channels ▾] (180 px, multi-select dropdown) [CD] (50 px numeric) [Act] (50 px numeric) [Save] (64 px). Save is greyed until the Name field is non-empty (mirrors Filters' v2.0.8 behaviour).
  • Row 2: [Message] (flex-width, single-line, capped at 255 characters by SetMaxLetters at the source — WoW's SendChatMessage limit) [Send] (60 px). Send fires addon.announce:Send().
  • All inputs use InputBoxTemplate; the dropdown uses UIDropDownMenuTemplate with the same -8 anchor offset Filters uses to compensate for the chevron's internal padding.
  • Labels above each widget via addon.UI.MakeLabel so the header text gets the same tooltip-on-hover pattern Filters uses (clickable label = section help).

RowList columns (saved profiles list below the strip):

Column Width Notes
On 36 Checkbox column. onToggle callback sets profile.enabled directly so users can pause/unpause a profile without editing it (matches Filters' Active checkbox).
Name 140 Click the row to load the profile into the strip for editing.
Channels auto buildChannelSummary() formats as "Guild, Officer, LFG (+1)" — up to 3 names then +N.
CD 50 Right-justified. Cooldown in seconds.
Status 100 Right-justified. Live readiness pill: Ready (≥1 channel eligible) / on cooldown / (paused) / (no chans).

Plus an X delete-action icon on the right of each row (Blizzard UI-GroupLoot-Pass-Up texture). Header tooltips on every column (headerTip field on the column spec).

Save semantics mirror Filters' doSave: if the row was loaded via click (editingId set), Save updates that specific profile (so renames via Name field actually rename in place rather than spawning a duplicate). If editingId is unset (fresh form), Save creates a new profile via addon.announce:CreateProfile(name). New profiles default enabled = true; existing profiles keep their current enabled state across Save (the per-row checkbox is the canonical toggle).

Numeric input validation — Cooldown is clamped to [FGI_ANNOUNCE_MIN_COOLDOWN, 3600] on Save; Activity to [0, 50]. Both are floored to integers. Out-of-range or non-numeric text falls back to the floor / 0 respectively.

AceConfig sub-page deleted entirely

GUI/SettingsPanel.lua's announce group is gone. The brief pointer-page that replaced the inline-group implementation was itself removed once the v2 tab proved sufficient — the horn icon on the v2 main window's bottom row and the compact tray's title row are the only triggers Announce needs, and per-profile Active toggles in the RowList are the canonical pause/resume mechanism. A separate "master enable" toggle was redundant: pausing every profile via their Active checkboxes is exactly the same outcome.

The dead FGI_MultiLineEditBoxSave custom widget (a "Save"-button-instead-of-"Accept" clone of AceGUI's MultiLineEditBox, introduced briefly for the inline-group profile message field) is also removed — no callers remain after the v2-tab move replaced multiline AceConfig inputs with a raw single-line InputBoxTemplate on the new strip.

Master enabled flag removed from the data model

DB.factionrealm.announce.enabled is gone — the only canonical pause mechanism now is the per-profile enabled flag (toggled via the On checkbox on each row of the Announce tab). Announce:Send() no longer consults a master toggle; it just iterates cfg.profiles. Horn-icon tooltips (compactFrame.lua and MainWindow.lua) dropped their "Master toggle is off" branch; the "no active profiles" branch is the only empty-state surface.

Wired up two horn-icon buttons

  • compactFrame.lua line 397-402 — the title-row horn icon at RIGHT,-92. Click invokes addon.announce:Send(). The OnEnter script renders a dynamic tooltip via addon.announce:GetStatus() — one line per Active profile showing profileName → Ready|in Ns. Gated by shouldShowTooltip() so the v2.0.7 "Disable compact UI tooltips" toggle still applies. Tooltip body points users at the new |cffffd700Announce|r tab on the main window for configuration.
  • GUI/MainWindow.lua line 385-411 — the bottom-row horn icon, anchored BOTTOMLEFT, statusbg, BOTTOMRIGHT, 3, 2. Same click + tooltip pattern as compactFrame. Tooltip width bumped to 320 to accommodate the per-profile readout without wrapping. Tooltip body updated to point at the in-window Announce tab rather than the AceConfig sub-page.

New locale keys

29 announce* keys added to both Locale/enUS.lua and Locale/ruRU.lua. The chat-status print strings (announceMsgPostedTo, announceMsgAllOnCooldown, etc.) are the runtime-facing ones; the settings sub-page itself uses hardcoded English labels for consistency with the other sub-pages (Guild / Advanced / Messages all hardcode their labels). Russian translations are best-effort — missing keys fall through to the English value via the existing locale-lookup convention.

"What's New" popup overhauled to match the v2 main window's layout

intro.lua refactored end-to-end:

  • Container switched from ClearFrame → stock AceGUI Frame (line ~65). ClearFrame is a title-bar-only variant defined in Libs/GUI.lua; the stock Frame widget ships the same title bar plus a bottom strip with a status-text label and a built-in Close button. The popup now matches the v2 main window's chrome (matching the MainWindow.lua:317 pattern of f:SetStatusText(addon.version)).
  • Status bar shows the versionintro:SetStatusText("v" .. addon.version). Title bar simplified to just "Fast Guild Invite" since the version moved out.
  • showLater and showNever dismiss buttons removed. The old buttons each wrote a different value to DB.global.introShow: showLater stamped addon.version (re-fire on next update), showNever stamped false (never again). The X close button on the AceGUI Frame now does what showLater used to via an OnClose callback that writes DB.global.introShow = addon.version. The "never again" semantic is gone — users who want to permanently silence the popup can rely on the natural cadence (only fires once per addon version) or just close it. /fgi intro re-opens on demand.
  • Updates list is now a Blizzard UIPanelScrollFrameTemplate instead of an unbounded TLabel. The old layout auto-grew the label vertically to fit text, pushing the donation block off the bottom of the popup. New layout: scrollFrame anchored TOP to intro.head.frame.BOTTOMLEFT and BOTTOM to intro.body.frame.TOPLEFT (both with 10–20 px padding); a child Frame (the scrollChild) holds a FontString whose width matches the scrollFrame's clip area minus a 22 px gutter for the scrollbar. The scrollChild height is recomputed from FontString:GetStringHeight() + 10 so the scrollbar engages only when content exceeds the visible region. Scrollbar's right anchor inset 22 px so it doesn't crowd the popup's right border.
  • Donation block pinned to the bottom of the popup. The intro.body (support text) is now anchored BOTTOM = intro.paypalL.frame.TOP, 0, 10 — directly above the topmost donation widget. The donation chain (paypal label, paypal field, discord label, discord field) sits flush above the AceGUI Frame's status bar / close button strip (offset y=50 from the bottom clears the 15 px status margin + 24 px status height + 11 px breathing room). Changes to the updates list now scroll inside the scrollFrame; the donation block never shifts.
  • refreshUpdatesText() extracted as a shared function invoked by both the PLAYER_LOGIN event handler (existing trigger) and the new addon.IntroShow() export (slash-command trigger). Lazily computes the scrollChild height each call.

Donation block simplified

Same file. Two donation entries removed entirely:

  • intro.streamelementsE + intro.streamelementsL ("More options for support" → StreamElements URL).
  • intro.patreonE + intro.patreonL (Patreon URL).

Remaining two entries updated:

L.laterButton / L.neverButton locale strings and the btnText helper function deleted — no callers remain.

/fgi intro slash subcommand + addon.IntroShow() API

  • FGI_Core.lua Console:FGIInput gains an elseif str == 'intro' branch that calls addon.IntroShow(). The on-login suppression flag (DB.global.introShow) is bypassed — explicit user invocation should show what they asked for. Listed in /fgi help between the v2 and roster lines.
  • intro.lua exports addon.IntroShow = function() refreshUpdatesText(); intro:Show() end. Single entry point shared by the slash command and the help-icon click handler below.

Help-icon (i) on the v2 main window is now a click button

GUI/MainWindow.lua helpIcon block (line ~391) refactored:

  • CreateFrame("Frame", ...)CreateFrame("Button", ...). Buttons inherit Frame's script bindings so the existing OnEnter / OnLeave tooltip handlers still fire.
  • Added SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD") so the icon picks up the same hover-glow the announce horn, compact-mode minus, and settings gear get — visually telegraphs the click affordance.
  • New OnClick handler calls addon.IntroShow() if available (the same export /fgi intro uses).
  • BOTTOM_ROW_HELP[3] (the line that describes the icon to itself) updated to read "Help — hover for the active tab's help (what you're reading right now). |cffffd700Click|r to open the |cffffd700What's New|r popup (same as |cffffd700/fgi intro|r)." so the click affordance is discoverable from inside the help tooltip itself.

Help tooltip width + auto-anchor fix

Same file, help-icon's OnEnter handler:

  • Owner anchor: GameTooltip:SetOwner(iconFrame, "ANCHOR_TOP")addon.Tooltip.Owner(iconFrame) (per CLAUDE.md). The helper picks ANCHOR_TOPRIGHT when the icon is in the bottom half of the screen (the common case for the bottom-right help icon, so tooltip rises up-and-to-the-left) and ANCHOR_BOTTOMLEFT otherwise. Fallback to ANCHOR_TOPLEFT if addon.Tooltip.Owner hasn't loaded yet.
  • Width: SetMinimumWidth(420)SetMinimumWidth(1200). The 420 value (the file default) caused every long help bullet to wrap 2-3 times, blowing the tooltip vertically to top-to-bottom of the screen.
  • Bug fixed: removed a GameTooltip:ClearLines() call that was sitting after SetMinimumWidth. ClearLines resets the minimum-width setting back to the default, so prior attempts to raise the width were silently no-ops. SetOwner already clears the tooltip implicitly so the explicit ClearLines was redundant anyway. Order now: SetOwnerSetMinimumWidthAddLine × N → Show.

Non-ASCII character cleanup

WoW's default tooltip font and the addon's custom PT_Sans_Narrow font (used by the intro popup) don't carry every Unicode glyph. Several chars that rendered fine on the IDE side appeared as missing-glyph squares in-game. Audit done with Python script; replacements made in two files:

  • intro.lua:
    • 17 × U+2014 EM DASH () → --
    • 1 × U+2192 RIGHTWARDS ARROW () → ->
  • GUI/MainWindow.lua:
    • 3 × U+25BE BLACK DOWN-POINTING SMALL TRIANGLE () removed from "|cffffd700Classes ▾ / Races ▾|r" (Filters tab help) and "|cffffd700Channels ▾|r" (Announce tab help). The triangle was decoration meant to indicate "dropdown" — the description text following each ("multi-select dropdown") already conveys the same info.

Cyrillic in Locale/ruRU.lua strings is left alone — those render fine on ruRU clients which load the Cyrillic font subset.

LibGuildRoster welcome-on-leave fix

User report: clicking the welcome message would fire when someone left the guild, not just on join. Root cause traced into the vendored Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua:

  • The lib's OnChatMsgSystem handler removes a leaver from self.roster immediately on CHAT_MSG_SYSTEM "X has left the guild." so it can fire OnMemberLeft and clean up state.
  • A subsequent GUILD_ROSTER_UPDATE event sometimes carries a stale server roster snapshot that still includes the leaver (server-side roster takes a beat to propagate to the client).
  • The lib's diff: wasOnline is captured from self.roster AFTER the chat removal (leaver no longer in there). Rebuild from GetGuildRosterInfo() returns the leaver because of the stale snapshot. Diff iterates new roster, sees leaver with wasOnline[leaver] == nil → fires OnMemberJoined for the just-departed player → FGI's welcome callback in FGI_Core.lua:333 fires SendChatMessage("Welcome to the guild, NAME!", "GUILD") and the welcome whisper to the departing player.

Fix: added a lib.recentlyLeft = {} table (lib-scoped, in-memory) with lib.RECENTLY_LEFT_TTL = 60 seconds. OnChatMsgSystem's leave / kick branch stamps recentlyLeft[norm] = GetTime() alongside the roster wipe. OnGuildRosterUpdate's diff loop seeds wasOnline from recentlyLeft (entries within TTL get wasOnline[name] = false, treating the name as "existing" rather than "new"). Stale entries past TTL are evicted lazily during the same loop pass so the table can't grow unbounded across long sessions.

Annotated "FGI vendor fix" inline so a future re-vendor diff against the canonical lib is obvious. Side effect: a legitimate rejoin within 60 s of a leave won't fire OnMemberJoined for that specific rejoin — sub-minute rejoins are vanishingly rare so the trade is accepted.

Documentation updates

  • docs/Curseforge_Description.html — added v2.0.9 entry at the top of Recent Updates; removed the oldest v2.0.4 section to keep the list at 5 patches per the CLAUDE.md convention.
  • intro.lua CURRENT_UPDATES array — prepended the v2.0.9 bullet (in-game "What's new" popup) mirroring the Curseforge HTML language.
  • docs/v2.0-plan.md — Phase 9 status: complete. Only Phases 10 (Bindings.xml) and 11 (Polish + legacy file deletion + 2.0.0 version bump) remain on the v2.0 roadmap.
  • docs/phase9-plan.md — implementation plan archived.

[v2.0.8] (2026-05-16) — LibGuildRoster Migration (Welcome-Spam Fix), Blacklist-Found Popup Improvements, Start Sync Button Feedback, Anti-Spam Sync Window Fix, Filter Name Required-Field UX, Statistics Period Dropdown Display Fix, Last/Next Scan Restored on v2 Scan Tab, D (Declined) Counter Added, Statistics Period Labels Clarified + 14-Day Option, Compact Tray Jitter Fix

Compact tray no longer jitters vertically when the queue changes

  • Symptom (user report) — "when inviting the UI moves every time". Each invite, decline, or skip would visibly shift the compact tray up or down by a few pixels, making the tray feel unstable during normal recruitment.
  • Root cause — the compact frame is created with cf:SetPoint("CENTER", UIParent, "CENTER", 0, 0) in compactFrame.lua:76 and the fallback restore path at compactFrame.lua:716 also uses CENTER. cf.refresh calls cf:SetHeight(TITLE_HEIGHT + visibleRows * ROW_HEIGHT) at compactFrame.lua:696 on every queue mutation. With a CENTER anchor, SetHeight expands or shrinks the frame symmetrically around the midpoint — so removing one row (ROW_HEIGHT = 16 px) moves the TOP down 8 px AND the BOTTOM up 8 px. The user sees the entire tray jump every time the queue changes by even one entry.
  • Fix — at PLAYER_LOGIN, after the saved position is restored (or the CENTER fallback is applied), capture the frame's current GetTop() / GetLeft() and re-anchor as TOPLEFT relative to UIParent BOTTOMLEFT using those screen coordinates. After this, SetHeight grows / shrinks the frame downward — the title bar stays put, only the bottom of the queue area moves. Idempotent: re-anchoring to the same coords on a session that already saved TOPLEFT is a no-op. The converted anchor is persisted to DB.global.compactFrame immediately so subsequent /reloads use the new format directly without re-converting from a stale CENTER save.
  • One-time visual shift on first post-upgrade load — existing users with a saved CENTER anchor will see their tray's apparent centre shift slightly DOWN on first load (the captured screen coords are TOP of frame, which is saved_CENTER_y + height/2). After that the tray stays put on every refresh. The shift is one ROW_HEIGHT/2 = 8 px at minimum if the queue is empty, up to half the full queue height. Acceptable tradeoff for eliminating per-invite jitter forever.
  • OnDragStop already saves whatever cf:GetPoint(1) returns so post-conversion drags persist TOPLEFT automatically; no change needed there.

Statistics period dropdown: labels clarified + "Last 14 days" added

  • Labels were misleading — the old dropdown read 24 hours / 1 week / 1 month / All, which implied calendar windows (current week Mon-Sun, current month 1-N). The graph actually plots a rolling window from now - daysBack to now. Relabelled to Last 24 hours / Last 7 days / Last 30 days / All time to match the actual semantic.
  • Last 14 days added between 7 and 30 since users asked for an intermediate window that the existing options didn't cover. New PERIODS entry {14, 14, 24} — 14 X-axis points, 1 per day, matching the 7-day option's points-per-day density.
  • Dedicated locale keys (statsLast24h / statsLast7d / statsLast14d / statsLast30d / statsAllTime) introduced in Locale/enUS.lua and Locale/ruRU.lua instead of reusing the existing L["24 часа"] / L["1 неделя"] / L["1 месяц"] / L["Все"] keys. Those existing keys are shared with the Settings → "Clear DB after" dropdown, where labels like "1 week" are correct (absolute retention period, not a rolling window). Renaming them would have broken that dropdown's labels too. deDE / frFR / koKR / zhCN / zhTW locale files unchanged — non-English users see the English fallback for the new keys until a translator adds them, matching how the addon already handles missing locale entries.
  • Period-index migration — the PERIODS table grew from 4 entries to 5 (14 days inserted between 7 and 30), shifting the indices for 30 days (3 → 4) and All (4 → 5). Without migration, a user whose stored prefs.period was 3 ("1 month") would post-upgrade see "Last 14 days" selected. StatisticsTab.MigratePeriodIndex(stats) in GUI/Tabs/Statistics.lua bumps the stored value to point at the user's previously-chosen window; gated by prefs.periodSchemaVersion = 2 so the migration runs exactly once. Called from FastGuildInvite:OnInitialize in FGI_Core.lua so the index is correct before either UI (v2 tab or legacy v1 popup) reads it. The v2 tab's Render also calls the same migrator defensively — it's idempotent.
  • Legacy v1 statistic popup also updated — same 5-entry frame.connect table and same locale keys in statistic.lua:463-475 so the popup graph stays in lockstep with the v2 tab. They share DB.global.statistic.period; without this update the legacy popup's 4-entry connect table would have indexed past its bounds when the migration bumped a stored value to 5.

D (Declined) counter added to compact tray + v2 Scan tab

  • Request — users asked for visibility into how many invites are being refused per session alongside the existing F (Found) / S (Sent) / A (Accepted) / X (Filtered) letters on the compact tray and v2 Scan tab counter strip.
  • Source — combined addon.searchInfo.decline (manual rejections, ≥ 1 s response) and addon.searchInfo.autodecline (auto-decline addon hits, < 1 s response, classified by the v2.0.6 timing split). Both counters already existed and were already wired through fn.history and the Statistics tab graph as separate series; the new D letter shows their sum so the compact strip stays at one letter per outcome.
  • Implementationaddon.searchInfo metamethod's __call return extended from {unique, sended, invited, filtered} to {unique, sended, invited, filtered, decline+autodecline} so all three counter-display sites get the new value from the same source. Backward-compatible: existing callers that unpack(t) to 4 vars (legacy mainFrame.searchInfo.update at mainFrame.lua:389) silently drop the extra t[5]. Display sites updated in compactFrame.lua:660 and GUI/Tabs/Scan.lua:280.
  • Colour|cffff6666 (light red) chosen to read as "rejected" while staying visually distinct from the existing X orange (|cffff9966) — same negative-outcome family but enough hue difference that two adjacent counters don't blur into each other.
  • Tooltips updated — both counter hover regions now include a D line explaining it's combined manual + auto-decline and pointing to the Statistics tab for the breakdown. compactFrame.lua:210 (GameTooltip:AddLine) and GUI/Tabs/Scan.lua:539 (attachTooltip body).
  • Legacy v1 main frame unchanged — the v1 frame uses the long-form locale string L["Статистика поиска (краткая)"] ("Found: %d Sent: %d Accepted: %d Filtered: %d") and only unpacks 4 values. Adding D there would mean editing the locale string in all 7 translations, which is out of scope for this change; the v1 frame is on the Phase 8 retirement path anyway.

v2 Scan tab: "Last scan / Next scan" line restored

  • Symptom — the legacy v1 main frame had a centred line near the top of the scan area reading "Last scan: <query> | Next scan: <query>" driven by mainFrame.lua scanInfo. The v2 main window's Scan tab dropped that line entirely during the Phase 7 migration; users lost visibility into what query just fired and what's next in the queue.
  • Fix — added a GameFontHighlightSmall fontstring to the v2 Scan tab between the top strip and the row list, sandwiched by a new SCAN_INFO_H = 18 constant that the rowsArea's top offset now accounts for. Stored as widgets.scanInfoFs so ScanTab.Refresh can update it.
  • Refresh wiringScanTab.Refresh now reads addon.search.lastQuery / whoQueryList / progress and builds the same "Last scan: X | Next scan: Y" string the legacy frame builds, including the (a) offset case from mainFrame.lua:402-433 (between fn:nextSearch firing and the WHO callback running, progress still points at the just-fired query so the next slot is +1; outside that window progress already points at the next slot). End-of-cycle wrap to list[1] is preserved so the label doesn't blank out during the brief gap between the last WHO callback and the next nextSearch call. Locale keys reused (L["lastScan"], L["nextScan"], L["scanNone"]) so Russian / Chinese / Korean translations continue to work.
  • Fan-out triggerfn:nextSearch in functions.lua writes addon.search.lastQuery = curQuery and was already calling the legacy mainFrame.scanInfo.update() directly. Added an addon.MainWindow.refreshScanTab() call right after so the v2 tab refreshes at the same moment as the legacy frame; both UIs now mirror lastQuery changes from one site. The existing fn.onListUpdate and addon.searchInfo metamethod fan-out paths already trigger ScanTab.Refresh on queue/counter mutations, so the new explicit call only fills the lastQuery-changed gap they don't otherwise cover.

Statistics tab period dropdown now displays the selected period

  • Symptom — clicking the period dropdown on the Statistics tab and picking 24 hours / 1 week / 1 month / All filtered the graph correctly (the period preference saved and the graph re-rendered) but the dropdown's visible button text stayed stuck on a placeholder string (rendered as "Custom" on the affected client). The selection was effectively invisible.
  • Root causemakeDropdown in GUI/Tabs/Statistics.lua called UIDropDownMenu_SetSelectedValue(dd, i) in both the initial render and the per-item info.func click handler. That API only updates the internal selected-value state used by UIDropDownMenu_GetSelectedValue; it does not touch the dropdown button's visible text. The visible label on a UIDropDownMenuTemplate is set by UIDropDownMenu_SetText(dd, label) — and that call was missing from both code paths, so the button kept whatever placeholder text the framework had on it.
  • Fix — paired every UIDropDownMenu_SetSelectedValue with a matching UIDropDownMenu_SetText so the button label tracks the selection. Initial render now reads items[startIdx] and calls SetText once with that label; the per-item click handler also calls SetText(dd, label) after the value-set so picking a different period visibly updates the dropdown immediately.
  • No functional change — the period filtering itself was always working (the onSelect(i) callback fired correctly and wrote prefs.period = idx); only the display was broken.

Filters tab: Save button now greyed when Filter Name is empty

  • Before — clicking Save on the Filters tab with the Filter Name field empty was a silent no-op (the doSave handler in GUI/Tabs/Filters.lua returned early via if name == "" then return end with no UI feedback). Users hit Save, nothing happened, and there was no indication that the name was the missing piece.
  • Fix — Save is now disabled by default and only enables when Filter Name contains non-whitespace text. Implementation: nameInput:HookScript("OnTextChanged", refreshSaveEnabled) checks the trimmed input on every keystroke and calls saveBtn:Enable() / :Disable() accordingly; initial state is disabled since the input starts empty. Loading an existing filter via row-click (loadIntoForm) calls nameInput:SetText(name) which fires OnTextChanged → re-enables; saving and clearing the input fires OnTextChanged → re-disables.
  • Tooltip swaps body with stateSetMotionScriptsWhileDisabled(true) keeps the hover tooltip firing while Save is greyed. A HookScript("OnEnter", ...) checks self_:IsEnabled() and shows either the regular Save-button help (when enabled) or "Filter Name is required — type a name in the Filter Name field before saving." in light-red (when disabled). Replaces the previous static attachTooltip(saveBtn, ...) call.
  • doSave's early return kept as belt-and-suspenders — the Enter-key submission paths (maxInput:SetScript("OnEnterPressed", function() doSave() end) and similar on mInput) can still invoke doSave directly. With the button disabled the OnClick path is dead, but pressing Enter in those inputs with an empty name still no-ops silently rather than crashing.

Anti-spam sync now honors the local "Clear DB after" retention setting

  • Root causefn.startSync in functions.lua built Sync.tablesForSync with three of the four data tables calling getLasWeekData(t, false, true) (the third true means "full copy, no time filter") but the fourth — alreadySended (the anti-spam list) — called getLasWeekData(DB.realm.alreadySended) with no full flag. That made the helper apply its 7-day time filter, hard-capping the synced payload at the last 7 days of anti-spam entries regardless of how long the user had been recruiting. Symptom: a guildie onboarded mid-recruitment received only the recent slice of anti-spam, missing weeks or months of older invited names. From git history (cfba887 v1.6.3) the 7-day cap on alreadySended was deliberate when sync was first wired up, but the asymmetry was never updated when blacklist sync moved to full=true and when the local-retention setting clearDBtimes was added.
  • Fix — sync window for alreadySended now reads from DB.global.clearDBtimes (the existing Settings → Main → "Clear DB after" dropdown that controls local retention: never / 1 day / 1 week / 1 month / 6 months). The threshold seconds come from FGI_RESETSENDDBTIME[clearIdx], the same constant that drives the local cleanup loop in FGI_Core.lua:737. Index 1 (never expire) sends the whole DB.realm.alreadySended table; any other index sends entries newer than time() - secs. The default (index 3 → 1 week) reproduces the pre-fix payload exactly, so users on defaults see no behavior change.
  • Implementation — inlined the windowed filter at the Sync.tablesForSync build site rather than threading a fourth parameter through getLasWeekData (the helper's name and the -7 hardcoded day offset are misleading for the other three callers that bypass the filter via full=true; widening its signature would compound that). The other three table entries (leave, blackList, blackListRemoved) still go through getLasWeekData unchanged — they were already sending full copies and remain so. Protocol-compatible with prior versions: receivers don't care how the sender chose its keys.
  • Practical effect — recruiters who set "Clear DB after" to 1 month or 6 months will now sync that much anti-spam to onboarding guildies. Recruiters who set it to "never" will sync their entire list. ChatThrottleLib + the existing 255-byte chunking in prepareTableForSend handles the size increase; subsequent syncs to the same peer are deduplicated via Sync.cache so the first onboarding sync is the only one that carries the full payload.

Welcome-spam fix v2: migrated join detection to LibGuildRoster-1.0

  • First attempt (earlier in v2.0.8 dev) moved welcome firing from the GUILD_ROSTER_UPDATE diff in functions.lua to a C_GuildInfo.GetGuildEventLog() "join" entry handler in FGI_Core.lua processGuildEventLog. Testing on retail showed it still spammed welcomes for every guildie who logged on, which the event-log approach shouldn't have done. Root cause: the retail copy of the addon was 10 days stale (the wow-version-replication watcher wasn't running), so the test was actually running v2.0.7's roster-diff welcome path the whole time. That's a process issue, not a code issue, but the event-log approach was still a roundabout fix.
  • v2.0.8 final: vendored LibGuildRoster-1.0 into Libs/LibGuildRoster-1.0/ and wired its OnMemberJoined callback to fire welcomes. Source: the canonical lib in ProfessionMaster/libs/LibGuildRoster-1.0/. The lib does a wipe-and-rebuild on every GUILD_ROSTER_UPDATE and fires OnMemberJoined when a name appears in the new roster that wasn't in the previous one. Same diff our code was doing manually, with three wins: one source of truth for joins across all WoW client versions, built-in wasInitialized guard so /reload doesn't welcome every existing member, built-in login-race retries when GetNumGuildMembers() returns 0.
  • Two FGI vendor fixes applied to the lib (annotated "FGI vendor fix" inline so a re-vendor diff is obvious):
    1. OnMemberJoined was dead code in the canonical lib — declared in the header docs but never :Fire'd anywhere. Added the firing inside OnGuildRosterUpdate's post-rebuild diff: wasOnline captures the pre-wipe roster keyed by member name (true/false for online state); the diff loop now checks wasOnline[name] == nil to detect brand-new members (versus wasOnline[name] == false for existing-but-came-online), firing OnMemberJoined and OnMemberOnline respectively. Purely additive — no existing subscribers to break since the callback never fired before.
    2. Forced SetGuildRosterShowOffline(true) at PLAYER_LOGIN on retail. Without this, GetGuildRosterInfo(i) iteration on retail is filtered by the guild panel's show-offline toggle — when off, the rebuilt roster only contains currently-online members and the presence-diff misfires whenever an offline guildie logs on (their name was absent from the previous filtered snapshot → looks "new" → false OnMemberJoined). Gated to retail (WOW_PROJECT_ID == WOW_PROJECT_MAINLINE) because Classic/TBC/Wrath/Cata don't filter the iteration this way and mutating the filter on those versions would be a behaviour change for existing consumers (fastguildinvite is currently the lib's first retail use; other consumers like ProfessionMaster ship Classic-only). Lives inside the lib's OnPlayerLogin so the toggle is set before its first GuildRoster() call — otherwise the timing race against a separately-registered handler could deliver a still-filtered roster.
  • Removed three now-redundant code paths:
    • processGuildEventLog in FGI_Core.lua reverted to leave-only — the v2.0.8-early "entry.type == 'join'" detection + seenJoinKeys dedup ledger are gone.
    • The Classic-only join-pattern match ("^(.+) has joined the guild%.$") + welcome firing in FGI_Core.lua CHAT_MSG_SYSTEM handler — replaced by the lib's OnMemberJoined which handles Classic via the same CHAT_MSG_SYSTEM → GuildRoster() → rebuild chain. Leaving the chat-match in would double-welcome on Classic.
    • The retail GUILD_ROSTER_UPDATE block in functions.lua keeps its Accepted-counter / pendingInvites / rememberPlayer bookkeeping (idempotent and unrelated to the welcome path) but no longer fires welcomes — that block was already de-welcomed in v2.0.8's first attempt.
  • TOC change: Libs\LibGuildRoster-1.0\LibGuildRoster-1.0.lua added to all five TOCs (FastGuildInvite.toc, _BCC, _Wrath, _Cata, _Mainline) right after LibDBIcon-1.0's lib.xml so CallbackHandler-1.0 (provided via the Ace3 dependency declared in every TOC) is available before the lib's CBH:New(lib) call runs.

Start Sync / Sync now buttons now show in-place visual feedback

  • Both Start Sync buttons (legacy popup settings.lua syncNow AceGUI button, and v2 Settings panel Advanced → "Sync now" AceConfig button) now show their state on the button itself instead of relying solely on chat messages. Sequence on click:
    1. Button label changes to "Syncing..." and the button disables until the sync completes.
    2. On success / nobody / failed / watchdog timeout, the button re-enables and shows a short result label for 5 seconds (Synced with PartnerName (+N) / Synced with PartnerName (up to date) / Already up to date / Sync failed: PartnerName / (timed out)).
    3. After 5 seconds the button returns to its idle label.
  • Shared state lives on addon.syncUI (settings.lua lines ~672-732): { inProgress, manualClick, resultText, resultAt, setResult }. setResult is exported so the v2 panel's early-exit handler can route through the same writer (single owner for result-state changes, single owner for the 5 s clear timer and the 30 s watchdog).
  • refreshSyncButtons() updates both surfaces (settings.lua line ~686): calls :SetText / :SetDisabled on the AceGUI button directly, and calls LibStub("AceConfigRegistry-3.0"):NotifyChange("FastGuildInvite") to re-render the v2 panel when it's open (no-op when closed). The AceConfig button's name and disabled fields are now functions that read live state from addon.syncUI.
  • Manual click overrides muteSync / addonMSG for chat output — the existing on*Sync* callbacks gated all chat prints on not (addonMSG or muteSync). When the user explicitly clicks one of the Start Sync buttons, the new manualClick flag forces a chat line on result too, so a muted user still sees confirmation that their action did something. Auto-syncs from PLAYER_LOGIN don't set the flag and still respect the mute toggles. consumeManualClick() clears the flag in the final-state callbacks (onSyncSuccess / onSyncFailed / onSyncNobody) so the next sync defaults back to mute-respecting behavior.
  • fn.startSync now returns started, skipReason (functions.lua line ~3954): true, nil if the broadcast went out, false, "combat" if blocked by UnitAffectingCombat, false, "in_progress" if Sync.target ~= ''. The button click handlers use the return value to immediately surface "Skipped: in combat" / "Sync already in progress" on the button — previously these early exits were silent, leaving the user with no signal at all. Internal callers (functions.lua:516, 3312, 3939, 3988) still call without using the return values; behavior unchanged for them.
  • Watchdog timer — 30-second C_Timer.NewTimer armed when onSyncStarted runs; if inProgress is still true when it fires (e.g., callbacks dropped, sync state machine wedged), the button is force-reset with (timed out) text. Prevents the button from sticking in "Syncing..." indefinitely.

"Blacklisted X is in your guild!" popup now shows reason + has Unblacklist button

  • StaticPopupDialogs["FGI_BLACKLIST"] in blackList.lua (lines 313-398) extended with two improvements driven by the same user-visible dialog. The popup fires from fn:blacklistKick() (functions.lua:927) on world entry and from fn:blackListAutoKick()'s CHAT_MSG_SYSTEM handler when a blacklisted player joins.
  • Reason now shown in the popup bodyshowNext() looks up DB.realm.blackList[name] via the new lookupBlacklistReason() helper and appends "\nReason: <reason>" to the body text when an entry exists. The helper handles both the v2.0.5 { reason = string, time = epoch } table shape AND pre-v2.0.5 raw-string entries that haven't been re-saved since the migration. Lookup uses fn:fullPlayerName(name) as the primary key (matching how fn:blackList stores entries on connected realms) with a fallback to the bare name. Missing/empty reasons are silently omitted so legacy entries don't render "Reason: nil".
  • Third "Unblacklist" button addedbutton1 is still "Kick"OnAccept (calls GuildUninvite), button3 becomes "Skip"OnAlt (just advances the queue; no action). New button2 = "Unblacklist"OnCancel calls fn:unblacklist(name) so the user can forgive the player without leaving the dialog. Button-to-callback mapping follows Blizzard's documented 3-button StaticPopup convention (button1=OnAccept, button2=OnCancel, button3=OnAlt). hideOnEscape = false is retained so accidental Esc presses don't fire the middle-button unblacklist via the OnCancel-on-Esc path.
  • Layout — Visual order is Kick / Unblacklist / Skip (left to right), reading as destructive → mid → passive so users can intuit the action without reading every label. All three branches mark the name in data2 and advance to the next queued blacklisted player via showNext() so the existing one-popup-per-player flow is preserved.

Welcome-spam fix v1 (superseded by the LibGuildRoster migration above, retained for context)

  • Root cause — On retail, GetGuildRosterInfo(i) iteration in the GUILD_ROSTER_UPDATE snapshot diff is filtered by the guild panel's SetGuildRosterShowOffline toggle. When that toggle is off (Blizzard default in many UI flows; other addons flip it), the stabilized snapshot only contained currently-online members. Previously-invited friends logging in later then appeared "new" in the diff and triggered the welcome path — once per invited friend that came online — producing the reported "spams welcomes for everyone invited in the session" behavior. v2.0.6 made this worse by writing every invited player to DB.realm.alreadySended immediately at invite time (the anti-spam fix), which increased the population of names eligible to misfire.
  • Fix — Retail welcome and whisper sending moved from the GUILD_ROSTER_UPDATE diff in functions.lua (line ~841) to processGuildEventLog in FGI_Core.lua (line ~278), keyed off C_GuildInfo.GetGuildEventLog() entries with entry.type == "join". This is the server-authoritative join signal we already use for "leave" / "remove" on retail, so the same event handler now produces both leavers and joiners in one pass.
  • Dedup — A module-level seenJoinKeys set tracks the keys we've already welcomed; each entry's key is memberName|year|month|day|hour. First call after login leaves joiners empty (the seenJoinKeys nil-check inside the loop guarantees that) so existing log entries don't all welcome on /reload; the snapshot saved on that first call becomes the baseline that subsequent calls diff against. Stale keys for members who later left and rolled off the server-side log are naturally evicted when the snapshot is replaced.
  • Roster-diff path retained for bookkeeping only — The if gv.isRetail then ... GUILD_ROSTER_UPDATE block in functions.lua (line ~756) still owns fn.history:joined(), addon.searchInfo.invited(), fn.history:onAccept() / logInvite("accepted"), addon.pendingInvites cleanup, and the rememberPlayer fallback. Those operations are idempotent (rememberPlayer and onAccept on a name we've already processed are no-ops) so the filter quirk doesn't break the Accepted counter the same way it broke welcomes.
  • Roster map simplified — Since originalName (the realm-suffixed form the welcome path needed for cross-realm whispers) is no longer used in this block, current is now keyed [normalizedName] = true instead of [normalizedName] = memberName. The unused originalName value in the diff loop was removed.
  • Classic/TBC/Wrath/Cata unchanged — Non-retail versions never had this bug because they trigger welcomes from the CHAT_MSG_SYSTEM "X has joined the guild." pattern match in FGI_Core.lua (line ~209), which fires exactly once per real join with no roster snapshot involved. That path stays as it was.

[v2.0.7] (2026-05-06) — Compact UI Tooltips Disable, /fgi v2 Respects Open-Last-Used

Compact UI tooltip disable toggle

  • New Disable compact UI tooltips checkbox in General → Appearance settings (default off). When enabled, all hover tooltips are suppressed on the compact tray — resize grip, scan counters (F/S/A/X), title icons (gear, help, announce, plus, close), row icons (invite, decline, blacklist, skip), the invite-next button (+(N)), and the scan button (>>). The compact frame itself remains fully functional; this only suppresses the tooltip popups. Useful for distraction-free recruiting when you already know what each icon does.
  • Implemented via shouldShowTooltip() helper in compactFrame.lua (line ~16) that returns false when DB.global.compactTooltipsDisabled is true. All 9 OnEnter handlers in the file check if not shouldShowTooltip() then return end before calling addon.Tooltip.Owner / GameTooltip:SetText. Lines 116, 202, 246, 300, 350, 462, 524.
  • DB default compactTooltipsDisabled = false added to FGI_Core.lua (line ~595). AceConfig toggle added to GUI/SettingsPanel.lua Appearance section (order 16, after openLastUsed). Lines 613-621.

/fgi v2 now honors openLastUsed setting

  • /fgi v2 (and /fgi v2.0) now routes through the same pickOpenView() logic as /fgi show so the Open last-used view toggle is honored consistently across all open commands. When openLastUsed is enabled and lastOpenedView is "compact", /fgi v2 opens (or toggles) the compact tray instead of always opening the v2 main window. When off, falls back to the compactMode preference like before.
  • Updated in FGI_Core.lua slash command handler (lines 821-837). The picker returns "compact" or "main"; compact path toggles interface.compactFrame visibility, main path calls addon.MainWindow:Toggle().

[v2.0.6] (2026-05-06) — Declined Invites History Fix, Retail Decline Detection Restored, Auto-Reject Timing Detection, Scan Interval Retail Fix

Anti-spam list fix: all invite types now call rememberPlayer() immediately

  • Types 1, 2, and 4 now call fn:rememberPlayer() immediately when the invite is sent, matching the Type 3 pattern that was already working. Previously only Type 3 (Message Only mode) would write to DB.realm.alreadySended upfront; Types 1, 2, and 4 deferred the anti-spam write to the decline/accept handler, which meant if the server confirmation never arrived (or the event was missed) the player would never be remembered.
  • All four invite paths in functions.lua invitePlayer() now write DB.realm.alreadySended[normalizedName] = true immediately after calling addon.API.GuildInvite(), before any message send or sync broadcast. Location: lines 1660-1730.
  • Manual invite paths updated: FGI_ChatMenu.lua (both legacy UIDropDownMenu and modern Menu API paths) and FGI_Core.lua whisper reply dropdown now call fn:rememberPlayer() and populate addon.pendingInvites immediately after GuildInvite(). Lines 64-77, 219-233, 115-130.
  • Removed redundant rememberPlayer() calls from Scan.lua decline/auto_decline handlers — the player is already in the anti-spam list from the upfront write, so the event handler only needs to clear pendingInvites and log to History.

Retail decline detection restored via CHAT_MSG_SYSTEM with pcall wrapper

  • Re-enabled CHAT_MSG_SYSTEM event on Retail (Scan.lua line 128). v1.9.9 disabled it to avoid taint errors from Blizzard's "secret strings" (patterns like ERR_GUILD_DECLINE_S that exist in the client but trigger SetForbiddenSecrets taint when read by addons), but decline/accept messages only arrive via CHAT_MSG_SYSTEM — the UI_ERROR_MESSAGE event doesn't carry them.
  • Wrapped playerHaveInvite(msg) in pcall() on Retail so tainted strings are caught and silently ignored without breaking execution. The success, type, name = pcall(playerHaveInvite, msg) pattern returns false for tainted patterns; the handler checks if not success then return end and skips processing. Lines 145-180.
  • UI_ERROR_MESSAGE handler unchanged — still registered on Retail as a fallback, but most invite responses now flow through the CHAT_MSG_SYSTEM path with the taint guard.

Timing-based auto-reject detection

  • addon.pendingInvites table restructured from [normalizedName] = playerName (string) to [normalizedName] = { name = playerName, time = GetTime() } (table) to track when each invite was sent. All write sites updated: functions.lua Types 1/2/4, FGI_ChatMenu.lua context menu paths, FGI_Core.lua whisper reply. Lines 1660-1730, 64-77, 219-233, 115-130.
  • Accept handlers in functions.lua updated to handle both old string format (for backward compatibility with in-flight invites from before the change) and new table format. Lines 701-720, 821-840: if type(pending) == "table" then name = pending.name else name = pending end.
  • Decline handlers check elapsed time since invite send: GetTime() - pending.time < 1.0 classifies as auto-reject (player has an auto-decline addon installed), >= 1.0 classifies as manual decline. Auto-rejects call fn.history:onDeclineAuto() and log with outcome "antispam"; manual declines call fn.history:onDecline() and log with outcome "declined". Lines 145-180 (Retail CHAT_MSG_SYSTEM), 227-265 (Classic/UI_ERROR_MESSAGE).
  • Classic and Retail both use the same timing check — the pattern matching in playerHaveInvite() checks ERR_GUILD_DECLINE_AUTO_S before ERR_GUILD_DECLINE_S so explicit auto-reject messages still take precedence, but when both patterns would match (Retail sends the generic decline message for both outcomes) the timing heuristic provides the classification.

Retail scan interval fix: libWho retailConfig now respects user setting

  • fn.setScanInterval(n) in functions.lua now calls libWho:SetRetailConfig("interval", n) on Retail in addition to libWho:SetInterval(n). Lines 2865-2872. The LibWho_Retail.lua override returns retailConfig.interval from GetInterval(), completely bypassing the value set by SetInterval() — so the user's configured scan interval was being ignored and the timer always showed 8 seconds.
  • Added initialization in PLAYER_LOGIN handler (functions.lua lines 3993-4003) to call fn.setScanInterval(DB.global.scanInterval) once the database loads, ensuring the retailConfig value is synced with the persisted user setting on every login.
  • LibWho_Retail.lua unchanged — the existing SetRetailConfig(key, value) method (lines 279-284) was already present but never called by the addon. Now it's wired into the settings flow so the user's preference propagates to the Retail-specific config table.

Backed out AFK detection feature

  • Removed CHAT_MSG_AFK and CHAT_MSG_DND event registrations from Scan.lua line 129-130.
  • Removed AFK/DND auto-reply handler (lines 208-237) that marked pending.isAFK = true and set a 90-second timeout to remove non-responders from the anti-spam list. The feature was an edge case — AFK players are online and can receive invites normally; the timeout logic added complexity without clear user benefit.

[v2.0.5] (2026-05-06) — Blacklist Reason Input + Timestamps, Open-Last-Used Toggle, Scan-Button Safety, Case-Insensitive Sort, Checkbox Revert Fix

Blacklist reason-input dropdown

  • Replaced the legacy Yes/Cancel blacklist confirmation with a free-text reason input. Every blacklist gesture in the addon — compact tray row icon, v2 Scan tab row icon, chat right-click FGI - Blacklist — now opens a small dropdown at the cursor with a title row (Blacklist <name>), an EditBox for the reason, OK / Cancel buttons, and a hint line showing the configured default reason. Empty input falls back to that default. Hitting Enter inside the input commits; Escape cancels. Single shared helper in GUI/UI.lua UI.ShowBlacklistConfirm so all three surfaces share identical UX. The legacy FGI_V2_BLACKLIST_EDIT StaticPopup remains for the Blacklist tab's edit-existing-row flow.
  • Fast blacklist toggle still gates this — when on, the dropdown is skipped and the silent default-reason path runs (UI.FastBlacklist); when off, the new reason-input dropdown opens.
  • Implemented via UIDropDownMenu's info.customFrame. A persistent 80 px tall Frame hosts the EditBox + buttons + hint fontstring, mixed in with UIDropDownCustomMenuEntryMixin (with inline stubs for older clients lacking the global) so the dropdown framework's SetOwningButton / GetPreferredEntryHeight calls succeed. Pre-built at file load to avoid first-open construction races.
  • Auto-close timer disabled. WoW's UIDROPDOWNMENU_SHOW_TIME (~2 s mouse-out timer) doesn't care that an EditBox child has focus — it'd fire CloseDropDownMenus mid-typing. Three-shot timer kill (synchronous + next-frame + 100 ms) directly nils DropDownList1.showTimer / isCounting and removes the OnUpdate script. OnEditFocusGained re-kills counting on every focus regrab to defend against OnLeave re-arming via StartCounting.
  • First-open layout primer. The framework's listFrame mis-sized on the very first open with a customFrame (~80 px of empty space below the row); subsequent opens were correct. Open-then-close-invisibly primer with DropDownList1 alpha-zeroed at an offscreen anchor warms the framework state so the user's first real open lands on the correct layout.

Blacklist timestamps + sortable Added column

  • DB.realm.blackList value reshape: name -> reason (string) is now name -> { reason, time } (table). New entries stamp time with the current epoch; on edit-reason the existing add-time is preserved (you're rewriting the reason, not re-adding the player). Idempotent migration in FGI_Core.lua OnInitialize converts legacy string values to { reason = str, time = 0 } (0 = "unknown, predates the v2.0.5 timestamp feature"). Sync receive coerces incoming string values from older peers to the new shape on the local side so reads stay uniform.
  • All write sites updatedfn:blackList, GRM importer's tryAdd, GIL importer's tryAdd, legacy blackList.lua OnAccept edit path. Sync ships the full table value automatically (existing updateTableForSync plumbing).
  • All read sites updated — officer-chat blacklist message, !blacklistGetList debug print, legacy blackList.lua OnShow + update() display loop, v2 Blacklist tab StaticPopup OnShow, and the v2 Blacklist tab's buildRows data builder.
  • New Added column in the v2 Blacklist tab, between Name and Reason, fixed-width 120 px, sortable. Renders as YYYY-MM-DD HH:MM for stamped entries and for legacy entries with time = 0. The YYYY-MM-DD format is lexicographically chronological so RowList's string sort gives the expected oldest-first / newest-first ordering; the em-dash sorts after digits so unknown-time rows naturally sink to the end in ASC.

"Open last-used view" toggle

  • New Open last-used view checkbox in General → Appearance settings (default off). When on, the minimap left-click and /fgi show open whichever view (full main window or compact tray) was used most recently — so a user who switches to the compact tray with the button can now "live" in compact without having to flip the compactMode preference manually.
  • Persistence is always-on. The compact tray's + (expand) button writes DB.global.lastOpenedView = "main" and the main window's (compact-mode) button writes DB.global.lastOpenedView = "compact" on every click, regardless of the toggle. Cheap to track and means flipping the feature on later doesn't need a primer click — the most recent gesture is already recorded.
  • Router refactor. addon._pickOpenView() in FGI_Core.lua returns "main" or "compact" based on openLastUsed + lastOpenedView (when on) or compactMode (otherwise). Both mainFrameToggle (minimap LMB) and fn.showAddon (/fgi show) route through it for consistency.

Scan-button safety timer (compact tray + v2 Scan tab)

  • Rare-but-real "frozen scan button" bug fixed on both the compact tray and the v2 Scan tab. The optimistic visual cooldown (cf.scanCooldown = true, widgets.scanCooldown = true) set on click had no escape hatch — if libWho's timeCallbackStart / timeCallbackEnd never fired (because /who was rejected, libWho was busy with another addon, or a partial scan abort), the cooldown flag stayed true forever and if cf.scanCooldown then return end silently no-op'd every subsequent click.
  • Mirrored the v1 main window's safety pattern. Each click increments a cooldownGen counter and schedules C_Timer.After(libWho:GetInterval() + 5, ...). The deferred callback re-enables the button only when cooldownGen still matches (so click N's still-pending timer can't prematurely clear click N+1's cooldown) and the flag is still set. Same defensive invariant added in v1.9.9 for the v1 main button.
  • v2 Scan tab also gained the optimistic visual cooldown for parity with the compact tray (it was previously missing the click-debounce, exposing the same WHO_LIST_UPDATE round-trip window).

Tooltip auto-flip — prefer ABOVE

  • (continued from v2.0.4) The addon.Tooltip.Owner global helper was already updated to prefer ANCHOR_TOPRIGHT when there's ≥250 px of screen space above the frame. The compact tray's help "i" tooltip got SetMinimumWidth(500) to flatten its vertical extent so it stays under that 250 px reserve.

Case-insensitive sort across every RowList column

  • GUI/RowList.lua comparator now lowercases both sides for the primary compare so "Apple" / "apple" / "banana" / "Banana" group together instead of scattering by ASCII byte order (Lua's default string compare is case-sensitive — capitals 65–90 sort before lowercase 97–122). Falls back to the original case-sensitive compare on tie so equal-modulo-case strings get a stable, deterministic order.
  • Applies to every sortable column on every v2 tab — Blacklist, Anti-Spam, Custom Scan, Filters, Quiet Zones, Statistics, etc. Reported originally for the Blacklist tab's Reason column.

Checkbox visual-revert fix

  • Checking / unchecking a row's checkbox in any v2 list-editor tab no longer requires a tab away + back to "stick". RowList's checkbox click handler previously dispatched to the column's onToggle callback (which writes to DB) but never updated entry[col.key] on the row data — so any subsequent _renderRows (parent resize, scroll, sort header click, external Refresh) repainted the cell from the stale value and visually reverted the click. Fixed by writing entry[col.key] = val in the framework's click handler before dispatching, so the row data stays in sync with the visible state and every re-render shows the user's click.
  • Applies to every checkbox column in the addon — Custom Scan's On + Strict, Filters' On, scan-group enable toggles, anywhere else checkbox = true is set on a column spec.

Sync output respects muteSync

  • GRM and GIL import-done chat lines now honor DB.global.muteSync (functions.lua). Both importers are sync-class operations (pulling foreign blacklist data into the local realm) so the same flag that suppresses peer-sync chatter now suppresses the importer's completion line. The on-screen addon.API.ShowMessage banner still fires either way — that's a transient notification, not chat spam, and it's how mute-on users still find out the import finished.

[v2.0.4] (2026-05-05) — Phase 8 Sub-pages, Compact Tray Resize, Skip/Decline Semantics, Intro Popup Fix, FGI Chat Submenu, Graphical Progress Bar

Settings panel — Phase 8 sub-pages filled out

  • Guild sub-page populated with the full set of settings from guild.lua: auto-welcome to guild chat + welcome message body, auto-whisper new members + whisper body, auto-blacklist guild leavers, announce blacklist additions in officer chat, and Classic-only set-public-note + set-officer-note with their respective templates (gated behind gv.isRetail-false hidden callbacks because Retail's note-setting APIs are Blizzard-only).
  • Advanced sub-page populated: Anti-spam memory (remember skipped, anti-spam expiry, history retention), Sync (print/mute sync chat, GRM/GIL auto-sync), /who chat output (show /who results, show invite system messages — both per-realm), Debug (debug mode, scan logs, show update info), Manual triggers (Trigger version check button, Sync Now button).
  • Messages sub-page populated as a list-style editor for whisper templates: select dropdown showing all templates with truncated bodies as labels, multiline = 6 input editing the selected template's body, Add new + Delete current execute buttons. Live storage in DB.factionrealm.messageList / curMessage matches the legacy message.lua shape so both UIs share data.
  • Section headers redesigned with hover-tooltips. Each sub-page's section heading was previously a type = "header" followed by a type = "description" body block (visible inline wall-of-text). Replaced by a single new custom AceGUI widget FGI_TooltipHeader: brand-coloured centred title flanked by divider lines, with the section's desc field surfaced as a hover tooltip via the same OnEnter/OnLeave dispatch pattern AceConfigDialog already uses. Eleven section headers across General, Guild, Advanced, Messages converted; the inline description blocks went away.
  • Per-input field labels also got hover-tooltips via a sibling widget FGI_TooltipInput: clones AceGUI's stock EditBox widget but moves the tooltip surface from the EditBox itself to an invisible Button frame overlaying the label fontstring's bounds. Hovering the label shows the tooltip; hovering the typing area doesn't pop a tooltip while you're trying to edit. The four Guild text inputs (welcome message, welcome whisper, public note, officer note) use it.

Settings panel — reorganization

  • createMenuButtons toggle moved from Advanced → Debug to General → Invite behaviour under the new label Add FGI to player right-click menus. The toggle gates the v2.0.3 native chat-menu integration; burying it in Debug made it undiscoverable. The new tooltip enumerates the surfaces (chat, friends list, party / raid frames, guild roster), the three follow-on actions (Guild Invite / Blacklist / Unblacklist), and how to reach the same actions when off (slash commands, v2 main window row icons).
  • /who scan subdivision moved from General to Advanced. Power-user setting, doesn't belong in the General sub-page.

Settings panel — clearDBtimes semantic restoration + crash fix

  • The clearDBtimes setting was rendered as a 0-365 days range slider in v2.0.3, but the field is actually an INDEX into a 5-element FGI_RESETSENDDBTIME table ({ 0, 86400, 604800, 2592000, 15552000 } — disable / 1 day / 1 week / 1 month / 6 months in seconds). Anyone who interacted with the slider could write a value above 5, which then crashed OnInitialize at FGI_RESETSENDDBTIME[DB.global.clearDBtimes] with attempt to compare nil with number. The crash aborted the rest of OnInitialize before fn:initDB() ran, so DB and debugDB upvalues in functions.lua stayed nil — every downstream call (fn.startSync, FiltersUpdate, fn.debug) then hit nil-DB errors.
  • Fixed in GUI/SettingsPanel.lua by replacing the slider with a select dropdown { [1] = "Never expire", [2] = "1 day", [3] = "1 week", [4] = "1 month", [5] = "6 months" }. The get callback clamps any out-of-range saved value back to 3 (1 week, the default).
  • Hardened in FGI_Core.lua — the cleanup loop now clamps idx to [1, #FGI_RESETSENDDBTIME] before indexing, writing the clamp back to DB so future runs see a valid value, and gates the threshold lookup on secs and secs > 0 so a 0-second (disable) or nil entry no-ops cleanly.

Anti-spam memory — Skip / Decline semantic restoration

  • Decline now always adds the player to the anti-spam list. Previously functions.lua:1663 gated the anti-spam write behind if noInv and (DB.global.rememberAll or DB.global.rememberSkipped), so with both flags off a Declined player could reappear on the next scan. The flag check is gone; the noInv path always remembers — that's the whole point of Decline (vs Skip). The v2 Scan tab and compact tray's row icons both flow through this path so they get the new behaviour for free.
  • Skip now respects rememberSkipped — previously the v2 row icons' Skip onClick called table.remove(list, idx) and never touched the rememberPlayer path or the rememberSkipped flag. The flag was misnamed (it actually gated the Decline path, alongside rememberAll). Both v2 Scan tab and compact tray Skip handlers now read DB.global.rememberSkipped after the table.remove and call fn:rememberPlayer(entry.name) when on. Off by default; Skip stays a soft removal.
  • rememberAll flag retired — the field was always functionally identical to rememberSkipped (both gated the same noInv path). Removed from DB.global defaults, the Wago Analytics switch, the AceConfig Advanced sub-page, the legacy popup checkbox + its SV-load setter, and the locale layout-size entries in ruRU / zhCN / zhTW / summary.lua.
  • The rememberSkipped description on the Advanced sub-page rewrites to spell out the Skip-vs-Decline distinction so the toggle's purpose is unambiguous.

Security sub-page retired

  • The legacy security.lua panel had two toggles (DB.global.security.sended / .blacklist) that were never read by any sync code — write-only flags that did nothing. Deleted the file, removed the security field from DB.global defaults, removed the AceConfig Security sub-page (renumbered Advanced/Announce/Messages/Credits to fill the gap), removed security.lua from all 5 TOCs, and dropped the three dead locale strings (Безопасность, Подтверждение отправки данных синхронизации, Список отправленных приглашений) from all 7 locale files. docs/v2.0-plan.md updated to mark the sub-page retired with rationale.

Compact tray — resizable

  • Width is now user-resizable via an invisible grabber in the bottom-right corner. At rest no chrome shows (the tray's minimal aesthetic is preserved); on hover three faint white dots fade in arranged in a diagonal grip pattern, plus a Resize GameTooltip explaining what to do. OnMouseDown calls cf:StartSizing("RIGHT") so only width changes — height stays driven by the queue refresh. OnMouseUp saves cf:GetWidth() to DB.global.compactFrame.width and the PLAYER_LOGIN restore reads it back with sanity bounds (>= MIN_WIDTH and < 3000).
  • Hard floor at MIN_WIDTH = 300 so the title-row counter strip always has at least ~80 px to render typical mid-scan readouts (F:25 S:15 A:3 X:7) on a single line. Below this point the counters used to wrap into stacked lines because GameFontHighlightSmall defaults SetWordWrap(true).
  • Counters now use SetWordWrap(false) / SetNonSpaceWrap(false) / SetMaxLines(1) so even at extreme scan-state values the text clips off the right rather than wrapping into a multi-line block that breaks the title-row layout. Same single-line clipping pattern the queue rows already used for the lvl/class column.
  • LVLCLASS_W reduced from 110 to 90 px in the queue rows. Was sized for "60 Demon Hunter" (Retail) at ~105 px; Classic Era's longest is "60 Warrior" at ~70 px. The 20 px goes back to the name field at every frame width — names truncate later in every queue row, including at the new 300 px minimum width.
  • Resize bounds set via direct cf:SetResizeBounds(MIN_WIDTH, MIN_HEIGHT) with a cf:SetMinResize fallback, inlined rather than calling through addon.UI.ApplyMinResize because compactFrame.lua loads BEFORE GUI/UI.lua in the TOC and addon.UI doesn't exist yet at that point. Same logic the helper wraps, just inline.
  • Help "i" tooltip on the compact tray now includes Move: and Resize: lines so the resize affordance is discoverable through the existing help icon.

Intro popup — silently broken since launch

  • The "Show update info on login" toggle was wired but the popup never displayed for any user, ever, due to two compounding bugs in intro.lua's PLAYER_LOGIN gate:
    1. tonumber(FGI.version) == nil rejected every 3-segment version string — tonumber("2.0.3") returns nil because Lua's number parser doesn't accept multiple decimal points. The popup was always suppressed for any real release.
    2. L.updates = {} in both enUS and ruRU locale tables was hardcoded empty, and the #L.updates == 0 guard short-circuited the display logic regardless.
  • Fixed: removed the tonumber guard entirely (the existing DB.global.introShow == addon.version equality check handles dev builds correctly because addon.version is the literal "FastGuildInvite-v2.3.2" string in dev — first-time stamp matches, suppressing future shows). Populated CURRENT_UPDATES table with six user-facing v2.0.4 bullets shared between both locales (Russian translations TODO).
  • Updated CLAUDE.md documentation-rule to add intro.lua's CURRENT_UPDATES array to the per-release update list alongside CHANGELOG.md and docs/Curseforge_Description.html. The Curseforge HTML's "Recent Updates" section and CURRENT_UPDATES should mirror each other — same audience, same plain-language voice.

User feedback — ElvUI dropdown spacing on TBC + global dropdown helper

  • A user on TBC Classic with ElvUI reported a visible gap between the per-item radial slot and the label text in the Quiet Zones tab's continent / zone dropdowns. Default WoW dropdowns reserve a left-side check-mark slot for notCheckable = nil items; ElvUI's dropdown skinning pads this slot into a visible gap. Most addon dropdowns (Quiet Zones continent + zone, Statistics period, blacklist Yes/Cancel, chat right-click follow-on, Custom Scan add-member, Clear-confirmation, legacy InitMenu) are single-select and don't use the check-mark slot at all — selected value is shown via UIDropDownMenu_SetText.
  • New global helper GUI/UI.lua addon.UI.CreateMenuInfo([checkable]) — a thin wrapper around UIDropDownMenu_CreateInfo() that defaults info.notCheckable = true. Every dropdown call site that doesn't need the check-mark slot now goes through this single helper, so the ElvUI fix is one-and-done and any future dropdown automatically inherits the right defaults. Pass true to opt back into the slot (Scan tab's Mode dropdown still uses info.checked for active-mode indication and is left untouched).
  • Migrated call sites: GUI/UI.lua ShowBlacklistConfirm, FGI_ChatMenu.lua (3 items), FGI_Core.lua InitMenu (5 items), GUI/Tabs/Scan.lua Clear dropdown, GUI/Tabs/CustomScan.lua add-member dropdown, GUI/Tabs/QuietZones.lua continent + zone dropdowns, GUI/Tabs/Statistics.lua period dropdown.

Quiet Zones tab — master toggle surfaced

  • The legacy settings popup carried an Ignore quiet zones checkbox that flipped DB.global.quietZones — the master gate read by IsInQuietZone() in functions.lua:421. When off, the scan engine bypassed both the built-in instance filter (raids / dungeons / arenas / battlegrounds via fn.getStaticAreas()) AND the user's custom zone list. The toggle never made it into the v2 main UI during the Phase 4 list-editor migration, so users couldn't disable filtering without going to the legacy popup.
  • New Filter quiet zones checkbox at the top-right of the GUI/Tabs/QuietZones.lua strip. Tooltip explains it's the master switch — when off, EVERY zone (built-in instance list + the custom rows below) is bypassed for the scan engine. Toggle calls fn.getAreas(true) to invalidate the area cache so the change takes effect on the next scan tick.
  • MIN_WIDTH bumped from 540 → 640 px so the new toggle's checkbox + label (~150 px) doesn't overlap the Add button at the smallest window size.

Tooltip anchor — prefer ABOVE the frame

  • addon.Tooltip.Owner previously placed tooltips BELOW the frame when the frame was in the top half of the screen (anchor ANCHOR_BOTTOMLEFT) to avoid clipping off the screen top, ABOVE only when the frame was in the bottom half. Compact-tray testing flagged that "below" routinely covered the queue rows underneath the title-row icons — exactly the content the user was trying to read.
  • Flipped to prefer ABOVE. The helper now checks GetScreenHeight() - frame:GetTop() > 250 (a conservative 250 px reserve for the tallest tooltip the addon renders) and uses ANCHOR_TOPRIGHT when there's room above; falls back to ANCHOR_BOTTOMLEFT only when the frame sits so close to the screen top that an above-anchored tooltip would clip. Global change — every call site (compact tray, Scan tab, Settings panel checkboxes, RowList action icons, etc.) gets the new behaviour without per-site edits.

Compact tray — help "i" tooltip widened

  • The compact tray's help icon tooltip is the longest in the addon (10 lines covering icons / counters / move / resize affordance), so at GameTooltip's default ~250 px width it rendered nearly half the screen tall. With the new "prefer ABOVE" anchor logic, that meant any tray docked closer than ~250 px from the screen top fell back to the BELOW path again — covering the queue.
  • makeTitleIcon now takes an optional tooltipWidth argument that calls GameTooltip:SetMinimumWidth(N) before SetText. The help "i" passes 500, which flattens its body from ~10 visual lines to ~5-6 — well under the 250 px reserve so the global helper can keep anchoring above the tray even when docked near the screen top. Other compact-tray tooltips (settings, close, etc.) are short single-sentence things that fit the default width without modification.

FGI chat right-click menu — hover-open submenu instead of click-popup

  • The v2.0.3 native chat-menu integration added an FGI entry to WoW's right-click menus that, on click, opened a follow-on UIDropDownMenu at the cursor with the three actions (Guild Invite / Blacklist / Unblacklist). User feedback: the click-popup pattern dismissed the parent menu and forced a separate close interaction; preferred a standard hover-open submenu that stays nested in the parent and lets the user navigate away naturally.
  • FGI_ChatMenu.lua refactored to use a Menu API submenu. rootDescription:CreateButton("FGI") with no click callback auto-promotes the button to a submenu with Blizzard's native > arrow; the three children attach via parentDesc:CreateButton(...) so the framework owns the open/close lifecycle (no manual timer or close).
  • Per-item tooltips preserved via the modern Menu API's description:SetTooltip(callback) — each child's tooltip body sits on a pcall-guarded attachMenuTooltip helper so a future patch dropping :SetTooltip just loses the tooltip body, not the whole submenu. Tooltips render via GameTooltip_SetTitle / GameTooltip_AddNormalLine when those globals exist (modern clients) and fall back to direct tooltip:SetText / tooltip:AddLine calls otherwise.
  • Legacy buildFollowOn / ShowFollowOn paths kept intactFGI_Core.lua's SetItemRef hook still calls ShowFollowOn as the fallback for clients where Menu.ModifyMenu didn't attach.

Main window — graphical scan progress bar restored

  • The v2 status bar text rendered scan progress as ASCII (# filled, . empty) inside the AceGUI Frame's bottom statustext fontstring. User feedback flagged that the bar visibly "grew in width" as more # replaced .# is meaningfully wider than . in WoW's proportional GameFont, so the perceived bar width changed with progress. The legacy v1 mainFrame.lua rendered progress via an AceGUI ProgressBar widget whose colored texture grew left-to-right inside a fixed-width frame; users preferred that aesthetic.
  • Replaced the ASCII bar with a colored Texture parented directly inside the AceGUI Frame's statusbg. Same v1 pattern (Libs/GUI.lua:1507): texture anchored TOPLEFT / BOTTOMLEFT with 4 px padding and width = (statusbg:GetWidth() - 8) * (done/total) set on every progress tick. Drawn on the ARTWORK layer so it sits beneath the OVERLAY-layer statustext fontstring — version + percent + queries text reads on top of the orange fill (#FF8000 @ 40% alpha).
  • Texture cached on statusbg.fgiProgressTex so reopen reuses the existing instance instead of creating duplicates on the recycled AceGUI Frame (textures don't have SetParent like frames; we can't detach on close).
  • MainWindow:SetScanProgress(done, total) is the public method called by GUI/Tabs/Scan.lua's update tick alongside SetStatusText. Hides the texture when idle / scan finished; sets width and shows when scanning.
  • First implementation tried a floating StatusBar above statusbg — invisible because the AceGUI tab-content frame sits at the same MEDIUM strata with a higher frame level and occluded the 6 px strip. Switching to an in-statusbg texture sidesteps strata fights entirely (texture renders on the parent's draw layers).
  • Status text simplified — was vX.Y | Scanning [###....] 51% | 23/45 queries, now vX.Y | Scanning 51% | 23/45 queries since the bar provides the visual readout.

fn.debug self-healing

  • fn.debug previously read from a local debugDB upvalue assigned only by fn:initDB(). When OnInitialize aborted partway through (the clearDBtimes crash above was one trigger) fn:initDB() never ran and debugDB stayed nil. Subsequent fn.debug calls then errored with bad argument #1 to 'insert' (table expected, got nil), masking whatever the actual upstream problem was.
  • Fixed: fn.debug now reads addon.debugDB live as a fallback (local sink = debugDB or addon.debugDB) and gates each table.insert(sink, ...) on sink being non-nil. Worst case it skips the SV log line and just prints to chat — the function never errors, even on a partially-initialised addon state.

[v2.0.3] (2026-05-05) — Scan Groups, Native Chat Menu, Notification Fix, Subdivision Toggles

Notification banner — silently broken since legacy

  • Queue-notify and Scan-ready alerts now actually render. FGI.animations.notification:Start(text) was a long-standing no-op for two compounding reasons. (1) font = font or settings.Font resolved to nil because L.settings only defines size = {...}SetFont(nil, 21, "OUTLINE") then silently failed and left the FontString unrenderable. (2) The hosting frame was created via CreateFrame("Frame") with no parent and no explicit strata, ending up at default MEDIUM with the FontString on the lowest BACKGROUND draw layer — covered by basically any other addon overlay or chat frame even when the font HAD been set. Rebuilt: parented to UIParent at FULLSCREEN_DIALOG strata, FontString promoted to OVERLAY draw layer with GameFontHighlightLarge, font fallback chain font or anim.f.font or STANDARD_TEXT_FONT (the captured-at-init font path that the original author meant to use), and a fade-in → hold → fade-out animation group so the banner is visible for a clear ~3.5 seconds instead of snapping into a 0.5 s tail-end fade after a 3 s invisible delay.

Settings panel

  • General sub-page expanded with Scanning (interval slider + level-range priority dropdown), Appearance (window opacity slider + minimap icon + keep open on Esc), Invite behaviour (auto-kick blacklist + Fast blacklist), Notifications (queue notify + scan-ready alert), and /who scan subdivision (see below).
  • Section headers use the native AceConfig type = "header" style — centered label between two horizontal divider lines. Tried type = "description" with brand-colour wrap and fontSize = "large" first (visual consistency with the orange tab labels) but reverted: the divider lines actually read as section breaks; left-aligned colored text didn't.
  • Discord invite link in Credits is non-editable but selectable. Custom AceGUI widget FGI_ReadOnlyEditBox cloned from stock EditBox: blocks OnChar so typed characters never insert, and snaps OnTextChanged back to the canonical URL as a defence-in-depth net for backspace / delete / paste. Mouse selection still works so users can Ctrl+A / Ctrl+C the URL. Replaces the previous "snap-back on focus loss" pattern that visibly flickered when typed in.
  • Gear-icon click no longer closes the v2 main window. Settings.OpenToCategory() calls CloseSpecialWindows() which fires the FGIMainWindowEscProxy OnHide handler → closes the v2 frame. Fixed by temporarily nilling that OnHide before the open call and restoring on the next frame via C_Timer.After(0, ...).

/who scan subdivision toggles

  • Per-tier on/off in General settings. DB.global.subdivideLvl / subdivideRace / subdivideClass / subdivideZone (all default true). Each tier has its own toggle in the General sub-page under a new "/who scan subdivision" header. Disabling a tier means capped queries at that shape accept the truncated 50 and the chain stops there — section description spells out "Tiers run in fixed order: Level >> Race >> Class >> Zone" with a "you will miss players" warning.
  • Dispatch in searchWhoResultCallback derives the tier from query shape, not just searchLvl. Multi-level query (max > min) at any sLevel halves via LVLsplit and is gated on subdivideLvl. Single-level queries are gated on the per-tier flag matching their searchLvl (race / class / zone). willSubdivide factors in the gate so progress-weighting math stays correct when a tier is off (cap accepted → full worst-case cost credited so the bar advances).

Blacklist confirmation flow

  • Blacklist row icons (v2 Scan tab + compact tray) honour DB.global.fastBlacklist. Previously the row icons silently bypassed the toggle: the v2 Scan tab always opened FGI_V2_BLACKLIST_EDIT regardless of fastBlacklist; the compact tray always blacklisted silently regardless of fastBlacklist. The toggle is now the single switch for "do I get prompted or not" across every blacklist gesture (chat right-click, slash command, v2 Scan row icon, compact tray row icon).
  • Confirmation popup replaced with an in-place Yes/Cancel dropdown at the cursor. Two new shared helpers in addon.UI: ShowBlacklistConfirm(entry, onDone) opens a UIDropDownMenu with Yes, blacklist <name> (tooltip showing the default reason) and Cancel; FastBlacklist(entry, onDone) runs the silent path (blacklist with default reason, log "blacklisted" history, drop from queue, fire onListUpdate, refresh). Both v2 Scan tab and compact tray row-icon onClicks branch on fastBlacklist to call one or the other. Per-player free-form reasons are now reached only via the Blacklist tab's edit popup; the row-icon path always uses the default reason.
  • SettingsPanel.lua Fast blacklist description rewritten to describe the actual surface area (row icons + chat right-click + slash command) and the off-state behaviour (the new Yes/Cancel dropdown).

Native chat right-click menu integration

  • New file FGI_ChatMenu.lua registers a single "FGI" item in WoW's native chat right-click menu via Menu.ModifyMenu against 12 unit-popup tags (MENU_UNIT_PLAYER, MENU_UNIT_FRIEND, MENU_UNIT_CHAT_ROSTER, etc.). Each tag's callback adds a CreateButton("FGI", ...) that, on click, opens a follow-on UIDropDownMenu at the cursor with three tooltipped items: FGI - Guild Invite, FGI - Blacklist, FGI - Unblacklist. The follow-on lives in its own dropdown rather than as a UnitPopup nested submenu because Blizzard's Menu API doesn't pass per-item tooltips in a portable way across patches.
  • Legacy SetItemRef hook + addon.MENU retired on clients where native attach succeeded. FGI_Core.lua's OnEnable checks addon.ChatMenu._registeredModern and only installs the chat-link fallback hook when the native injection didn't take. Eliminates the dual-popup the user flagged ("I get both the FGI popup AND the in-game popup"); on Classic Era 1.15.x the native path attaches all 12 tags so users see a single integrated menu.
  • Diagnostic addon.ChatMenu.PrintDiagnostic() prints which tags accepted the registration to chat — useful for future Blizzard tag renames.
  • Confirmed working on Classic Era 1.15.x which has fully migrated off the legacy UnitPopupButtons / UnitPopupMenus tables; the legacy code path was tried during early development and removed once the modern API was confirmed working everywhere.

Scan Groups (Custom Scan tab)

  • New file FGI_ScanGroups.lua owns the entire scan-groups feature. Multi-membership (the same scan can live in any number of groups) with per-membership enable independent of standalone selected. Members are an ordered list of customScans names; a group can be reordered manually via per-member up/down arrows. Strict-sequential execution: when a group is enabled, every query (including subdivision children) of member N drains before member N+1 starts. Multiple enabled groups also drain strict-sequentially: group A finishes, then group B starts.
  • SchemaDB.faction.scanGroups = [{name, enabled, expanded, members = [{scan, enabled}, ...]}, ...]. Mirrors DB.faction.customScans shape. No migration; defaults to empty.
  • Scheduler refactor (fn:nextSearch) — branches into addon.ScanGroups.PopulateQueue when addon.ScanGroups.HasActiveGroups() returns true, otherwise runs the existing customScans + default-sweep path unchanged. addon.search.bucketQueue holds the ordered bucket list (one bucket per enabled group with at least one resolvable enabled member, plus a final standalone bucket containing customScans-with-selected=true minus dedup against grouped scans, plus the default sweep when on). whoQueryList is the ACTIVE bucket's queries; subdivisions push into it directly so children stay scoped to the active bucket. When the active bucket drains (progress > queueLen), AdvanceBucket replaces whoQueryList with the next bucket's queries; after the last bucket cycles, PopulateQueue rebuilds from current state for a fresh pass (so user changes mid-scan get picked up). fn.clearSearch calls ScanGroups.Reset to wipe bucketQueue / bucketIndex so a fresh scan after Clear starts clean.
  • Bucket-transition announcements gated behind DB.global.logs.on — same flag that controls the per-query "Search returned N players" line. Each bucket activation prints <FGI> now running group X (N queries) (or ... standalone scans (N queries)) so users with logs enabled can verify strict-sequential drain in real time.
  • UI on the existing Custom Scan tab — strip gains an Add Group button on row 2 alongside Default Scan. Body splits into two areas: a Groups area at the top (height 0 when no groups exist, so users without groups see the same layout they had before) and the existing scans RowList below it. Each group renders as a header row [On] [+/-] Name [+ Add member] [× Delete]; expanded groups show indented member rows underneath [On] scan name [^] [v] [× Remove]. Add Member opens a UIDropDownMenu listing customScans not already in the group; clicking one appends it as an enabled member. Reorder via the per-row up/down arrows. Group rows live in their own ad-hoc Frame layout (not via RowList) so the hierarchical render shape doesn't pollute the shared component.
  • Standalone scans header label — brand-coloured Scans label appears above the existing scans list when at least one group exists, for visual separation. Hidden when no groups.
  • Cascade cleanups on customScan delete / renamefn.groupsCleanScan walks every group's members[] and drops references to a deleted scan; fn.groupsRenameScan walks every group and updates members[*].scan from old name to new. Wired into the existing customScan Delete action and the rename branch of doSave.
  • Save button renamed SaveSave Scan for visual disambiguation from the new Add Group button (both pull from the same Scan Name input field). Scan Name label updated to Scan / Group Name with a tooltip listing both creation paths and what each requires.
  • Defensive quote-strip in trim — pasting a name like "60 Casters" (with literal quote characters, common when copying from chat / prose) creates a scan/group called 60 Casters without the quotes.
  • Layout fix in rowsArea anchoring. RowList's _recomputeVisibleRows reads parent:GetHeight() to size its visible-row pool — when rowsArea was anchored TOPLEFT to groupsArea BOTTOMLEFT (a dynamically-sized intermediate Frame), GetHeight could return 0 in some layout-pass timings, leaving zero visible rows even when customScans had data. Switched to parent-relative anchoring with an explicit y-offset computed from group state, plus an explicit rl:Refresh() after every layout change. Standalone scans now render reliably regardless of group count or expand state.

[v2.0.2] (2026-05-04) — RowList Overhaul, Sort Arrows, Edit-Mode Rename, Gear Icon

Major Changes

  • GUI/RowList.lua column-system rewrite — propagates to every list-style tab (Scan / Custom Scan / Filters / Blacklist / Anti-Spam / History / Quiet Zones). Single edit applies to all seven tabs:
    • Sort arrows on every sortable headerInterface\Calendar\MoreArrow texture (the Blizzard chevron the calendar's guild-events list uses on its own column headers, lifted from ClassicCalendar's CalendarEventInviteSortButtonTemplate). ASC/DESC swap by flipping V TexCoord top↔bottom (SetTexCoord(0.0, 0.9375, 0.0, 0.6875) for DESC, (0.0, 0.9375, 0.6875, 0.0) for ASC). Arrow position computed from GetStringWidth() so it always sits 3 px right of the actual rendered text — never lands inside an adjacent column regardless of how the column was specced.
    • Header text always LEFT-justified; data cells always LEFT-justified. col.justify still accepted in the spec for back-compat but ignored at render time. Layout reads uniformly across header and rows.
    • :New pre-pass bumps each col.width to at least textWidth + 22 px (3 gap + 15 arrow + 4 right pad) so the sort arrow always fits inside the column without spilling. Uses a hidden probe FontString on UIParent to measure each header's actual rendered width.
    • Checkbox columns LEFT-align the box at the column's left edge (was centred — looked off after the LEFT-justify pass since header "On" / "Strict" hugged the left while the boxes floated in the middle of their column slot).
    • _buildRow + _buildHeader rewritten to respect column-array order. Find the (single) auto-width column index; columns BEFORE it chain LEFT-to-LEFT from parent.LEFT + LEFT_PAD; columns AFTER chain RIGHT-to-RIGHT from parent.RIGHT - iconAreaW; the auto column fills whatever's between the two chains. Previously the auto-width column always landed leftmost regardless of array position, which forced consumers to put auto-width columns first; now they can sit anywhere in the array. _makeHeaderColumn got a richer opts arg with three modes: {leftOffset = N} for LEFT-chain, {rightOffset = N} for RIGHT-chain, {leftOffset = N, rightOffset = M} for auto-width.

Tab consequences of the RowList overhaul

  • Filters tab (GUI/Tabs/Filters.lua) — column order is now On / Name / Classes / Races / Lvl / Count + delete-action (was On / Name / Lvl / Classes / Races / Count). Classes is the auto-width flex slot so it grows with the window.
  • Custom Scan tab (GUI/Tabs/CustomScan.lua) — the On checkbox column is now actually leftmost visually (was hijacked by the Parameters auto-width column under the old layout).
  • Edit-mode rename for both Filters and Custom Scan — clicking a row sets editingName; Save updates THAT row, renaming it if the Name input differs, instead of creating a new entry under the new name. Carries On / Strict / Count state across the rename. Editing-the-row-then-deleting-it clears editingName so the next Save creates new instead of failing on a missing target.
  • Dead justify = "RIGHT" / "CENTER" / "LEFT" specs cleaned out of every RowList consumer — they no longer affect rendering after the LEFT-justify changes above.

Scan tab

  • Two-zone wheel-scroll on the Lvl readout — replaces the single-zone slider that scrolled both bounds together. New layout Lvl [min] - [max] with each number an independently-scrollable Button-frame hover zone. ±1 normal, ±5 with Shift. Push-partner clamp: scrolling Min past Max bumps Max up to match (and vice versa) so the range can never invert. Hover highlight on each number-button so the cursor target is unambiguous. Mirrors the legacy v1 spinner mechanics.
  • Tooltip body cleaned of internal DB.global.lowLimit / highLimit names and Phase 8 references per CLAUDE.md guidance ("users don't care").

MainWindow bottom-row icons

  • Announce + compact-mode minus icons resized to 20×20 (was 14×14) to match the AceGUI Close button's height. Help "i" stays 24×24 per user feedback. All five icons' vertical centres line up at y=27 (close: 17+10, help: 15+12, the three 20-tall icons: bottom y=17 + height 20 → centre 27). Status bar's right edge moves from -191 to -226 to fit the wider icons + the new gear with even 3-px gaps between every element.
  • New gear icon between the minus and Close button. Texture: Interface\Icons\Trade_Engineering. Click opens the legacy settings popup via interface.settings:Show() / .ShowContent("Main"). Tooltip notes that Phase 8 will swap the click handler over to the AceConfig settings panel (Settings.OpenToCategory("FastGuildInvite") on retail, InterfaceOptionsFrame_OpenToCategory on classic) and retire the legacy popup.
  • Help-icon tooltip's BOTTOM_ROW_HELP appendix gains a fifth line for the gear with a |TInterface\Icons\Trade_Engineering:14:14|t texture escape so the rendered icon shows inline alongside announce / help / minus.
  • local interface = addon.interface added to the file preamble — without it the bottom-row icon click handlers (the compact-mode launcher specifically) blew up with "attempt to index global 'interface' (a nil value)" because interface was being treated as a global instead of a file-local. Same addon.interface reference the rest of the v2 GUI files use.

Compact frame

  • Announce button mirrors the v2 main window's bottom-row horn — same Interface\Icons\INV_Misc_Horn_03 texture, same Phase 9 placeholder click handler, same tooltip pointing at ESC > Options > AddOns. Shifts pause/scan from RIGHT,-48 to -70 and invite from -72 to -94 to make room between expand (+) and pause (>>); counters' right edge moves from -110 to -132.
  • Drag fix on the counter tooltip overlay. The Button covering the wide middle of the title bar (counterTip, the F/S/A/X tooltip-bearing region) was intercepting the title bar's RegisterForDrag("LeftButton") events because EnableMouse(true) is implicit on Buttons. Users couldn't move the tray by click-dragging the counter region — the drag silently no-op'd because the events never reached title's drag handler. Fixed by also calling counterTip:RegisterForDrag("LeftButton") and forwarding cf:StartMoving() / :StopMovingOrSizing() with the same DB-position-save body as the title bar's drag handler.

[v2.0.1] (2026-05-04) - TBC / Wrath / Cata / Mainline TOC Sync

Bug Fixes

  • /fgi v2 reported "v2.0 main window not loaded" on TBC / Wrath / Cata / Mainline clients. When v2.0.0-beta shipped, the v2.0 GUI files (GUI/UI.lua, GUI/RowList.lua, GUI/Tabs/*.lua, GUI/MainWindow.lua) were added to the Classic Era TOC (FastGuildInvite.toc) but the four sibling TOCs (FastGuildInvite_BCC.toc, _Wrath.toc, _Cata.toc, _Mainline.toc) carried the v1.x file list unchanged. On those clients MainWindow.lua never loaded, addon.MainWindow stayed nil, and Console:FGIInput at FGI_Core.lua:753 printed the error and returned. Fixed by replicating the same v2.0 GUI block (after inviteHistory.lua, before debug.lua) into all four sibling TOCs so the load order matches the Classic Era TOC. All five TOCs now share the same 12-file GUI list.

[v2.0.0] (2026-05-04) - v2.0 UI Overhaul

Major Changes

  • New tabbed main window replaces the popup-soup main UI. Built TOGProfessionMaster-style with an AceGUI Frame + TabGroup root and per-tab modules under GUI/Tabs/. Reachable via /fgi v2; the legacy /fgi show window keeps working in parallel where feature parity isn't yet 100 % (Phase 8 retires the legacy window). Eight tabs ship: Scan, Filters, Blacklist, Anti-Spam, History, Statistics, Quiet Zones, Custom Scan.
  • Standardised data-row component — GUI/RowList.lua. Distilled from compactFrame.lua; every list-style v2 tab consumes it instead of building its own row layout. 16 px row height, alternating row banding, class-coloured names, right-edge action icons (Invite / Skip / Decline / Blacklist textures from Interface\RaidFrame\ReadyCheck-* and Interface\Buttons\UI-GroupLoot-Pass-Up), virtual scrolling with mouse-wheel + slider, optional column headers with click-to-sort (asc/desc), optional checkbox-column type wired to a per-row onToggle(entry, val). The scrollbar is a plain CreateFrame("Slider") with manual textures rather than UIPanelScrollBarTemplate because the template's modern secure-scroll-template logic crashes on a non-ScrollFrame parent.

Tabs

  • Scan tab (GUI/Tabs/Scan.lua) — primary working surface. Single-row strip in compact-frame style: >> scan / +(N) invite / Clear / Mode dropdown / F:n S:n A:n X:n counters / wheel-scrollable Lvl X-Y readout. Below the strip, RowList queue with per-player Invite / Skip / Decline / Blacklist icons mirroring the compact tray's per-row layout exactly. Status text on the AceGUI Frame's bottom bar shows version when idle, prepends scan progress while a /who is in flight (vX.Y | Scanning [#####.........] 36% | 12 / 24 queries in plain ASCII — the unicode block characters tried first didn't render in WoW's default font chain). Scan button greys out and shows seconds remaining during the libWho cooldown via the same setMainScanCooldown fan that drives the compact tray's countdown.
  • Filters tab (GUI/Tabs/Filters.lua) — whitelist semantics. v2 filters answer "who do I want?" (selecting Shaman means I want Shamans), not "who do I exclude?". DB.realm.filtersList entries gain a schemaVersion = 2 marker plus wantedClasses / wantedRaces whitelist tables; fn:filtered and isQueryFiltered branch on schemaVersion so v2 entries use whitelist logic and pre-existing v1 deny-list filters keep working unchanged. Inline form: Filter Name + Min/Max Lvl + Save on row 1, Classes ▾ + Races ▾ multi-select dropdowns + Min RIO M+ + Raid + N/H/M kill counts on row 2. The legacy DB.realm.enableFilters master switch is no longer gated at any of the four query-pruning sites — per-filter filterOn is the only switch that should matter; the master flag was a v1-era kill switch that surprised v2 users when their per-row toggles did nothing.
  • Blacklist tab (GUI/Tabs/Blacklist.lua) — RowList view of DB.realm.blackList with name + reason + per-row delete + click-to-edit. Top strip carries Name + Reason + Add inputs and Import GRM / Import GIL buttons; both importers reuse the existing fn:importGRMBlacklist / fn:importIgnoreListBlacklist paths. Edit popup uses the modern WoW StaticPopup template's self.Text / self.EditBox field names (the lowercase v1 names crash on current clients) and supports data.removeFromQueue = true so the Scan tab's row blacklist icon can reuse the same popup to ask for a reason and drop the player from the queue on Save.
  • Anti-Spam tab (GUI/Tabs/AntiSpam.lua) — RowList view of DB.realm.alreadySended. Pre-formatted YYYY-MM-DD HH:MM timestamps so the column sorts chronologically without needing a separate sort key. Clear All button with confirm popup.
  • History tab (GUI/Tabs/History.lua) — RowList view of DB.factionrealm.history.invites, newest first, with class-coloured names and outcome colour codes (accepted=green / declined=red / antispam=orange / blacklisted=purple).
  • Statistics tab (GUI/Tabs/Statistics.lua) — LibGraph-2.0 chart of historical events plus all-time / per-session totals. Per-series visibility checkboxes + period dropdown.
  • Quiet Zones tab (GUI/Tabs/QuietZones.lua) — cascading Continent + Zone dropdowns sourced from C_Map.GetMapInfo / GetMapChildrenInfo; pre-selects the player's current location on first open. Catalog walks once per session and caches.
  • Custom Scan tab (GUI/Tabs/CustomScan.lua) — runs as a scan launcher with named profiles, not just a /who-string list. DB.faction.customScans = [{name, query, selected, strict}, ...] with a per-realm DB.realm.defaultScanSelected toggle. Inline Scan Name + Scan Parameters + Save form on the strip; Save creates or updates by name, preserving On/Strict. Mid-dev migration in OnInitialize flattens any prior queries[] shape into per-query profiles and folds the legacy customWhoList into named "Imported N" entries.
  • Locations tab dropped — replaced by automatic zone subdivision inside the scan engine. functions.lua locationSplit reads zones directly from the just-returned /who results instead of DB.factionrealm.locations, so depth-3 50-cap queries always subdivide by zone without any user-curated list.
  • Messages tab dropped from the v2 main window — message templates are settings, not interactive data; they migrate to the AceConfig settings panel in Phase 8 alongside the auto-welcome-whisper text and the Announce per-channel messages.

Scan engine

  • fn:nextSearch builds the work queue from selected customScans profiles plus (when defaultScanSelected) the level-band sweep. Per-query flags (addon.search.queryFlags[query] = {custom, strict}) tell the result callback whether to subdivide on a 50-cap (per-profile strict) and whether to fire the "50+ results" warning (custom-profile queries only). Falls back to the level-band sweep when nothing is selected so the Scan button never silently no-ops.
  • fn:getEffectiveLevelRange() resolves the default-sweep level range based on DB.global.levelRangePriority. Two sources can drive it: the strip range (DB.global.lowLimit / highLimit, edited via the wheel-scrollable Lvl readout) and the union of active v2 filters' lvlRanges. Priority defaults to "strip"; Phase 8 surfaces the toggle in the AceConfig General sub-page. Filters' level constraints still post-filter on level for any filter that has one — strip controls where to look; filters control who passes.
  • fn:filtered split into v1 deny-list and v2 whitelist branches keyed off schemaVersion. isQueryFiltered understands v2 filters too, so queryWorstCaseCost prunes the worst-case denominator correctly when a v2 class/race filter is active (fixes the +1, +1, +1, +89 progress-bar jumps users hit when running a v2 class filter — the v1 path only inspected classFilter / raceFilter, never wantedClasses / wantedRaces).
  • fn.clearSearch() extracted as a UI-agnostic helper so the legacy mainFrame's local clearSearch and the v2 Scan tab's Clear button delegate to one place. Wipes the queue, the in-session anti-spam cache, and the F/S/A/X session counters via the searchInfo metamethod (n=0 zeroes); leaves DB.realm.alreadySended (the persistent anti-spam list) alone.

UI plumbing

  • addon.UI.ApplyMinResize(frame, minW, minH) — wraps modern SetResizeBounds with a SetMinResize fallback. Each tab module exports MIN_WIDTH / MIN_HEIGHT constants based on its own content; MainWindow's OnGroupSelected applies the active tab's floor on every tab switch and auto-grows the window when the current dimensions are below the new floor.
  • Bottom-row icon strip — three icons aligned with even 3-px gaps between the status bar's right edge, the AceGUI Close button's left edge, and each other: announce horn (Phase 9 placeholder, brass-horn texture from the Blizzard icon atlas), help "i", compact-mode minus (UI-MinusButton-Up/Down). Help-icon tooltip appends a global "bottom-row icons (visible on every tab)" section using |T<path>:14:14|t texture escapes so the rendered icons appear inside the tooltip — avoids the unicode glyph problem.
  • Compact frame — added an X close button to the title row's rightmost position; OnShow refreshes counters and queue rows so a scan run from the v2 main window populates the tray immediately when it's opened. Counter strip got a tooltip overlay explaining the F/S/A/X abbreviations (mirrored to the v2 Scan tab so both views read the same). Expand-to-full button now opens the v2 main window instead of the legacy mainFrame.
  • searchInfo metamethod and onListUpdate broadcast to addon.MainWindow.refreshScanTab when set, so any counter or queue mutation propagates to the v2 Scan tab without the engine needing to know about it specifically. The legacy mainFrame.searchInfo.update is now nil-checked so it can be retired in Phase 8 without the metamethod crashing.
  • Map-press fix — the v2 ESC proxy joined fn.updateEscFrames so the existing DB.global.keepOpen toggle covers the v2 window too. Pressing M (which calls CloseSpecialWindows()) no longer hides the v2 window when keepOpen is on.
  • AceGUI Frame pool-leak fixOnClose detaches every manually-attached child icon (_helpIcon, _announceIcon, _compactModeIcon) from the AceGUI Frame before release. Without it the pool reused the widget on the next :Open() with the old icons still attached, so fresh icons rendered on top of leftovers.

[v1.9.10] (2026-05-02) - Retail Welcome-Spam Fix + Scan-Button Crash Fix

Bug Fixes

  • Scan >> button raised "attempt to index global 'libWho' (a nil value)" on every click. The v1.9.9 OnClick safety-timer fix at mainFrame.lua:735 calls libWho:GetInterval() + 5 to size the safety timer — but libWho is only declared as a local in functions.lua:12 (local libWho = LibStub("FGI-WhoLib")), and locals don't cross files. mainFrame.lua never had its own local declaration, so the reference resolved to the nil global. Crash fired on every scan-button click in v1.9.9; fixed by adding local libWho = LibStub("FGI-WhoLib") to mainFrame.lua's preamble alongside the existing GUI LibStub local. The fix went unnoticed in pre-release testing because the source-of-truth tree lives in _classic_era_ and the user's retail install hadn't been replicated since the v1.9.9 commit landed.
  • Retail: auto-welcome no longer spams a flood of welcome messages on a single guild join, while still firing reliably for every real join. The v1.9.6-era retail welcome path is a GUILD_ROSTER_UPDATE roster-diff handler in functions.lua — it keeps an in-memory snapshot of the guild roster and welcomes any member that appears in the live roster but not in the snapshot. The diff was added because retail's CHAT_MSG_SYSTEM payloads for guild-event messages are tagged as "secret strings" — msg:match against them either raises a taint error or returns no match silently (chat frames have privileged C-side access addons don't share, which is why the message renders in the player's chat but is unreadable from a Lua handler). The v1.9.10 first attempt to switch to CHAT_MSG_SYSTEM with pcall confirmed empirically that real "X has joined the guild" messages didn't fire the welcome path on retail (and the accept counter, which lived on the same handler, also stopped updating).
  • The diff approach was fragile against retail's roster batching, which is what caused the original spam:
    • Initial-load race. Retail streams the guild roster in over multiple GUILD_ROSTER_UPDATE events after login or /reload. The v1.9.x first event captured a partial roster as the baseline snapshot — if it was missing N members (typical when recently-joined alts sit at the bottom of the roster and the first batch truncates before them), the next event treated those N as "new joiners" and welcomed them all.
    • Mid-session roster reload. Anything that triggers a fresh C_GuildInfo.GuildRoster() call (FGI's own peer sync, GRM/GIL imports, other addons) restreams the roster in chunks and produces the same partial-then-full diff, spamming welcomes for already-known members.
  • v1.9.10 keeps the diff (it's the only addon-readable signal for guild joins on retail) and replaces the v1.9.x first-event snapshot with stabilization: while the snapshot isn't yet "stable" the handler silently absorbs every event into the snapshot without firing welcomes, the joined counter, or the accept counter. The snapshot is declared stable after STABLE_THRESHOLD = 2 consecutive events report the same member count — at that point initial roster batching has settled and any subsequent count growth is a real join.
  • After stabilization, mid-session roster reloads triggered by sync/import are absorbed correctly: even if n drops during the reload's transition (early-out keeps the snapshot intact), when it climbs back the diff finds zero new members because the locked snapshot already matches the post-reload roster. Real joins during a stable window grow n past snapshotSize and produce one new member each, firing exactly one welcome message and one whisper per join.
  • A first v1.9.10 attempt added a 30-second login grace window and a burst threshold (suppress welcomes when >2 "new" members appear in one event) on top of the diff, but it traded one bug for another — multiple invitees accepting close together hit the burst threshold and got zero welcomes. Both the grace window and the burst threshold are gone in the final shipped version; stabilization replaces them.
  • Classic / TBC / Wrath / Cata are unaffected — they continue to use the CHAT_MSG_SYSTEM "X has joined the guild" path which fires only on real game system messages and was never susceptible to the roster-batch race. The if not gv.isRetail gate in the functions.lua CHAT_MSG_SYSTEM handler and the early-return on retail in FGI_Core.lua's leave/welcome handler stay in place.

[v1.9.9] (2026-05-02) - Import Confirmation Popup, GIL Auto-Sync, Retail Scan Taint Fix

Improvements

  • Single-class scans skip race subdivision entirely — when the active filter set narrows the class space to exactly one class (e.g. "Hunters only") and a single-level query hits the 50-cap, RACEsplit at functions.lua:1901 now REPLACES the query with <level> c-<class> and doesn't subdivide by race. A single 60-60 c-Hunter /who returns up to 50 Hunters across all races in one round-trip; race subdivision only kicks in if THAT class-tagged query also hits the cap, on which RACEsplit is re-entered with qp.class already set, the early branch is skipped, and the standard race-subdivide path runs with the class tag preserved. Per-race queries on the second pass are emitted in canonical <level> r-<Race> c-<Class> order so getSearchDeepLvl correctly classifies them as sLevel=3 (the regex requires r- before c-); reconstructing from the level portion of the query also avoids the duplicate-tag trap that would have produced 60-60 c-Class r-Race c-Class. Helper soleAllowedClass(qmin, qmax) at functions.lua:1707 computes the forced class by intersecting the rejected-class sets of all active filters whose level range covers the query's level. Reads the classFilter rejection flag as truthy rather than strictly == true so any persisted value WoW treats as truthy is honored (covers historical save formats and sync from older peers). queryWorstCaseCost mirrors the optimization at sLevel=1 — credits 1 (level) + 1 (class-only replacement) + N (compatible races) when the query has no class tag and 1 (class-only) + N (compatible races) when re-entered with the class tag — so the progress bar denominator stays accurate. Races whose RaceClassCombo doesn't include the forced class (e.g. Undead can't be Shaman on Classic Horde) are skipped on the second pass instead of queuing impossible combos. A [soleAllowedClass] debug-line print is gated behind addon.debug for /fgi debug diagnosis. Only fires when class filtering is active and ends up at a single allowed class — no behaviour change for users with no class filter or a multi-class filter.

New Features

  • GIL auto-sync on login — feature parity with the v1.9.3 grmAutoSync opt-in. New DB.global.ignoreAutoSync flag (default false) added to defaults at FGI_Core.lua:489 and exposed via a new "Auto-sync GIL list on login" checkbox in Settings → Main, sitting between the GRM import button and the GIL import button. The PLAYER_LOGIN handler at the bottom of functions.lua now defers fn:importIgnoreListBlacklist() by 2 s alongside the existing GRM auto-sync deferral; the same delay covers WoW's known login-time bug where every /ignore entry comes back as UNKNOWN until the client finishes populating the cache (called out in the GIL readme), so the importer doesn't drop valid entries. New locale keys ignoreAutoSync and ignoreAutoSyncTooltip added to enUS.lua and ruRU.lua. Persistence path mirrors GRM: checkbox OnClick writes to DB.global.ignoreAutoSync, settings open re-reads the value at settings.lua:739.

Bug Fixes

  • Retail: "secret string" taint spam from Scan.lua's invite-response handlerpausePlayFilter at Scan.lua:109 registered CHAT_MSG_SYSTEM on every client. On Retail the messages it cares about (decline / auto-decline / not-found / invite-sent) arrive as Blizzard "secret strings" — any strfind / strsub / format / == against the message raises "attempt to perform string conversion on a secret string value (execution tainted by 'FastGuildInvite')", spammed the error log on every system message and tainted the addon's execution context for protected calls. Same fix shape as the v1.9.6 guard on functions.lua's guild-join handler: on Retail, register UI_ERROR_MESSAGE only — CHAT_MSG_SYSTEM is skipped entirely. UI_ERROR_MESSAGE delivers the decline / auto-decline / not-found message text without taint; the existing OnEvent dispatch already handled both events. Trade-off documented in the source: the "You have invited X to join your guild" success-side confirmation isn't an error and isn't delivered via UI_ERROR_MESSAGE, so the explicit invite-sent signal is dropped on Retail — pendingInvites entries clear via the existing GUILD_ROSTER_UPDATE diff (when the player accepts) or via the decline / not-found UI_ERROR_MESSAGE branches (when they don't).
  • GRM importer prefix swapped to <GRM> for consistency with <GIL> / <FGI> — for parity with the GIL fix above. fn:importGRMBlacklist() at functions.lua:977 now writes "<GRM>" (no reasonBanned) or "<GRM> <reasonBanned>" instead of the v1.9.3 "GRM: <reason>" / "GRM: imported". Same tryAdd contract as the GIL importer (added | refreshed | skipped) and the same legacy-reason refresh path: existing rows whose reason matches "GRM: ...", "<GRM>", or "<GRM> ..." are upgraded in place on a re-import, so the user gets the new format on every old row by clicking Import GRM blacklist once after the upgrade. The legacy detector is GRM-specific so a GRM re-import won't touch GIL-imported rows and vice versa. Locale key grmImportDone updated in enUS.lua and ruRU.lua to take three counts; the function's return signature is now imported, refreshed, skipped (the GRM-not-loaded early return is also 0, 0, 0).
  • GIL imports lost the GIL per-entry note and showed "Ignored" insteadfn:importIgnoreListBlacklist() at functions.lua:1065 read two sources in this order: (1) the WoW built-in /ignore list with the literal reason "Ignored", then (2) _G.GlobalIgnoreDB with the better reason "GIL: <note>". Because the GIL addon mirrors every WoW /ignore entry into its own list, every name was already present in the FGI blacklist by the time Source 2 ran, and the dedupe in tryAdd rejected the GIL upgrade — so the better note-bearing reason from GIL was thrown away and the blacklist held "Ignored" for everything. The reason format was also "GIL: <note>" instead of the angle-bracket prefix the addon uses everywhere else (<FGI> chat header, etc.). Three changes: (a) Source 2 (GIL with notes) now runs before Source 1 (WoW /ignore fallback), so each player's GIL note wins over the bare mirror entry. (b) The reason format is now <GIL> for no-note rows and <GIL> <note> when GIL carries a per-entry note. (c) tryAdd returns one of added | refreshed | skipped: when an existing blacklist entry's reason is one of the legacy auto-import strings ("Ignored", "GIL", "GIL: <note>", "<GIL>", "<GIL> <note>"), the row's reason is refreshed with the latest string from GIL instead of being skipped. So a single re-import after the upgrade rewrites every existing "Ignored" row to <GIL> <note> automatically — the user doesn't have to clear the blacklist and start over. Manually-typed reasons are detected via the legacy-prefix check and never overwritten. The import-done summary line and modal popup now read "GIL import: %d added, %d refreshed, %d skipped" (locale keys ignoreImportDone updated in both enUS.lua and ruRU.lua); the function's return signature is now imported, refreshed, skipped.
  • Scan >> button on Retail and TBC un-greyed itself partway through every cooldown — companion to the click-rapidly fix below: the OnClick safety timer was scheduled at T = (DB.global.scanInterval or 5) + 2, but two facts make that wrong on Retail/TBC. (a) LibWho_Retail.lua overrides libWho:GetInterval() to return its own hardcoded retailConfig.interval = 8 regardless of the user's DB.global.scanInterval, so on Retail the safety timer fired at 7s while libWho's actual cooldown ran for ~10s. (b) timeCallbackStart (which sets scanCooldown=true and starts the visible countdown label) doesn't fire when OnClick runs — it fires when WHO_LIST_UPDATE arrives, 1-3+ s later. So the safety timer's countdown started from OnClick but libWho's countdown started from WHO_LIST_UPDATE, leaving the safety timer firing several seconds before libWho's timeCallbackEnd. Result: button visibly un-greyed and clickable for the last ~3-5 s of every cooldown while the timer label was still ticking down. TBC has the same shape of bug whenever WHO_LIST_UPDATE latency is high enough; Classic generally has fast-enough /who responses that the windows happen to overlap. Fix: the OnClick safety timer at mainFrame.lua:721 now uses libWho:GetInterval() + 5 instead of DB.global.scanInterval + 2. The + 5 budgets for the WHO_LIST_UPDATE round-trip; using libWho:GetInterval() directly self-adjusts to whatever interval the active libWho variant returns (Classic/TBC: the user's configured scan interval; Retail: 8). The safety timer still does its job as a true backstop for stuck /who queries — it just can't fire ahead of libWho's normal cooldown end any more.
  • Scan >> button on the main window looked clickable mid-cooldown when clicked rapidly — every click of the main window's >> button schedules a C_Timer.After(scanInterval + 2, ...) safety timer at mainFrame.lua:709 to force the cooldown off in case timeCallbackEnd from libWho never fires (stuck /who query). The timer's body was if mainButtonsGRP.scanCooldown then setPausePlayCooldown(false) end — it had no way to tell which cooldown cycle it was the safety net for. When the user clicked >> again the moment the previous cooldown expired, click 1's safety timer was still pending its 2-second buffer; it fired ~2 s into click 2's cooldown, saw scanCooldown == true (correctly set by click 2's timeCallbackStart), assumed it was its own stale cooldown, and turned the cooldown off — making the button visibly clickable for the rest of click 2's interval. Waiting longer than scanInterval + 2 between clicks hid the bug because click 1's safety timer had already fired and no-op'd before click 2 started its cooldown. The compact tray's >> button wasn't affected because it has no safety timer at all (it relies entirely on the libWho timeCallbackStart/End pair driving cf.scanCooldown). Fix: a monotonic mainButtonsGRP.cooldownGen counter is incremented in OnClick after each new cooldown is set; the OnClick captures the new value into myGen and the safety timer's callback only re-enables the cooldown when cooldownGen == myGen still holds at fire time. Click 2's bump invalidates click 1's safety timer; click 2's own safety timer carries myGen = cooldownGen and continues to be the canonical safety net for click 2's cycle. Other code paths that toggle the cooldown (timeCallbackStart, timeCallbackEnd, clearSearch) deliberately leave cooldownGen alone so they don't accidentally invalidate a still-needed safety timer.
  • Window background opacity slider couldn't reach a fully-opaque window even at 1.0fn:applyWindowOpacity() at functions.lua:927 only called frame:SetBackdropColor(0, 0, 0, a) on each AceGUI window. The forked AceGUI FrameBackdrop at Libs/GUI.lua:330 sets bgFile = "Interface\DialogFrame\UI-DialogBox-Background" — the standard WoW parchment texture, which carries its own per-pixel alpha channel. SetBackdropColor multiplies the requested alpha against the texture's existing alpha, so any pixel of the parchment that ships at alpha < 1 stayed translucent no matter what value the slider passed. The tooltip promised "1.0 = fully opaque" but in practice the user could still see the game scene through the window at the slider's max. Fix: lazily attach a solid black BACKGROUND-drawlayer texture at sublevel -8 (drawn behind the backdrop's own bgFile) to each managed window the first time applyWindowOpacity runs, and scale its alpha with the slider alongside SetBackdropColor. At slider = 1.0 the parchment's translucent pixels now show the solid black backing instead of the world behind, so the window is genuinely opaque; smaller slider values still let the world through proportionally because both layers fade together. Compact tray's existing cf.bg texture wasn't affected by this bug (it was already a solid colour) and keeps its existing 0.7-multiplier behaviour.
  • GIL/GRM importers created Name + Name-Realm duplicates in the blacklist — two layered issues, both surfaced by the v1.9.6 GIL importer. (1) The v1.9.3 connected-realm migration in FastGuildInvite:OnInitialize() (originally FGI_Core.lua:570) was gated by DB.global.migratedToFullRealmKeys. DB.global is account-wide in AceDB, so once any character on any realm completed the migration, the flag was set and bare-name entries on every other realm in that account were never cleaned up — they sat there until a sync from another peer or an importer touched them. (2) The GIL importer's tryAdd at functions.lua:1064 and the GRM importer at functions.lua:995 deduped using only the canonical DB.realm.blackList[Name-Realm] lookup. With a stale bare-name entry like Bob still in the table, the canonical lookup for Bob-RealmA returned nil and the importer added the player a second time as Bob-RealmA, leaving the table holding both forms for the same player. Concrete reproducer: account with two characters across realms, the first character ran v1.9.3 migration on its own realm, second character on the other realm clicked Import GIL list — every same-realm player who was already on the FGI blacklist as a bare name got a duplicate Name-Realm row added with reason "GIL" / "Ignored". Fix: (a) the migration is now idempotent and ungated — runs on every login, walks DB.realm.blackList and DB.realm.blackListRemoved, and when a bare key has a corresponding canonical key on the local realm the bare key is dropped (canonical wins, since it's the form everything else writes today); when there's no canonical sibling, the bare key is renamed to canonical (the original v1.9.3 behaviour). The flag is still set true for any external code that may still read it. (b) Both importers now dedupe via IsInBlackList(name) (which checks canonical, then bare, then prefix) instead of DB.realm.blackList[fullName], so even if a bare-name entry slips in mid-session via sync the importer won't double-add. (c) Tombstone checks in tryAdd also look up the bare-name form so a player tombstoned pre-v1.9.3 stays tombstoned through a Name-Realm import attempt. Existing duplicate rows from before the upgrade are auto-cleaned at next login by the new normalize pass — no user action required.
  • GIL / GRM import buttons gave no visible confirmation that the import ran — both fn:importIgnoreListBlacklist() (GIL + WoW /ignore) at functions.lua:1040 and fn:importGRMBlacklist() at functions.lua:931 only emitted a print("<FGI> ...") line to the default chat frame after computing the imported / skipped counts. Users who had busy chat windows, channel-filtered tabs, or simply weren't watching chat at the moment the button was clicked saw nothing happen and assumed the button was broken — particularly likely with the GIL importer since fresh installs often have an empty /ignore list and a missing GlobalIgnoreDB, so a 0 added, 0 skipped chat line is even easier to miss. Fix: both importers now also call addon.API.ShowMessage(msg) after the chat print, surfacing the same "GIL import: %d added, %d skipped..." / "GRM import: %d added, %d skipped..." text in a modal message() popup on Classic / Anniversary / TBC / Wrath / Cata and via UIErrorsFrame:AddMessage on Retail (the addon.API.ShowMessage wrapper at FGI_APICompat.lua:229 already handles the version split because Blizzard removed message() from the Retail global namespace). Chat print is preserved so the result still lives in chat scrollback for users who want it.

[v1.9.6] (2026-04-30) - GIL Import, Retail Joins, Filter-Aware Progress

New Features

  • Import GlobalIgnoreList (GIL) and WoW /ignore list into the blacklist — new "Import GIL list" button in Settings → Main mirrors the v1.9.3 "Import GRM blacklist" importer (label uses "list" not "blacklist" because GIL stores an ignore list, not bans). Reads two sources, both optional: (1) the WoW built-in /ignore list via C_FriendList.GetNumIgnores() / C_FriendList.GetIgnoreName() (always available, capped at 50), and (2) the GlobalIgnoreList addon's account-wide ignore list via _G.GlobalIgnoreDB.ignoreList (no 50-cap, syncs across characters, supports per-entry notes). Implementation in fn:importIgnoreListBlacklist() at functions.lua:1021; reads GIL's parallel-array layout (ignoreList[i] / typeList[i] / notes[i]) and only imports typeList == "player" entries, skipping NPCs and server-wide ignores. Reason text is "Ignored" for the WoW list and "GIL: <note>" for GIL entries (or just "GIL" when no note). Same realm-set filtering as the GRM importer (only same-realm + connected realms via GetAutoCompleteRealms) and same tombstone respect (DB.realm.blackListRemoved skips entries the user previously removed from FGI). Locale strings ignoreImportButton, ignoreImportButtonTooltip, ignoreImportDone added in enUS.lua and ruRU.lua. Button positioned in Settings between the existing GRM auto-sync and the "Show update info" toggle.

Bug Fixes

  • First-cycle progress bar denominator was the unfiltered worst case, never reaching 100% before cycle wrapqueryWorstCaseCost(query) at functions.lua:1508 computed the per-level cost as 1 + |L.race| + Σ|RaceClassCombo[race]|, treating every race and every class as if it would actually be scanned. But the live scan skips: (a) races on the filter's raceFilter ignore list, (b) classes on the filter's classFilter ignore list, (c) races whose only classes are all on the class-ignore list (the v1.9.5 race-class compatibility skip at functions.lua:1354). With a "Shaman/Hunter only" class filter on Classic Era a 10-level range estimated ~500 scans but actually ran ~150 — the bar climbed to maybe 30 % then wrapped to a corrected denominator on cycle 2 (the existing wrap-recompute path at functions.lua:1872 does use the post-subdivision queue, which is why cycle 2+ looked fine). Fix: queryWorstCaseCost now reuses isQueryFiltered to predict whether each candidate race/class subquery will survive the filter pass and only counts the ones that will. The level portion of the parent query is preserved when constructing the sample subqueries so the filter's level-bounds check sees realistic values. Filters-off path is gated behind a quick next(DB.realm.filtersList) ~= nil check so users without active filters skip the per-race isQueryFiltered calls entirely (no behaviour change for the unfiltered case).
  • Scan >> button still fired nextSearch() during the cooldown — the v1.9.3-v1.9.5 fixes greyed the button visually and aligned the safety timer with the configured scan interval, but the OnClick handler at mainFrame.lua:686 had no explicit cooldown guard. It relied entirely on Button:Disable() to suppress clicks. Two paths bypassed that: the keybind handler at FGI_Core.lua:652 calls pausePlay.frame:Click() directly, and per WoW's API :Click() doesn't honour the disabled state — OnClick fires anyway and nextSearch() runs. On modern retail (11.x), custom OnClick scripts set via frame:SetScript("OnClick", ...) on a UIPanelButtonTemplate button are also not always gated by Disable() the way the template's built-in click path is, so even direct mouse clicks could land mid-cooldown on retail / Anniversary. Fix: added an explicit mainButtonsGRP.scanCooldown flag (set/cleared by setPausePlayCooldown(on) alongside frame:SetDisabled(on)) and check it at the top of OnClick. The flag is the same pattern the compact tray button at compactFrame.lua:113 already uses (cf.scanCooldown), so both views now reject cooldown clicks the same way regardless of how the click is dispatched. Safety timer at the bottom of OnClick now also reads the flag instead of :IsEnabled(), so its re-enable check stays in sync with the explicit state we're tracking.
  • Accepted counter and auto-welcome both broken on retail when someone joined the guild — the CHAT_MSG_SYSTEM "X has joined the guild" handler at functions.lua:590 (Accepted counter / accept history) and the matching one at FGI_Core.lua:155 (auto-welcome guild-chat message + auto-welcome whisper) both short-circuit on retail at line 170 because msg is a tainted secret string — msg:match raises taint errors per system message and would spam the error log. The handler comment already noted "Guild-join detection on Retail would need to be reimplemented via GUILD_ROSTER_UPDATE diffing; skipping here keeps the addon quiet" — that work was deferred and both features just no-op'd on retail. Fix: added a retail-only GUILD_ROSTER_UPDATE handler that snapshots the guild roster (keyed by normalised name to match pendingInvites and alreadySended) and diffs against the previous snapshot. Each new member bumps fn.history:joined(); members in pendingInvites or alreadySended additionally bump the Accepted counter and log an "accepted" history entry; and the same loop fires the auto-welcome guild-chat message and welcome whisper if the user has them configured in the Guild settings tab. The chat-template NAME substitution uses Ambiguate(name, "none") so the message reads cleanly without a -RealmName suffix; the whisper target keeps the realm-suffixed name so cross-realm whispers route correctly. 2.5 s delay before sending matches the Classic path. Gated on member-count growth (n <= snapshotSize short-circuits) so the full roster scan only happens when there's actually a new member, not on every status-change fire (which retail does dozens of times per minute on busy guilds). Classic / TBC / Wrath / Cata still use the existing CHAT_MSG_SYSTEM paths — no behaviour change there.

[v1.9.5] (2026-04-29) - Scan, Filter, & UI Polish

Improvements

  • Shaman class colour is now version-specific — Classic Era and Anniversary use the original vanilla convention where Shaman shares Paladin's pink (#F58CBA), since the two are faction-mirror classes (Shaman = Horde-only, Paladin = Alliance-only) on those versions. Retail / TBC / Wrath / Cata continue to use Blizzard's canonical blue (#0070DE) from RAID_CLASS_COLORS, since both factions can roll either class on those versions and visually distinct colours match what players see in the game's own UI. Resolved at addon load by gating on addon.gameVersion.isClassic at init.lua:54. Required swapping the TOC load order so FGI_Compatibility.lua loads before init.lua in all 5 TOC files — previously init.lua ran first and saw addon.gameVersion as nil, so the version check evaluated to false and Classic always fell through to blue. The comment in init.lua already said "FGI_Compatibility.lua must be loaded first", but the actual TOC ordering didn't match that intent.
  • Compact tray's >> scan button now shows the cooldown countdown — the compact tray's scan button at compactFrame.lua:103 was a plain text button that always read >>, with no visual indication of the WHO-throttle interval. Users in compact mode had to guess when the scan was ready and click repeatedly hoping it'd take. The main window's >> button has had a numeric countdown via pausePlayLabel since v1.9.0, driven by timeCallbackStart / ticker / timeCallbackEnd from libWho. Added cf.setScanCooldown(remaining) to compactFrame.lua that swaps the label between >> and the seconds remaining, dims the label colour, and hides the hover highlight while counting down. The compact button's OnClick short-circuits via cf.scanCooldown while active. Hooked the libWho callbacks in functions.lua so the same chain that drives the main window's countdown also updates the compact tray button — both views stay in sync, and the compact OnClick also applies an immediate cooldown so rapid clicks don't fire extra /who requests in the gap before WHO_LIST_UPDATE arrives.
  • Race subdivision now respects the class filter via RaceClassComboisQueryFiltered at functions.lua:1306 only consulted the explicit race-ignore list at sLevel=2 (level + race query). If a filter restricted classes (e.g. "wanted Shaman/Hunter only", with all other classes on the ignore list) but had no race exclusions, every race got subdivided and queried even when the race couldn't be any non-excluded class. The race query came back, every result got individually filtered out, then the queue subdivided each useless race by class, where the class-level check finally caught it — wasted time and inflated the progress denominator. Added a race-class compatibility check at sLevel=2: look up the query's race in RaceClassCombo, and if every class the race can be is on the filter's ignore list, mark the race query as filtered. Concrete impact: on Classic Era with "wanted Shaman/Hunter only", the addon now skips Undead, Gnome, and Human entirely (none of those races can be either class). On retail the gain is filter-dependent — every race in the retail RaceClassCombo can be Hunter, so a Hunter-inclusive filter doesn't shrink the race set, but tighter filters (e.g. "Druid only", "Demon Hunter only", "Shaman only") drop most races at the race-subdivision step instead of querying-then-filtering each one.

Bug Fixes

  • "Found 50, subdividing for full coverage..." printed even when no further subdivision was possiblewillSubdivide at functions.lua:1750 was set to true whenever searchLvl ∈ {1,2,3} and #results >= 50, but the dispatch table inside searchAddWhoList (line 1547) only subdivides at sLevel=3 when #DB.factionrealm.locations > 0. Most users have no saved locations, so a level-3 query (race + class) returning 50 results printed the subdivide message and then quietly skipped every subdivision branch — no new queries got queued. The mismatch also shorted the progress denominator: cost = willSubdivide and 1 or queryWorstCaseCost(query) credited 1 instead of the full worst-case, making the bar drag for the rest of the cycle. Fix: gate the sLevel=3 case on #DB.factionrealm.locations > 0 to match the actual dispatch — the message only fires when subdivision will really happen, and the cost calculation now correctly credits the full worst-case for queries that won't subdivide.
  • Scan >> button became yellow/clickable before the visible cooldown timer reached zero — the safety re-enable timer in the OnClick handler at mainFrame.lua:680 used FGI_DEFAULT_SEARCHINTERVAL + 2 (= 7 s, hardcoded constant from before the v1.9.x configurable scan-interval feature). The user-facing scan interval (DB.global.scanInterval, 2-30 s, configured in Settings > Main) drives the visible countdown via libWho:GetInterval(), and timeCallbackEnd re-enables the button after that interval. When the user set the interval above 5 s (e.g. 10, 15, 30), the safety timer fired first at 7 s while the visible countdown still had time left, calling setPausePlayCooldown(false) which restored the highlight texture — making the button look hover-clickable mid-cooldown. The v1.9.4 fix made highlight show/hide reliable, but didn't address the safety timer firing early. Fix: the safety timer now reads (DB.global.scanInterval or FGI_SCANINTERVALTIME) + 2 at click time, so it always outlasts the configured scan interval regardless of the user's setting. The +2 buffer keeps the safety net intact for the rare case where timeCallbackEnd doesn't fire (WHO query never resolves).
  • "Next scan" label briefly blanked to "—" at the end of every scan cycle before the loop wrappedscanInfo.update() at mainFrame.lua:395 computed nextQuery from whoQueryList[progress] (with the v1.9.4 between-fire-and-callback offset), but didn't account for the cycle-wrap window. After the WHO callback for the last entry in the queue fires, progress advances past #whoQueryList; both branches of the if/else then resolve to nil because the index is out of bounds. The label rendered "Next scan: —" until the very next nextSearch() call clamped progress back to 1 (functions.lua:1884) and re-fired whoQueryList[1]. Most visible to retail users, where shorter scan intervals make the wrap window land more often. The loop is genuinely cyclic and nextSearch() will fire list[1] next, so showing it eagerly matches reality. Fix: when the computed nextQuery is nil and the queue is non-empty, fall through to list[1].
  • Anti-Spam List "Clear All" confirmation popup didn't go away after clicking Yes — the FGI_ANTISPAM_CLEAR_ALL StaticPopupDialog's OnAccept at antiSpamList.lua:211 ended with return true. Blizzard's StaticPopup_OnClick interprets the OnAccept return value as a "keep the popup open" flag (hide = not OnAccept(...)), so returning true left the confirmation dialog on screen after Yes was clicked — making it look like nothing happened. Other dialogs in the codebase (FGI_BLACKLIST, FGI_BLACKLIST_CHANGE) also return true, but they explicitly call StaticPopup_Hide(...) first to chain to a follow-up popup; this one didn't have that chain, so the dialog just sat there. Fix: removed return true, so the popup auto-hides after OnAccept runs. While there, switched the data clear from DB.realm.alreadySended = {} (whole-table reassignment) to an iterate-and-nil loop that preserves the AceDB-managed table reference, and also flushed addon.search.tempSendedInvites so the in-session scan cache doesn't keep filtering players the user just cleared from the persistent list.

[v1.9.4] (2026-04-28) - Scan & Progress Bar Fixes, Vertical-Only Main Window

Bug Fixes

  • Scan >> button cooldown helper threw bad argument #2 to '?' — the v1.9.3 setPausePlayCooldown(on) helper on the >> button tried to clear/restore the highlight texture with frame:SetHighlightTexture(nil) (clear) and frame:SetHighlightTexture(textureObj) (restore). The WoW API rejects both: SetHighlightTexture requires a string asset path, so passing nil raises "bad argument #2 to '?' (Usage: self:SetHighlightTexture(asset [, blendMode]))" and passing the Texture object never reaches the restore path because the disable call already errored. Fix: hide/show the existing texture object directly via pausePlayHighlight:Hide() / :Show(). The captured texture reference at construction time stays valid for the lifetime of the button.
  • Progress bar started at 70% on login — the SetProgress(self, cur) function at the top of mainFrame.lua:108 had a cur = (cur or self._lastCur or 70) fallback. At login, the bar is initialised by a :SetProgress() call with no argument before any scan has run, so cur == nil and _lastCur == nil falls all the way through to 70 and renders the bar at 70%. Changed the fallback to 0.
  • "Next scan" label duplicated "Last scan" between fire and callbackscanInfo.update() read whoQueryList[progress] for the next-scan slot, but nextSearch() calls update() immediately after setting lastQuery = curQuery and BEFORE the WHO callback increments progress. So during the entire scan-interval window (5+ seconds) Next showed the same query as Last. Especially visible with narrow filters (e.g. only Shamans at level 70) where each query takes the full interval. Fix: update() now detects the between-fire-and-callback state by checking list[progress] == lastQuery, and looks one slot ahead in that case.
  • Scan >> button could end up perma-disabled or with missing highlight — there are two paths that disable/re-enable the scan button: the OnClick path (uses the v1.9.3 setPausePlayCooldown helper that hides/shows the highlight texture) and the LibWho callbacks (timeCallbackStart / timeCallbackEnd in functions.lua, which used raw pp:SetDisabled(...)). Whichever fired first won; the other path's state could be left out of sync. When the LibWho timer re-enabled the button before the OnClick safety timer fired, the OnClick safety timer saw the button as enabled and skipped the highlight restore — highlight stayed hidden permanently and the button looked broken/stuck until clearSearch() (Reset) ran. Fix: timeCallbackStart / timeCallbackEnd now route through mainButtonsGRP.setPausePlayCooldown (with a fallback to raw SetDisabled if the main frame isn't constructed yet), so both disable paths and both re-enable paths leave the button in a single consistent state.
  • Settings Main scrollbar didn't respond to the mouse wheel — the v1.9.3 scroll wrapper had EnableMouseWheel(true) and an OnMouseWheel handler on the ScrollFrame, but mouse wheel events in WoW don't bubble to parents; they go to the topmost frame at the cursor with EnableMouseWheel(true). The AceGUI checkboxes inside the Main page have EnableMouse(true) (for click handling) but no wheel handler, so they were "topmost" at the cursor and silently swallowed wheel events instead of letting them reach the ScrollFrame underneath. Fix: added a transparent overlay frame covering the entire scroll viewport with EnableMouse(false) (clicks still pass through to the checkboxes) and EnableMouseWheel(true) (wheel events land on the catcher and forward to the scrollbar). The catcher sits at frame level +100 so it's reliably above the AceGUI children regardless of their internal frame ordering.
  • Progress bar shot past 100% on level-range scans — the v1.9.3 queryWorstCaseCost(query) only handled single-level queries: it returned 1 + |races| + sum_classes (~25 on Classic Era Horde) regardless of how wide the level range was. But level ranges subdivide BY LEVEL FIRST (binary split) before race/class subdivisions, so a 70-80 query is really 11 single-level slices × 25 + 10 internal binary-split nodes = 285 worst-case scans, not 25. Result: as soon as one /who in a range scan returned <50, it credited the (under-counted) 25, blowing past the denominator and showing things like 52/25 (208%). Fix: range-aware formula range_size × per_level_cost + (range_size - 1). Single-level queries still return the same value as before; only ranges change.
  • Progress bar stacked across scan cycles instead of resetting at 100% — the addon scans cyclically: once the queue is consumed, nextSearch() wraps progress back to 1 and re-fires every query so newly-online players get caught. v1.9.3 set progressTotal once at the first scan and let progressDone keep accumulating across cycle wraps, producing values like 1559/1559 → 2338/1559 after the second pass. Fix: nextSearch() now detects the wrap (progress > #queue) and recomputes progressTotal from the CURRENT queue (which may have grown via subdivisions during cycle 1) plus resets progressDone to 0. Each full pass through the expanded queue now shows a clean 0→100% sweep.

Improvements

  • Main window is now vertical-only resizable — same pattern as the v1.9.2 history window. Width is locked at MAIN_W = 635 (bumped from the locale default of 620 — see button-row centering note below), height stays free between 450 and 1500. The right-edge sizer_e grip is hidden because horizontal drag has no effect, but the bottom-right sizer_se corner grip stays visible (its diagonal-line texture is the universal "this window is resizable" affordance) — StartSizing("BOTTOMRIGHT") only changes height because the width bounds are clamped. The PLAYER_LOGIN restore stops applying any saved custom width; users with a non-default saved width will see a one-time snap to MAIN_W. Height is still saved/restored across sessions.
  • Bottom button row clipped against the window border — the locale default size.mainFrameW = 620 left mainButtonsGRP (width = MAIN_W - 20 = 600) too narrow for the 605 px button row (8 buttons: 3×57 + 4×95 + 1×40 + 7×2 gaps), so the leftmost (Invite) and rightmost (Compact toggle) buttons each overflowed ~2.5 px past mainButtonsGRP and clipped against the window's inner border. Bumped MAIN_W to 635 — mainButtonsGRP becomes 615 px wide, leaving 5 px of clean padding either side of the 605 px row. Centering anchor for the Invite button shifted from -273 to -274 to keep the row exactly symmetric.
  • Settings Main: wheel dead zone between widgets and scrollbar — the v1.9.4 wheel catcher used :SetAllPoints(settings.mainScroll) so it only covered the ScrollFrame's footprint. The scrollbar lives anchored to the settings WINDOW's right edge (not the ScrollFrame's), so the strip between the ScrollFrame's right edge and the scrollbar's left edge had no wheel-enabled frame and silently dropped wheel events. Fix: anchor the catcher from mainScroll TOPLEFT to mainScrollBar BOTTOMRIGHT so it spans the entire content area through the scrollbar. The catcher still has EnableMouse(false) so clicks on the scrollbar slider still work.
  • History retention and scan interval input boxes were oversized for 3-digit values — both EditBoxes (history retention bounded 0-365, scan interval bounded 2-FGI_SEARCHINTERVAL_MAX) were sized to fill ~180-220 px, way more than a 3-digit number needs. AceGUI's EditBox layout has both label (TOPLEFT→TOPRIGHT) and editbox (BOTTOMLEFT→BOTTOMRIGHT) anchored to fill the widget frame, so a naive SetWidth(60) would shrink the label too. Fix on both: keep the widget frame at its original width so the label renders fully, then narrow only the inner editbox by replacing its right anchor with a fixed SetWidth(50) (fits "365" with a few px breathing room).

[v1.9.3] (2026-04-27) - Connected-Realm Blacklist, GRM Import, Smarter Filters, Opacity, Progress

New Features

  • feat: GRM blacklist import — new Settings page Button (Import GRM blacklist) reads active bans from Guild Roster Manager and adds them to the FGI blacklist, prefixed with GRM: so the source is visible in the blacklist UI. Walks both GRM_GuildMemberHistory_Save and GRM_PlayersThatLeftHistory_Save, importing any entry where bannedInfo[1] == true and bannedInfo[3] == false. Filters by the connected-realm set (GetAutoCompleteRealms() + current realm) so cross-cluster bans aren't pulled in. Skips entries already in the FGI blacklist and respects DB.realm.blackListRemoved tombstones so re-imports don't resurrect entries the user deleted from FGI. Reports N added, M skipped after each run.
  • feat: GRM blacklist auto-sync — opt-in Settings checkbox Auto-sync GRM blacklist on login (default off) runs the importer 2 seconds after every PLAYER_LOGIN; the delay covers GRM's own SavedVariables load timing.
  • feat: Window background opacity slider — new Settings slider Window background opacity (range 0.3–1.0, default 1.0) controls the background transparency of the main window, Settings, Statistics, History, and the compact tray simultaneously. Single-pass fn:applyWindowOpacity() walks interface.{mainFrame,settings,graphFrame,historyFrame,compactFrame} and updates the AceGUI :SetBackdropColor (or for the compact tray's plain texture, cf.bg:SetColorTexture scaled against the existing 0.7 base). Applied on slider drag and once on PLAYER_LOGIN; sticky across hide/show.
  • feat: Worst-case progress denominator — the main window progress bar now never moves backward when a /who returns 50 results and gets subdivided. At scan start, fn:queryWorstCaseCost(query) precomputes the maximum number of /who calls each level slice could fan out into (1 + |races| + sum_over_races(|classes_for_race|)), and the bar fills based on actualCompleted / worstCaseTotal. A query that doesn't subdivide credits its full worst-case (skipped subdivisions "cover" themselves); a query that does subdivide credits only itself, then its children credit their own worst-case as they complete. Net result: the bar fills smoothly, hits 100% early when filters or low population skip subdivisions, and never reverses.
  • feat: Subdivision chat note — when /who returns 50 results and the addon queues subdivisions, a single chat line <FGI> Found 50, subdividing for full coverage... prints once per breakdown event. Suppressed by the existing addonMSG mute setting. Helps users understand why the progress bar isn't already at 100%.
  • feat: Level spinner push behaviour — scrolling the min-level spinner upward past the max (or the max spinner downward past the min) now pushes the partner spinner along instead of clamping silently. New widget:SetPushPartner(cb) API on makeLvlSpinner; the callbacks bump DB.global.{low,high}Limit by the same scroll mod (1 normal, 5 with Shift) and call :SetText on the partner widget so the displayed value follows the DB state.
  • feat: Officer-chat blacklist messages tagged <FGI> — both add and remove notices in the officer channel now begin with the <FGI> prefix so they're trivially distinguishable from messages emitted by Guild Roster Manager and other addons that broadcast their own blacklist activity.
  • feat: Settings page Main scroll — the Main page now wraps its widget group in a CreateFrame("ScrollFrame") + UIPanelScrollBarTemplate slider (mirroring AceGUI's own ScrollFrame pattern). The settings window stays non-resizable; the inner GroupFrame is sized 800 px tall so every widget fits with room for future additions, and the scroll wrapper clips/scrolls. The scrollbar is anchored to the settings window's right edge (not the ScrollFrame's right edge) so it sits flush against the window border instead of floating mid-window over content. Mouse wheel scrolls 30 px per tick.
  • feat: Compact tray invite button — added a +(N) invite button to the title row of the compact tray, positioned just to the left of the >> scan button. Click invites the head of the queue via fn:invitePlayer() (same as the main UI's invite button); the (N) count refreshes via cf.refresh() so it always tracks #addon.search.inviteList, mirroring the main window's inviteBtnText() behaviour.
  • feat: Tooltip auto-flip helper — new addon.Tooltip.Owner(frame) in functions.lua (mirrored from TOGProfessionMaster's addon.Tooltip.Owner) replaces every raw GameTooltip:SetOwner(frame, "ANCHOR_TOP") call in FGI. The helper anchors ANCHOR_BOTTOMLEFT when the frame is in the top half of the screen, ANCHOR_TOPLEFT when in the bottom half — so tooltips on top-row buttons (compact tray, main window button row) appear BELOW instead of above-and-centered over the cursor. Routed through 13 FGI sites plus 4 widget tooltip handlers in Libs/GUI.lua, so every AceGUI widget:SetTooltip() (every checkbox, slider, button in Settings) now auto-flips too.

Bug Fixes

  • Blacklist entries collided across connected realms (data corruption) — the previous fn:blackList() called a now-deleted removeSelfRealm() helper that stripped the -MyRealm suffix from any name on the player's own realm, so on a connected-realm cluster bob-RealmA and bob-RealmB collapsed to the same key bob and overwrote each other's reasons. Same problem in fn:unblacklist, fn:isInBlackList, the lookup variants in IsInBlackList, fn:setNote, the whisper bookkeeping in fn:sendWhisper / fn.hideWhisper, and Sync's IsTrustedPlayer. Fix: deleted removeSelfRealm(); added fn:fullPlayerName(name) which always returns canonical Name-Realm regardless of client; rewired every storage and lookup site to use canonical full names. The Retail-only API call boundary (C_GuildInfo.Invite and C_ChatInfo.SendChatMessage both fire ERR_*_PLAYER_NOT_FOUND_S when given Name-MyRealm) now goes through a new fn:formatNameForRetailAPI(name) helper that strips the suffix only at the API call, leaving storage canonical. One-shot DB migration in OnInitialize walks DB.realm.blackList and DB.realm.blackListRemoved, appending the current realm to any bare-name key; gated by DB.global.migratedToFullRealmKeys so it runs only once.
  • Filter race/class exclusions didn't prune the /who queueisQueryFiltered had a structural bug in the sLevel == 2 and sLevel == 3 branches where the race-exclusion check required f.class == nil, so a filter that excluded both Undead AND Druid would still let an Undead-tagged /who through (wasting the scan budget). Rewrote the function so race-exclusion and class-exclusion are independent dimensions: an r-Undead query is filtered if Undead is on the ignore list, regardless of which classes are also restricted. Same fix applied at sLevel 3 and 4 (race+class+zone). Level-only queries unchanged — they're only filtered when the filter has no race/class exclusions, since a level scan still has to run to find the included races/classes.
  • Filter "enabled" status reset to off every time the user edited the filter — the save path at the bottom of addfilterFrame.saveFilter() hardcoded filterOn = false for both new filters and edits. Fix: detect the edit case by checking whether DB.realm.filtersList[filterName] already exists, and preserve the existing filterOn and filteredCount instead of clobbering them.
  • Scan >> button looked clickable during the 5-second cooldown — AceGUI's Button uses UIPanelButtonTemplate; calling :SetDisabled(true) correctly disables clicks but the amber highlight texture still renders on mouseover, which fooled users into thinking they could press the button mid-cooldown. Fix: introduced a setPausePlayCooldown(on) helper that calls :SetDisabled(true) and also strips the highlight texture (SetHighlightTexture(nil)) for the duration; the original highlight is captured at construction time and restored on re-enable. clearSearch() routes through the same helper.
  • Retail CHAT_MSG_SYSTEM secret-string taint spam (FGI_Core copy) — v1.9.0 fixed this in functions.lua but missed the duplicate CHAT_MSG_SYSTEM handler in FGI_Core.lua:157, which still ran strfind/strsub/msg:match on every system message. On Retail 11.x those payloads are tagged "secret strings", and any string operation raises "attempt to perform string conversion on a secret string value (execution tainted by 'FastGuildInvite')" — once per system message, hundreds per session. Added the same not gv.isRetail early-return guard. Leave detection on Retail is already covered by processGuildEventLog via C_GuildInfo.GetGuildEventLog; auto-welcome on Retail would need GUILD_ROSTER_UPDATE diffing (not implemented here, was already non-functional on Retail due to the taint).

Internal

  • fn:fullPlayerName(name) — sibling of the existing fn:normalizePlayerName. Where normalizePlayerName only canonicalizes on Retail (the documented existing behaviour), fullPlayerName always returns Name-Realm regardless of client. Used by the connected-realm fix and the GRM importer; not retroactively applied to alreadySended / leave (still keyed via normalizePlayerName) — those tables are session-ish and a wider migration is out of scope for this patch (noted in docs/DEV_NOTES.md).
  • fn:formatNameForRetailAPI(name) — pure no-op on non-Retail; on Retail strips only the own-realm suffix and leaves cross-realm names alone. Used by fn.invite (FGI_APICompat.lua) and fn:sendWhisper (functions.lua) to work around the Retail API bugs that made the realm-stripping necessary in the first place.
  • fn:queryWorstCaseCost(query) — returns the maximum /who-call cost a query could fan out into. 1 for sLevel ≥ 3; 1 + |classes_for_race| for sLevel 2; 1 + |races| + sum_over_races(|classes|) for sLevel 1. Used by item C to fix the progress denominator at scan start.
  • fn:applyWindowOpacity() — applies DB.global.windowOpacity to all five FGI windows. Called on slider change (Settings) and once on PLAYER_LOGIN. Compact tray's plain texture (cf.bg) is now exposed on the frame so the helper can rescale it.
  • fn:importGRMBlacklist() — new public API; returns imported, skipped so external integrations could call it programmatically. Bails early with a localised "GRM not loaded" print if neither GRM SavedVariable is present.
  • DB schema additions: DB.global.windowOpacity (default 1.0), DB.global.grmAutoSync (default false), DB.global.migratedToFullRealmKeys (one-shot migration flag).
  • PLAYER_LOGIN handler in functions.lua — extended to also call fn:applyWindowOpacity() and (when enabled) schedule the deferred GRM auto-sync via C_Timer.After(2, ...).
  • Locale keys addedscanSubdivide, windowOpacity, windowOpacityTooltip, grmImportButton, grmImportButtonTooltip, grmAutoSync, grmAutoSyncTooltip, grmNotLoaded, grmImportDone — added to both enUS.lua and ruRU.lua.

[v1.9.2] (2026-04-27) - Reset Fixes, Compact Tray, Invite History, Popup Toggles

New Features

  • feat: Compact tray window — a separate slim Track-o-Matic-style overlay (new file compactFrame.lua) replaces the main window when DB.global.compactMode is enabled. 18-px title row with FGI tag, live counters (F:N S:N A:N X:N), a >> Pause/Play button matching the main window, and a + expand-back icon. 5 queue rows beneath the title row each with the same Invite / Skip / Decline / Blacklist icons as the full window. Mouse-wheel scrolls the queue, frame auto-resizes to the visible row count, position persisted in DB.global.compactFrame, registered as FGICompactFrame in UISpecialFrames so ESC closes it. Toggle via the new minus-icon button on the full main window's bottom row, the Settings checkbox, or /fgi compact (also a diagnostic).
  • feat: Invite History page — new History button next to Statistics opens interface.historyFrame, a scrollable per-invite log showing time / name / level / class / outcome (Accepted, Declined, Anti-spam, Blacklisted). Newest entries first. Outcomes are coloured by category. Empty-state and entry-count footer included. Vertical-only resize between 250 and 1500 px tall — width is locked at 660 to keep columns from clipping. Visible row count is computed live from viewport height so growing the window taller actually surfaces more rows. Bottom-right corner grip stays visible as a "this window is resizable" affordance even though horizontal drag is locked.
  • feat: Configurable history retention — new Invite history retention (days) setting (range 0–365, 0 = forever) controls how long invite-history entries are kept. Default 30. fn.history:trim() runs on every logInvite() call and once on PLAYER_LOGIN; entries are appended in time order so trim is a single linear scan that drops the leading prefix. Saved in DB.global.historyRetentionDays; data lives in DB.factionrealm.history.invites to match the existing fn.history scope.
  • feat: Per-row Blacklist button — fourth icon button on each player row (red X texture from Interface\Buttons\UI-GroupLoot-Pass-Up) immediately blacklists the player and removes the row from the queue, replacing the right-click → Black List menu round-trip. Wired through fn:blackList(name) and fn.history:logInvite("blacklisted", ...) so the action shows up on the new History page.
  • feat: Last/next scan parameters display — new mainFrame.scanInfo label below the counters row shows the most-recently issued WHO query (Last scan: 10-19) and the next one queued (Next scan: ...), updating on every list refresh and at each nextSearch(). addon.search.lastQuery is the new field that records the just-issued query.
  • feat: Settings / Statistics / History buttons toggle their popups — clicking each button opens the popup if closed and closes it if open (was open-only). None of the three popups now hide the main window when opening — they're independent overlays so you can keep the main UI visible while you check stats / history / settings.
  • feat: Diagnostic slash command /fgi compact — dumps the compact frame's runtime state (visibility, strata, scale, bounds, anchor, screen size) and force-shows it at screen centre. Useful if the tray ever ends up in an unreachable position.

Bug Fixes

  • Compact tray invisible / silently failing on Retail, Anniversary, TBC, Wrath, Cata — the per-expansion TOC files (FastGuildInvite_Mainline.toc, _BCC.toc, _Cata.toc, _Wrath.toc) never listed compactFrame.lua or inviteHistory.lua; only the base FastGuildInvite.toc (Classic Era) loaded them. So interface.compactFrame was nil on every other client and applyCompactMode had nothing to show. Added both files to all four per-expansion TOCs.
  • Compact tray buried behind world UI on Retail — pure-Lua frames need SetToplevel(true) set explicitly to be brought to the top of their strata when shown; XML-loaded frames (like Track-o-Matic's) get this from the toplevel="true" attribute. Without it, Retail's UI manager left the tray at the back of the HIGH strata. Added SetToplevel(true) and SetFrameLevel(200) so the bar reliably surfaces above other addons sharing the same strata.
  • Compact tray rendering off-screen after a reloadcf had no SetPoint until PLAYER_LOGIN. If Show() ran before the position-restore (or with a stale saved offset from a different UI scale / monitor), the frame either rendered at WoW's unanchored default (bottom-left 0,0) or at an unreachable saved position. Fixed by setting a default SetPoint("CENTER", UIParent) immediately at file load, validating saved xOfs/yOfs in PLAYER_LOGIN (anything beyond ±3000 px is discarded as stale), and adding compactFrame to /fgi resetWindowsPos for emergency recovery.
  • Per-row icon buttons overlapped the player-list scrollbar — Invite/Skip/Decline/Blacklist were anchored from listBG.TOPLEFT with absolute X offsets, so at the default window width the rightmost (Blacklist) icon clipped under the 12-px scrollbar gutter. Re-anchored all four icons from listBG.TOPRIGHT with negative offsets and a 16-px scrollbar-clearance gutter, so they always stay clear regardless of window width.
  • Bottom button row clipped at the default window width — adding the History and Compact-toggle buttons widened the row to ~605 px, which exceeded the previous 580-px window minimum. Bumped the minimum (SetResizeBounds) to 640 px so the row never gets clipped; old saves clamped upward on load.
  • History tray's outcome column clipped under the scrollbar — the column layout (Time/Name/Lvl/Class/Outcome at fixed pixel offsets) needed content.width >= 625 to fit with the 15-px row scrollbar gutter, but the window defaulted to 620, leaving the outcome column overlapping where the scrollbar appears once entries exceed visible rows. Bumped HISTORY_W to 660 with explicit width math documenting the dependency between column widths and minimum frame width.
  • Statistics window resizable but content was fixed-size — could be shrunk past where the LibGraph instance and totals labels fit, leaving the graph clipped or empty space at larger sizes. Made non-resizable at 600×575; sizer grips hidden and mouse scripts cleared so dragging the corner can't trigger anything.
  • Settings window resizable but content used absolute SetPoint — shrinking horizontally clipped widgets to the right edge ("text floats outside"). Made non-resizable at 900×637; sizer grips hidden. The Game Version label inside the Main page also stopped wrapping (SetWordWrap(false) + SetMaxLines(1) plus an explicit 600-px width because SetFullWidth(true) is a no-op when the parent group uses SetLayout("NIL")).
  • History tray class column wrapped to a second line for long class nameslvlClass FontString had width 56 and word-wrap enabled, so "60 Demon Hunter" / "60 DeathKnight" wrapped. Bumped width to 110 and disabled word/non-space wrap with SetMaxLines(1). The name column auto-shrinks to keep the row geometry consistent.
  • Main window title showed the raw build tag (Fast Guild Invite v.FastGuildInvite-v1.9.1) — the BigWigs packager replaces ## Version: FastGuildInvite-v2.3.2 in the TOC with the full git tag (FastGuildInvite-v1.9.1), and mainFrame:SetTitle("Fast Guild Invite v."..addon.version) then concatenated v. in front, producing the doubled prefix; fixed by stripping the FastGuildInvite-v prefix from addon.version once at load time in init.lua and changing the title strings in mainFrame.lua and intro.lua to "Fast Guild Invite v"..addon.version, so the displayed string is now Fast Guild Invite v1.9.2.
  • Reset confirmation dialog never appeared even with confirmSearchClear enabledinterface.confirmClearFrame was created via GUI:Create("ClearFrame") but never had a SetPoint anywhere except in the rarely-used /fgi resetWindowsPos slash command; AceGUI's ClearFrame:OnAcquire calls Show() once, but a frame without an anchor renders with undefined geometry, so subsequent Show() calls had no visible effect; fixed by adding position restoration in the PLAYER_LOGIN handler (mirroring the existing mainFrame pattern) — restores from DB.global.confirmClearFrame if saved, else SetPoint("CENTER", UIParent) — so the dialog always has an anchor and Show()/Hide() now toggles a visible widget.
  • Reset button did not clear the Found / Sent / Accepted / Filtered counters or the row listclearSearch() reset addon.search.{inviteList, state, progress, timeShift, tempSendedInvites, whoQueryList}, the scrollbar offset, and the progress bar, but skipped the addon.searchInfo session counters; per CLAUDE.md these must be set through the metamethod setter so the main-frame display refreshes via its update() callback; fixed by adding addon.searchInfo.{unique,sended,invited,filtered,decline,autodecline,search}(0) calls in clearSearch() (the n==0 branch of the metamethod zeroes the counter via self[1] + (-self[1])).
  • "Accepted" count never incremented on Classic Era — the join handler only bumped the counter when DB.realm.alreadySended[normalizedName] was set, but Classic Era never fires ERR_GUILD_INVITE_S to the inviter so the "invite" handler path that calls rememberPlayer() never runs on accept (only on decline); on a successful accept the anti-spam list stayed empty for that name and the counter lookup missed; fixed by also checking addon.pendingInvites[normalizedName] in the join handler — the entry is set at GuildInvite() time and only cleared on decline / not-found / accept, so its presence at join time is a reliable signal; if found, increment Accepted, log the invite, persist to alreadySended (for non-type-3 invites), and clear the pending slot.
  • Scrollbar in player list was always visible even when the list fit the viewportmainFrame.listScrollBar was created with SetMinMaxValues(0, 0) and never explicitly hidden; fixed in fn.onListUpdate() (where the scrollbar range is already recomputed each refresh) by calling sb:SetShown(maxValue > 0) so the bar disappears whenever #list ≤ rowCount.

Internal

  • New file compactFrame.lua — owns interface.compactFrame. Plain CreateFrame (no AceGUI border), HIGH strata + SetToplevel(true) + SetFrameLevel(200), semi-transparent black background, draggable title row, refresh hooked into both the addon.searchInfo counter metamethod and fn.onListUpdate() so counters and queue rows track scan activity in real time. Loaded after mainFrame.lua in all five TOCs.
  • mainFrame.applyCompactMode() — single mutator that swaps visibility between interface.mainFrame and interface.compactFrame. No-op when neither is shown so PLAYER_LOGIN doesn't pop a window the user wasn't looking at. Replaces the previous in-place "shrink the AceGUI window" approach.
  • fn.showAddon() — new helper used by /fgi show and the Settings close button so they bring up whichever frame the current mode wants instead of hard-coding mainFrame:Show().
  • mainFrameToggle() in FGI_Core.lua — minimap LMB now checks DB.global.compactMode and toggles the compact tray when in that mode.
  • DB.factionrealm.history.invites — new per-invite log array with rich metadata ({time, name, lvl, race, class, outcome}); trimmed to DB.global.historyRetentionDays on every write.
  • addon.invitesInFlight — runtime-only map keyed by normalized player name, populated at send time with {time, name, lvl, race, class}; consumed by fn.history:logInvite(outcome, name) so accept/decline/antispam outcomes inherit the level/race/class captured at send. Cleared on resolution.
  • fn.history.logInvite(outcome, name, fallback) — new sink for per-invite events; outcomes are accepted, declined, antispam, blacklisted. Called from the join handler (functions.lua), the decline / auto-decline handlers (Scan.lua), and the new row-level Blacklist button (mainFrame.lua).
  • fn.history.trim() — single-pass retention enforcement; runs on every logInvite() and at PLAYER_LOGIN. Appends are time-ordered so the implementation linear-scans for the first kept index and slices once, with a fast path when everything is older than the cutoff.
  • New file inviteHistory.lua — owns interface.historyFrame, the row template, and fn.history.refreshHistoryPage(); loaded after statistic.lua in the TOC. FGIHistoryFrame global is registered with UISpecialFrames (via fn.updateEscFrames()) so ESC closes it like the other addon windows.
  • /fgi resetWindowsPos — now also resets interface.compactFrame to CENTER and clears DB.global.compactFrame, alongside the other window resets.
  • New dev-only wow-version-replication.ps1 — file watcher that mirrors the source tree from _classic_era_/Interface/AddOns/fastguildinvite/ into the _classic_, _anniversary_, and _retail_ installs as you edit. Reads .pkgmeta's ignore: list at startup so the synced output looks like the BigWigs-packaged release (no .git, no IDE metadata, no docs). Excluded from the packager via the existing **/*.ps1 .pkgmeta rule.
  • Locale keys addedlastScan, nextScan, scanNone, compactMode, compactModeTooltip, compactSymbol, expandSymbol, compactToggleTooltip, historyRetentionDays, historyRetentionDaysTooltip, historyTitle, historyBtn, historyBtnTooltip, historyCol*, historyOutcome*, historyEmpty, historyFooterCount*, rowBlacklistTooltip — added to both enUS.lua and ruRU.lua.

[v1.9.1] (2026-04-19) - Main Window Size Persistence & Minimap Button Rebinding

Improvements

  • Minimap button rebinding — LMB now opens/closes the main window (previously invited the front-of-queue player), RMB now opens/closes the settings window (previously opened the main window); Shift+LMB pause/continue is removed entirely; the main window already has a visible Pause/Play button and an Invite button, so the minimap-button shortcuts for those actions were redundant and easy to trigger by accident; L["minimap"] tooltip updated in enUS, ruRU, deDE, frFR, zhCN, zhTW, and koKR to match the new two-line binding

Bug Fixes

  • Main window size not persisted across reloads — window position saved correctly because the title bar's OnMouseUp was overridden to write DB.global.mainFrame.{point,xOfs,yOfs,width,height}, but resizing uses three separate grip frames on the ClearFrame widget (sizer_se, sizer_s, sizer_e — see Libs/GUI.lua), each bound to the library's default MoverSizer_OnMouseUp, which only updates the widget's internal status table and never touches DB.global.mainFrame; so releasing a resize drag left the saved width/height at their previous (or default) values and next login restored the old size; fixed by factoring the title-bar persistence logic into a local persistMainFrameGeom helper and binding it to all three sizer grips' OnMouseUp as well, so any resize release now writes the same DB entry the move release already did

[v1.9.0] (2026-04-19) - Main Window Consolidation & Player Queue List

New Features

  • feat: Scrollable player queue list — the main window now shows all scan candidates at once in a scrollable list (Name / Lvl / Class columns) with per-row action icons; any row can be acted on, not just the front of the queue; the list scrolls with the mouse wheel; player names are coloured by class; right-click any name opens the same context menu (Invite / Blacklist / Unblacklist) that was previously on the scan popup; RaiderIO profile tooltip appears on hover when RaiderIO is installed
  • feat: Progress bar in main window — the scan progress bar (queries completed / total) is now embedded in the main window between the filter controls and the player list; it was previously only visible in the separate scan popup
  • feat: Compact stats row — a single-line summary row (Found: N Sent: N Accepted: N Filtered: N) appears below the progress bar and updates in real time as the scan runs
  • feat: Scan controls integrated into main window — the Start/Pause button and countdown timer label are now part of the main window's bottom button row; the scan popup no longer appears
  • feat: Per-row Skip button — a third per-row action (between Invite and Decline) that removes a player from the current queue without blacklisting; Decline still respects the existing Remember skipped/Remember all settings, so Skip is the non-destructive option when a player should just be passed over for this cycle
  • feat: 3D spinner widget for the level filter — the Level Filter min/max numbers are now rendered in an outlined, drop-shadowed font with ▲/▼ spinner buttons above and below each value; mouse wheel still works (Shift = step of 5) and clicking the arrows increments/decrements; makes it obvious the range is interactive instead of looking like a static label

Improvements

  • Flat icon buttons for per-row actions — the per-row Invite / Skip / Decline buttons are now flat 18×18 icon buttons using WoW's built-in ReadyCheck textures (green check / yellow ? / red X), replacing the beveled TButton widgets; they sit vertically centered against row text via a 2-px offset
  • Enriched multi-line tooltips on every main-window button — Close, Invite +(N), Pause/Play, Decline, Settings, Statistics, and all three per-row icons now have descriptive two-line tooltips explaining what the button does and relevant side effects (e.g. that Decline consults the Remember skipped setting, that Skip does not); new locale keys closeBtnTooltip, inviteBtnTooltip, pausePlayBtnTooltip, declineBtnTooltip, settingsBtnTooltip, statisticBtnTooltip, rowInviteTooltip, rowSkipTooltip, rowDeclineTooltip in both enUS and ruRU; original short labels are kept untouched for use as button text elsewhere
  • Top-row labels on the same horizontal line — "Level Filter" and "Invitation Mode" now share the same Y, and the level spinners are vertically centered with the mode dropdown below them; the panel reads as a single top row rather than two staggered mini-panels
  • Renamed "Level Range" → "Level Filter" — more clearly conveys that the range actually filters WHO query results (it has always been wired up, but "Range" read as a static display); also capitalised "Invitation mode" → "Invitation Mode" for consistency
  • Main window resizes with content — a new OnSizeChanged hook on the outer frame restretches the progress bar, stats label, and bottom button group so the UI actually uses the extra horizontal space when the window is dragged wider; SetProgress now remembers the last value so the bar doesn't reset to empty during a resize
  • Symmetric top/bottom padding inside the scroll list — 4-px padding on top/bottom (LIST_PAD_TOP / LIST_PAD_BOTTOM) keeps descenders like g, p, j from clipping the container edge; relayoutList() shrinks the list container after each resize so the bottom buffer always matches the top exactly, rather than growing up to 19 px of dead space from floor() truncation
  • Minimum window dimensions raisedSetResizeBounds(580, 450) so (a) the scrollbar can no longer overlap the row's rightmost decline icon, and (b) saved sizes from before the queue list redesign are clamped upward on load, preventing a collapsed list area on older profiles
  • Dark semi-transparent background on the list container — a 30 % black texture over listBG so the list area is always visually obvious, even while empty

Bug Fixes

  • UIPanelScrollBarTemplate crashed the load (SetVerticalScroll (a nil value) at SecureScrollTemplates.lua:24) — the template's OnValueChanged script calls SetVerticalScroll on an associated ScrollFrame, but our list container is a plain Frame so the call errored on the first SetValue(0) and halted the rest of mainFrame.lua (row loop, button group, PLAYER_LOGIN handler); fixed by replacing the templated slider with a bare CreateFrame("Slider") plus manual thumb and background textures — we don't need scroll-frame integration because the list uses virtual row windowing, not native scroll
  • List rows invisible even though the window loadedlistBG's TOPLEFT anchored to colName.frame.BOTTOMLEFT, but TLabel.UpdateImageAnchor derives the frame height from FontString:GetStringHeight() which can return 0 at load time before font metrics are ready (clamped to 1 px); combined with any anchor chain fragility this collapsed listBG to zero height; fixed by anchoring listBG.TOPLEFT directly to searchInfo.frame, "BOTTOMLEFT", -5, -30 — a fixed offset independent of TLabel font metrics
  • Saved window height below the new minimum produced a clipped/collapsed list — profiles from before the queue-list redesign could store a DB.global.mainFrame.height much smaller than 450; the load-time SetPoints would then place listBG's bottom above its top, giving negative height and hiding every row; fixed by clamping the loaded size with math.max(580, width) / math.max(450, height) in the PLAYER_LOGIN handler so older profiles are migrated silently
  • Retail: error spam attempt to index local 'msg' (a secret string value tainted by 'FastGuildInvite') (100+ times per session) — functions.lua:520 registers a CHAT_MSG_SYSTEM handler that tries to detect guild-join messages via msg:match("^(.+) has joined the guild%.$"); on Retail 11.x, CHAT_MSG_SYSTEM payloads are now flagged as "secret strings" and any :method() call (which indexes the string via its metatable) raises a taint error; the Classic code path using ERR_GUILD_JOIN_S was already guarded with not gv.isRetail, but the Retail fallback was unguarded, so every single system message (loot rolls, zone changes, achievement announcements, etc. — not just guild-joins) hit the unsafe msg:match and errored; fixed by adding the same not gv.isRetail guard to the fallback; guild-join detection on Retail now returns early — the feature was not actually functional there due to the taint error, so no working behaviour is lost, and re-adding it properly would require switching to GUILD_ROSTER_UPDATE diffing

Removals

  • Retired floating scan popup (FGIScanFrame) — the compact always-visible scan overlay is removed; all its functionality (progress bar, player display, invite/decline buttons, pause/start control) has been absorbed into the enriched main window; the "Customize Interface" settings tab that toggled scan-popup sub-elements is also removed

[v1.8.0] (2026-04-19) - Sync Broadcast Safety Overhaul

New Features

  • feat: Whisper welcome to new guild members — added a second welcome control to the Guild settings tab: a toggle ("Send whisper to new guild members") and a text box that fires a private whisper to the new member 2.5 seconds after they join, using the same NAME substitution token as the existing guild chat welcome; entirely independent — either message can be enabled or disabled on its own; uses C_ChatInfo.SendChatMessage on Retail and SendChatMessage on Classic/TBC/Wrath/Cata
  • feat: Session and all-time stats panel in Statistics window — the Statistics graph window now shows two summary rows below the graph: all-time totals (searches, found, sent, accepted, rejected — persisted per faction-realm from the date tracking began) and current-session totals; session tracking is expanded to include declines and auto-declines in addition to the existing sent/accepted counters; DB.factionrealm.totals stores the persistent counters and reuses all existing locale strings
  • feat: ESC keep-open toggle — pressing Esc now closes the main window, scan frame, and settings window; a new "Keep open on Esc" checkbox in Settings > Main restores the original no-op behaviour when checked; implemented via UISpecialFrames — each FGI window is assigned a global name (FGIMainFrame, FGIScanFrame, FGISettingsFrame) and added to or removed from the list by fn.updateEscFrames(); stored in DB.global.keepOpen
  • feat: Invite testing mode — a new "Invite testing mode" checkbox in Settings > Main simulates the full invite flow without sending any guild invite or whisper; when enabled, clicking Invite prints what would have been sent to chat (player name + invite mode), still increments session/all-time counters, and removes the player from the queue; stored in DB.global.testingMode
  • feat: Configurable scan interval — the WHO query interval (previously hardcoded at 5 s via FGI_SCANINTERVALTIME) is now user-adjustable (2–30 s) via a new "Scan interval (sec)" edit box in Settings > Main; the value is applied immediately on change via fn.setScanInterval() and persisted in DB.global.scanInterval; the constant is kept as the load-time fallback until DB is ready at PLAYER_LOGIN
  • feat: Filter overwrite confirmation — saving a filter with a name that already exists now shows a yes/no StaticPopup ("Filter already exists. Overwrite it?") instead of blocking with a "name is busy" error; confirming re-runs the full validation pass and writes the new data over the existing entry

Bug Fixes

  • ESC had no effect on any FGI window — initial implementation used EnableKeyboard(true) + OnKeyDown, which only fires on frames that hold keyboard focus; plain Frame widgets never receive keyboard events this way; fixed by using UISpecialFrames — WoW's own ESC handler iterates this list and hides the topmost visible frame; the settings window was also missing from the original implementation and now closes on ESC as well
  • Direct checkSync calls bypassed all sync safety guards (SYNC-021) — two code paths called checkSync(CHANNEL_MOD) directly instead of going through fn.startSync(): one after every successful sync in CHECK state, and one after any blacklist entry was removed; checkSync only broadcasts the hash — it does not rebuild Sync.tablesForSync, check combat state, or guard against an already-in-progress session; if fn.startSync() was skipped at login (e.g. player was in combat), Sync.tablesForSync would be nil, causing a Lua crash inside getTotalHash() the next time any FGI guild member triggered a broadcast; the post-success direct call also caused cascading guild-wide broadcasts on large guilds: every completed sync immediately re-broadcast to all peers, which each responded if their hash differed, creating a chain of back-to-back sync sessions with no idle window; the blacklist-removal call had no Sync.tablesForSync nil guard at all; fixed by replacing both direct checkSync(CHANNEL_MOD) calls with fn.startSync(true), which rebuilds Sync.tablesForSync from the current DB, respects the Sync.target == '' in-progress guard, and skips the broadcast if the player is in combat

[v1.7.11] (2026-04-18) - Multi-Table Sync Stall & Stale Message Guard

Bug Fixes

  • TBC: Sync failed: disconnected spam when 2+ tables are out of sync (SYNC-019) — after the first mismatched table was transferred and both sides reached CHECK state, the receiver sent {msg=0} with the updated total hash; an early-dispatch block intercepted msg=0 before the state machine and, on mismatch, printed a debug line and returned without doing anything; the CHECK state handler that drives the next sync round was therefore dead code in this path; both sides sat in CHECK until the 10-second timeout fired closeConnect(), printed "Sync failed", and re-queued the session — an infinite loop for any guild with 2+ mismatched tables; fixed by adding explicit continue-sync logic to the msg=0 mismatch branch: when in CHECK state, re-enter LISTEN and send initSync + choose_table to advance to the next table
  • Stale msg=2 in ESTABLISHED or CHECK state could corrupt the transfer (SYNC-020) — the same early-dispatch block processed msg=2 (settings/header) from any state and any sender with no guard; a delayed or replayed settings message arriving while already ESTABLISHED would silently overwrite Sync.type, stop the active timeout, and fire a second approve_settings, causing SendSyncAddonStream() to run twice and corrupt the receiver's chunk reassembly; fixed by adding a LISTEN-only state guard and a sender-identity check to the msg=2 early-dispatch handler

Improvements

  • Settings > Main: added tooltips to all checkboxes and dropdownsminimapButton now describes left-click / shift-left-click / right-click behaviour; createMenuButtons clarifies the chat context menu feature and reload requirement; queueNotify, searchAlertNotify, and confirmSearchClear each received new descriptive tooltips; showUpdateInfo (Updates) explains the changelog popup behaviour and developer data requirement; clearDBtimes (Player memorization time) explains the anti-spam list expiry options via a custom hover hook on the AceGUI Dropdown frame

[v1.7.10] (2026-04-18) - Bidirectional Sync Race Fix

Bug Fixes

  • TBC: Sync failed: disconnected spam — bidirectional sync race condition (SYNC-018) — when two FGI clients both detect a hash mismatch on each other's guild broadcast simultaneously, both run the size comparison at the same time; the side with more data correctly transitions LISTEN→ESTABLISHED and sends msg=2 settings, while the other side — also correctly — sends approve_connect to ask the sender to proceed; these two messages cross in transit; when the sender (already in ESTABLISHED) received the approve_connect, the unguarded handler fired and sent a second msg=2, causing the receiver to emit two approve_settings responses; SendSyncAddonStream() was then called twice, sending duplicate or shifted data chunks; the receiver's reassembly failed to decode, it called closeConnect() and sent CLOSE_CONNECT back, which printed "Sync failed: <player> disconnected" on the sender's side; since closeConnect() re-queues and re-broadcasts 1 second later, the cycle repeated indefinitely; debug mode appearing to "fix" the issue was coincidental — the partner actually disconnected shortly afterwards; fixed by adding LISTEN-only guard to approve_connect handler and ESTABLISHED-only guard to approve_settings handler so crossed-in-transit messages are silently dropped

[v1.7.9] (2026-04-17) - Sync Timeout Leak Fix

Bug Fixes

  • TBC: Sync failed: disconnected false positives during normal sync (SYNC-017) — Sync.timeout.new() created a new C_Timer but never cancelled the previous one; every call in the LISTEN state—one per table hash round-trip—leaked an orphaned timer; with 4 sync tables and matching hashes, up to 4 orphaned timers accumulated per sync session; the oldest orphaned timer fired closeConnect() 10 seconds after it was created, even if the sync had already advanced to ESTABLISHED or CHECK state; closeConnect() saw wasEstablished=true and wasSuccess=nil and printed the false failure message; the symptom was worse on TBC because larger, more active guilds generate more data across all tables, producing more LISTEN-state round trips and more leaked timers; fixed by adding an explicit timer:Cancel() guard at the top of Sync.timeout.new() before allocating a new timer

[v1.7.8] (2026-04-15) - Delayed Guild Welcome Message

New Features

  • Delayed auto-welcome message on guild join — the welcome message sent to guild chat when a new member joins is now deferred by 2.5 seconds via C_Timer.After(); previously the message fired immediately on the CHAT_MSG_SYSTEM event, before the new member's client had fully settled into the guild channel; SendChatMessage is not hardware-event-protected so the timer callback does not raise a Lua protected-call error; the player name token (NAME) is resolved before the timer starts so there is no stale-closure risk

[v1.7.7] (2026-04-14) - Combat Sync Fix, Anti-Spam Accuracy, Scan State Cleanup

Bug Fixes

  • TBC: Sync failed: disconnected spam and screen stutter during combat (SYNC-016) — getTableHash() and getTotalHash() each called IsInCombat(true), which invoked Sync.closeConnect() as a side effect of computing a hash; the resulting CLOSED-state reset left a wandering 10-second timer that re-killed any new sync started 1 second later, looping indefinitely; every closeConnect cascade (ChatThrottleLib flush + timer cancel + chat print + UI refresh) ran synchronously in CHAT_MSG_ADDON OnEvent, dropping a frame on each cycle; removed the IsInCombat local function entirely; combat is now checked only at fn.startSync() entry via UnitAffectingCombat("player"), which prevents initiating a new sync in combat without ever aborting an in-flight one
  • Stale scan results from previous sessions caused invites to offline players (SCAN-006) — saveSearch wrote the entire addon.search table (including inviteList) to SavedVariables after every WHO callback and restored it on login; WHO results are an online-only snapshot with no value after the session ends, and WoW provides no reliable way to distinguish /reload from logout, so players who logged off hours ago could remain in the queue and receive broken invite attempts; removed saveSearch entirely — addon.search is now always freshly initialised on login; the corresponding Settings checkbox, AceDB default, and analytics call are also removed
  • Offline players added to anti-spam list; players never added on Classic Era; not_found cleanup incorrect on cluster realms (SCAN-007) — fn:rememberPlayer() was called before GuildInvite() returned, writing failed invites to the anti-spam list; on Classic Era ERR_GUILD_INVITE_S is never sent to the inviter's client so the original "invite"-only handler never fired and players were never added to the list at all; not_found cleanup used normalizePlayerName on the bare server name which is a no-op on Classic, missing a cluster-realm key like "Name-OtherRealm"; added addon.pendingInvites keyed by normalizePlayerName(playerName); types 1/2/4 defer rememberPlayer to server confirmation; "decline" and "auto_decline" handlers now also flush pendingInvites (covering Classic Era); not_found uses next(pendingInvites) since only one invite can be in-flight at a time

[v1.7.6] (2026-04-13) - Retail Anti-Spam & Invite Fixes

Bug Fixes

  • Retail: anti-spam list silently cleared after every same-realm invite (SCAN-004) — on Retail, C_GuildInfo.Invite and C_ChatInfo.SendChatMessage reject "Name-MyRealm" for same-realm players and fire a not_found error event; scanFrame.pausePlayFilter was handling that event by deleting DB.realm.alreadySended[normalizedName], erasing the entry that rememberPlayer() had just written; only cross-realm players (~1 in 10 WHO results) were stored correctly because their realm suffix was already different; fixed by stripping the self-realm suffix in API.GuildInvite() and fn:sendWhisper() before calling the WoW API on Retail so the API call succeeds and no not_found event is raised
  • Retail: blackListAutoKick crashes on every guild join (SCAN-005) — fn:blackListAutoKick() registered a CHAT_MSG_SYSTEM listener that called strfind(ERR_GUILD_JOIN_S, ...) directly, the same protected C-side string that caused the v1.7.5 crash in the main handler; the auto-kick handler was missed in that fix; now guarded with not gv.isRetail and falls through to the same English text-pattern fallback used by the main handler
  • Anti-Spam List panel not refreshing in real time (UI-003) — fn:rememberPlayer() wrote to DB.realm.alreadySended but never notified the panel; entries were only visible after switching settings tabs; rememberPlayer() now calls antiSpamList:update() immediately when the panel is visible; settings.ShowContent() also calls update() when either the Anti-Spam List or Blacklist tab is opened so bulk sync arrivals are reflected without a second tab switch
  • Anti-spam timestamps always show :00 for minutes and seconds (UI-002) — fn.getTime() called time({year, month, day, hour}) with min and sec omitted; Lua's time() defaults omitted fields to 0, so every stored timestamp was HH:00:00; min and sec are now included

[v1.7.5] (2026-04-13) - Retail Secret String Crash Fix

Bug Fixes

  • Lua crash on guild join in RetailERR_GUILD_JOIN_S is a protected C-side "secret string" in Retail WoW; calling strfind() on it raised attempt to perform string conversion on a secret string value (642 times per session per report); the Classic ERR_GUILD_JOIN_S path is now skipped on Retail, which already has a working fallback pattern match for the "has joined the guild" message

[v1.7.4] (2026-04-13) - Add to Blacklist from Anti-Spam List

New Features

  • Add to Blacklist from Anti-Spam List — right-clicking a player name in the Anti-Spam List window now shows an "Add to Blacklist" option alongside the existing Delete option; adds the player to the blacklist (with the configured default reason, chat confirmation, and sync propagation) while leaving them in the anti-spam list unchanged

Bug Fixes

  • Blank "Add to Blacklist" button text — the locale key "Добавить в черный список" was missing from ruRU.lua, which is the master key source for the locale merge in summary.lua; all keys absent from ruRU are silently omitted from the final L table, causing the button to display no text

[v1.7.3] (2026-04-13) - Scan Window Resize Fix

Bug Fixes

  • Scan window resize clears player display (SCAN-003) — resizing the scan frame while a scan was running caused the player name label to go blank and the invite count to disappear, causing missed invitations; the OnSizeChanged hook now calls onListUpdate() after each size event to re-sync the display with inviteList

[v1.7.2] (2026-04-11) - Sync Stability & Multi-Peer Queue

Bug Fixes

  • Sync sender false failure message (SYNC-014) — the peer who sent data during a sync always received a red failure message in chat even when the sync completed successfully; CLOSE_CONNECT received while in CHECK state is now correctly treated as a clean success
  • Second peer disrupts in-progress sync (SYNC-015) — a competing initSync from a second peer while already syncing caused the first sync to be torn down mid-handshake; the second peer is now refused gracefully and queued
  • Sync queue implementation — refused peers are added to Sync.queue; when the current session ends, a re-broadcast is triggered automatically so every online peer eventually gets synced without manual intervention
  • Nil crash in onSyncSuccess (SYNC-014 follow-up) — automatic login sync triggered onSyncSuccess without onSyncStarted ever being called, leaving syncPreCounts.blackList nil and causing a Lua arithmetic error; count diff is now skipped when syncPreCounts was not initialised

[v1.7.1] (2026-04-11) - Settings Tab Highlight & Sync Feedback

New Features

  • Active settings tab highlight — the left-hand navigation buttons in the Settings window now visually indicate which panel is currently open; the active button stays highlighted using WoW's native LockHighlight / UnlockHighlight button methods
  • Sync status messages — pressing the Sync button now prints status messages to the default chat frame with the <FGI> prefix; messages are suppressed when "Disable addon messages" is enabled
    • Yellow Syncing... on broadcast
    • Green Synced with <player> (+N entries) or (already up to date) on success
    • Red Sync failed: <player> disconnected on timeout or mid-sync disconnect
    • Grey Already up to date. when no guild peer responded to the broadcast

Bug Fixes

  • False sync failure message on sender sideCLOSE_CONNECT received while in CHECK state is now treated as a successful close; previously the sending peer always printed a failure message even when the sync completed normally

[v1.7.0] (2026-04-10) - Blacklist Sync Overhaul & Class Filter Fix

New Features

  • Blacklist removal propagation — removing a player from the blacklist now syncs the removal to all online guild peers; a blackListRemoved tombstone table tracks deletions so removed entries are never re-synced back by peers
  • Auto-broadcast on blacklist removal — triggers an immediate guild hash broadcast when a player is removed so online peers sync without requiring relog or manual sync

Bug Fixes

  • Bidirectional cursor desync (SYNC-008) — the choose_table responder was incorrectly sending choose_table back, causing both peers to advance their cursors independently and compare the wrong tables against each other
  • Re-add clears tombstone — re-adding a previously removed player now correctly clears their tombstone so the re-addition propagates to peers
  • Double-delete required to remove (SYNC-010) — fn:blacklistRemove now deletes the exact stored key before case variants, so a single remove always works
  • Lua nil errors on blacklist operations (SYNC-011) — Sync, checkSync, and CHANNEL_MOD are now forward-declared at the top of functions.lua; they were previously nil when referenced by functions defined before the sync section
  • Blacklist UI not refreshing after sync (SYNC-012) — the blacklist panel now updates immediately when entries are added or removed via sync
  • Class filter checkboxes version-gated — Death Knight filter shown for Wrath/Cata/Retail; Monk, Demon Hunter, and Evoker filters shown for Retail only; previously these checkboxes were absent on non-Classic-Era clients causing incorrect filter results
  • Window resize minimum bounds (UI-001) — scan frame minimum resize now tracks actual visible content so it cannot be squished below its contents; main frame minimum width set to prevent the level range controls from overflowing the frame border; scan frame children (player label, progress bar) now resize with the frame

[v1.6.3] (2026-04-06) - Blacklist Sync & Manual Sync Button

New Features

  • Blacklist Sync Between FGI UsersDB.realm.blackList is now included in the peer-to-peer sync system alongside the leave and invited lists; hashes automatically on login and syncs when a mismatch is detected
  • Manual Sync Button — "Start Sync" button added to FGI Settings → Main tab; triggers a guild-wide hash broadcast and full sync handshake on demand without relogging

Changes

  • Refactored login sync init into fn.startSync() so it can be called externally (login and button share the same code path)

[v1.6.2] (2026-03-29) - Performance Fixes & Blacklist Auto-Advance

New Features

  • Auto-advance invite queue after blacklisting — right-clicking to blacklist a player from the scan frame now automatically advances to the next candidate (both fast-blacklist and popup-confirmation paths)

Bug Fixes

  • GuildRoster stormGuildRoster() is now guarded behind the note-setting check; was being called on every guild join even when note-setting was disabled
  • Ticker accumulationC_Timer.NewTicker in timeCallbackStart now cancels the prior ticker before creating a new one, preventing N simultaneous tickers after N scan cycles
  • removeMsgList memory leak — matched entries are now removed and empty per-player slots are nilled instead of accumulating indefinitely

[v1.6.1] (2026-03-29) - Settings & Auto-Blacklist Fixes

Bug Fixes

  • Duplicate blacklistOfficer checkbox — removed orphaned copy from the Main settings tab; canonical copy remains in the Guild tab
  • False "player is in guild" popup — when auto-blacklisting a leaver, the roster scan (blacklistKick) is now skipped via skipKick=true, preventing a false positive popup for a player who just left

[v1.6.0] (2026-03-29) - Anti-Spam List UI & Auto-Blacklist Leavers

New Features

  • Anti-Spam List Management UI — new settings tab to view and manage the invited-players list (DB.realm.alreadySended)
    • Paginated display (13 entries per page) with player names and invitation timestamps
    • Right-click context menu to remove individual players
    • "Clear All" button with confirmation dialog
    • Integrated with "Player memorization time" auto-cleanup setting
    • Fully localized (English & Russian)
    • Access via FGI Settings → "Anti-Spam List"
  • Auto-blacklist leavers (all versions)CHAT_MSG_SYSTEM handler now triggers auto-blacklist for all game versions; GUILD_EVENT_LOG_UPDATE registered for all versions

Bug Fixes

  • blacklistOfficer not persisting — Guild tab setting now correctly saves and restores across sessions
  • Statistics period dropdown not saving — period selection now persists across reloads
  • processGuildEventLog crash on Retail — now uses C_GuildInfo.GetGuildEventLog() on Retail instead of the missing global

[v1.5.19-beta] (2026-03-28) - Blacklist Auto-Kick Fixes

Bug Fixes

  • Hook stacking on right-click (BL-001) — AddHookClick() was called on every page turn in blackList:update(), stacking OnMouseDown handlers and causing multiple context menus per right-click
  • Auto-kick never calling GuildUninvite (BL-002) — FGI_BLACKLIST popup OnAccept now correctly calls GuildUninvite(name); button labels updated to Kick/Skip; OnCancel added to dequeue and advance

[v1.5.18-beta] (2026-03-27) - Remember Skipped Players

New Features

  • "Remember skipped players" setting — when enabled, clicking Skip/Decline adds the player to the anti-spam list so they won't appear in future scans; defaults to off
  • Right-click player name in scan frame — opens the FGI context menu (Guild Invite / Blacklist / Unblacklist), same as chat name right-click

[v1.5.17] (2026-03-27) - TBC/Wrath/Cata GuildRoster Fix

Bug Fixes

  • Crash on guild join in TBC/Wrath/Cata (KEY-017) — GuildRoster() global only exists in Classic Era; all other versions now correctly use C_GuildInfo.GuildRoster()

[v1.5.16] (2026-03-21) - Retail Guild Note Support

New Features

  • Retail note-settingGuildRosterSetPublicNote / GuildRosterSetOfficerNote confirmed working in Retail; guild note controls fully functional on all versions

Bug Fixes

  • message() global missing in Retail (KEY-010/011) — replaced with addon.API.ShowMessage() which falls back to UIErrorsFrame:AddMessage() on Retail
  • fn.debug format string crash — format string had 3 %s placeholders but only 2 arguments; fixed missing |r reset arg
  • FGI_APICompat debug calls using colon syntax — caused self (the functions table) to be passed as the message argument, triggering the error path every time
  • Guild join note: stale roster dataGuildRoster() is now requested immediately on join so the member appears in cache before setNote runs 5 seconds later
  • Note condition always true — was checking DB.global.setNote ~= "" (boolean vs string); now correctly checks DB.global.noteText ~= ""
  • GetNumGuildMembers() multi-return (KEY-016) — wrapped in parentheses to prevent the online-count being used as a loop step

[v1.5.13] (2026-03-20) - Retail Note API & Sync Fix

Bug Fixes

  • Guild notes crash on Retail (KEY-014) — SetPublicNote/SetOfficerNote now use C_GuildInfo.SetNote with correct isPublicNote boolean on Retail
  • C_GuildInfo.CanEditPublicNote missing in Retail (KEY-012) — nil-guarded; returns true when absent, deferring permission check to the server

[v1.5.12] (2026-03-19) - Sync De-taint & Stability

Bug Fixes

  • Tainted sender string crash in Retail (KEY-013) — sender in CHAT_MSG_ADDON is a Blizzard-protected secret string in Retail; replaced :match() with Ambiguate(sender, "none") to safely de-taint it
  • Sync.timeout.timer nil crash — added nil guards in restoreSyncDefaultValues() and Sync.timeout.stop() before calling :Cancel()
  • BCC interface line added to base TOC; CurseForge TOC creation re-enabled for multi-version packaging

[v1.5.8] (2026-03-13) - Multi-Version TOC

New Features

  • TBC, Wrath, Cata Classic TOC files — created FastGuildInvite_BCC.toc, FastGuildInvite_Wrath.toc, FastGuildInvite_Cata.toc for proper CurseForge multi-version packaging

[v1.5.7] (2026-03-11) - Filter UI & Tooltip Fixes

Bug Fixes

  • Fixed multi-line tooltip rendering in GUI widgets (FilterButton, TCheckBox, TLabel, TKeybinding)
  • Fixed FilterButton tooltip not displaying due to missing widget property
  • Fixed nil letterFilter causing tooltip errors in FiltersUpdate
  • Fixed fn:split() nil crash
  • Improved spacing in filter creation UI (RaidProgress labels, bottom hint text)
  • Localized new tooltips in English and Russian

[129] (2024-09-22)

  • chat menu fix