Fast Guild Invite - Revived

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

File Details

FastGuildInvite-v2.0.10

  • R
  • May 21, 2026
  • 5.62 MB
  • 105
  • 12.0.1+6
  • Retail + 2

File Name

FastGuildInvite-FastGuildInvite-v2.0.10.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.1.9] (2026-05-20) — Retail Scan.lua secret-string crash fix, announce keybind, icon hitbox parity

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

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.0.10 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.0.10" 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.0.10 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