diff --git a/Core/Channels.lua b/Core/Channels.lua new file mode 100644 index 0000000..524561b --- /dev/null +++ b/Core/Channels.lua @@ -0,0 +1,338 @@ +-- Freewood-OctoWoW-Core: Kanaele und ihre Mitglieder (nur lesen, alles UNGEPRUEFT im Spiel) +-- Datenquelle: GetChannelList() + ListChannelByName(n) -> Ereignis CHAT_MSG_CHANNEL_LIST (arg1 = Namensliste). +-- Alle Aufrufe laufen geschuetzt (pcall). /fwchans zeigt Rohdaten zur Pruefung. + +local ADDON, FW = ... + +local G = _G +local Ch = {} +FW.Channels = Ch + +Ch.list = {} -- { {num=, name=, members={...}, count=, stamp=} } +Ch.byNum = {} +Ch.queue = {} +Ch.current = nil +Ch.lastSend = 0 +Ch.probe = false +Ch.version = 0 -- steigt bei jeder Aenderung, die UI prueft das + +local SPACING = 0.8 -- Sekunden zwischen zwei Abfragen +local TIMEOUT = 4 -- Sekunden bis eine Abfrage als verloren gilt +local SUPPRESS = 5 -- so lange nach einer Abfrage wird die Chatausgabe der Liste unterdrueckt + +local function P(t) + if G.DEFAULT_CHAT_FRAME then + G.DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffFWChans|r " .. tostring(t)) + end +end + +local function Short(s, n) + s = tostring(s) + n = n or 160 + if string.len(s) > n then return string.sub(s, 1, n) .. "..." end + return s +end + +-- Kanalliste lesen: 1.12 liefert num, name, num, name ... (spaeter evtl. mit Zusatzwert) +function Ch.ReadList() + local out = {} + if type(G.GetChannelList) ~= "function" then return out end + local res = { pcall(G.GetChannelList) } + if not res[1] then return out end + local i = 2 + local n = table.getn(res) + local lastNum + while i <= n do + local v = res[i] + if type(v) == "number" then + lastNum = v + elseif type(v) == "string" and lastNum then + out[table.getn(out) + 1] = { num = lastNum, name = v } + lastNum = nil + end + i = i + 1 + end + return out +end + +-- eigenes Entfernen (table.remove zusammen mit table.getn verhaelt sich auf dem Client eigenartig) +local function RemoveAt(t, idx) + local n = table.getn(t) + local k = idx + while k < n do t[k] = t[k + 1]; k = k + 1 end + t[n] = nil +end + +local function Bump() Ch.version = Ch.version + 1 end + +-- Liste neu einlesen, bekannte Mitglieder behalten +function Ch.Rescan() + local fresh = Ch.ReadList() + local old = Ch.byNum + Ch.byNum = {} + Ch.list = {} + local k = 1 + while k <= table.getn(fresh) do + local e = fresh[k] + local prev = old[e.num] + if prev and prev.name == e.name then + e.members = prev.members + e.count = prev.count + e.stamp = prev.stamp + else + e.members = {} + e.count = 0 + end + Ch.byNum[e.num] = e + Ch.list[table.getn(Ch.list) + 1] = e + k = k + 1 + end + Bump() +end + +-- Mitglieder aller Kanaele abfragen (nacheinander) +function Ch.RefreshAll() + Ch.Rescan() + Ch.queue = {} + local k = 1 + while k <= table.getn(Ch.list) do + Ch.queue[table.getn(Ch.queue) + 1] = Ch.list[k].num + k = k + 1 + end +end + +function Ch.RefreshOne(num) + Ch.queue[table.getn(Ch.queue) + 1] = num +end + +function Ch.Pending() + return table.getn(Ch.queue) + (Ch.current and 1 or 0) +end + +local function Send(num) + Ch.current = num + Ch.sentAt = (G.GetTime and G.GetTime()) or 0 + Ch.lastSend = Ch.sentAt + local ok, err = pcall(G.ListChannelByName, num) + if not ok and Ch.probe then P("ListChannelByName(" .. tostring(num) .. ") Fehler: " .. Short(err)) end +end + +local pump = CreateFrame("Frame") +pump:SetScript("OnUpdate", function() + local now = (G.GetTime and G.GetTime()) or 0 + if Ch.current and now - Ch.sentAt > TIMEOUT then + Ch.current = nil + Ch.timeouts = (Ch.timeouts or 0) + 1 + end + if not Ch.current and table.getn(Ch.queue) > 0 and now - Ch.lastSend >= SPACING then + local num = Ch.queue[1] + RemoveAt(Ch.queue, 1) + if Ch.byNum[num] then Send(num) end + Bump() + end +end) + +-- Namensliste aus arg1 zerlegen: "Name1, Name2*, Name3" -> Liste (Markierungen entfernt) +function Ch.ParseNames(text) + local out = {} + if type(text) ~= "string" then return out end + local rest = text + -- evtl. Kopf "Players in channel ...:" abtrennen + local pos = string.find(rest, ":", 1, true) + if pos and pos < 60 and string.find(string.sub(rest, 1, pos), " ", 1, true) then + rest = string.sub(rest, pos + 1) + end + local iter = string.gmatch or string.gfind + for part in iter(rest, "[^,]+") do + local name = string.gsub(part, "^%s+", "") + name = string.gsub(name, "%s+$", "") + name = string.gsub(name, "%*", "") + name = string.gsub(name, "|c%x%x%x%x%x%x%x%x", "") + name = string.gsub(name, "|r", "") + if name ~= "" then out[table.getn(out) + 1] = name end + end + return out +end + +local function OnList(a1, a2, a3, a4, a5, a6, a7, a8, a9) + if Ch.probe then + P("CHAT_MSG_CHANNEL_LIST arg1=" .. Short(a1) .. " | arg2=" .. tostring(a2) .. " arg4=" .. tostring(a4) .. + " arg7=" .. tostring(a7) .. " arg8=" .. tostring(a8) .. " arg9=" .. tostring(a9)) + end + local num = tonumber(a8) + local e = num and Ch.byNum[num] + if not e and Ch.current then e = Ch.byNum[Ch.current] end + if not e and a9 then + local k = 1 + while k <= table.getn(Ch.list) do + if Ch.list[k].name == a9 then e = Ch.list[k] end + k = k + 1 + end + end + if not e then return end + e.members = Ch.ParseNames(a1) + e.count = table.getn(e.members) + e.stamp = (G.GetTime and G.GetTime()) or 0 + if Ch.current == e.num then Ch.current = nil end + Bump() +end + +local function OnJoin(a1, a2, a3, a4, a5, a6, a7, a8, a9) + if Ch.probe then P("CHANNEL_JOIN arg2=" .. tostring(a2) .. " arg8=" .. tostring(a8) .. " arg9=" .. tostring(a9)) end + local e = tonumber(a8) and Ch.byNum[tonumber(a8)] + if e and type(a2) == "string" and a2 ~= "" then + local k = 1 + while k <= table.getn(e.members) do + if e.members[k] == a2 then return end + k = k + 1 + end + e.members[table.getn(e.members) + 1] = a2 + e.count = table.getn(e.members) + Bump() + end +end + +local function OnLeave(a1, a2, a3, a4, a5, a6, a7, a8, a9) + if Ch.probe then P("CHANNEL_LEAVE arg2=" .. tostring(a2) .. " arg8=" .. tostring(a8) .. " arg9=" .. tostring(a9)) end + local e = tonumber(a8) and Ch.byNum[tonumber(a8)] + if e and type(a2) == "string" then + local k = 1 + while k <= table.getn(e.members) do + if e.members[k] == a2 then + RemoveAt(e.members, k) + e.count = table.getn(e.members) + Bump() + return + end + k = k + 1 + end + end +end + +local function OnNotice(a1, a2, a3, a4, a5, a6, a7, a8, a9) + if Ch.probe then P("CHANNEL_NOTICE arg1=" .. tostring(a1) .. " arg4=" .. tostring(a4) .. " arg8=" .. tostring(a8) .. " arg9=" .. tostring(a9)) end + if a1 == "YOU_JOINED" or a1 == "YOU_LEFT" or a1 == "YOU_CHANGED" then + Ch.dirty = true + end +end + +FW:RegisterEvent("CHAT_MSG_CHANNEL_LIST", OnList, Ch) +FW:RegisterEvent("CHAT_MSG_CHANNEL_JOIN", OnJoin, Ch) +FW:RegisterEvent("CHAT_MSG_CHANNEL_LEAVE", OnLeave, Ch) +FW:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE", OnNotice, Ch) + +-- Die Listenausgabe des Spiels soll nicht im Chat landen, solange wir selbst abfragen +if type(G.ChatFrame_AddMessageEventFilter) == "function" then + local function Hide() + if Ch.probe then return false end + local now = (G.GetTime and G.GetTime()) or 0 + if Ch.current or (now - Ch.lastSend) < SUPPRESS then return true end + return false + end + pcall(G.ChatFrame_AddMessageEventFilter, "CHAT_MSG_CHANNEL_LIST", Hide) + Ch.filter = true +end + +-- Aktionen (geschuetzt) +function Ch.Whisper(name) + if type(G.ChatFrame_SendTell) == "function" then pcall(G.ChatFrame_SendTell, name) end +end +function Ch.Invite(name) + if type(G.InviteByName) == "function" then pcall(G.InviteByName, name) + elseif type(G.InviteUnit) == "function" then pcall(G.InviteUnit, name) end +end +function Ch.Ignore(name) + if type(G.AddIgnore) == "function" then pcall(G.AddIgnore, name) end +end + +-- Suche ueber alle Kanaele: Liste { {name=, chans={Kanalnamen}} } (Teiltreffer, ohne Gross/Klein) +function Ch.Find(text, limit) + local out = {} + text = string.lower(text or "") + if text == "" then return out end + local byName = {} + local k = 1 + while k <= table.getn(Ch.list) do + local e = Ch.list[k] + local m = 1 + while m <= table.getn(e.members) do + local nm = e.members[m] + if string.find(string.lower(nm), text, 1, true) then + local r = byName[nm] + if not r then + if limit and table.getn(out) >= limit then + Ch.moreFound = true + else + r = { name = nm, chans = {} } + byName[nm] = r + out[table.getn(out) + 1] = r + end + end + if r then r.chans[table.getn(r.chans) + 1] = e.num .. ". " .. e.name end + end + m = m + 1 + end + k = k + 1 + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + +-- /fwchans [find |probe|list] +G.SLASH_FWCHANS1 = "/fwchans" +G.SlashCmdList = G.SlashCmdList or {} +G.SlashCmdList["FWCHANS"] = function(msg) + msg = msg or "" + local cmd, rest = string.match and string.match(msg, "^(%S*)%s*(.*)$") + if not cmd then + local _, _, c, r = string.find(msg, "^(%S*)%s*(.*)$") + cmd, rest = c or "", r or "" + end + cmd = string.lower(cmd or "") + if cmd == "find" then + local res = Ch.Find(rest, 20) + if table.getn(res) == 0 then P("keine Treffer (Mitglieder bekannt: " .. Ch.TotalMembers() .. ")") end + local k = 1 + while k <= table.getn(res) do + P(res[k].name .. ": " .. table.concat(res[k].chans, ", ")) + k = k + 1 + end + return + end + if cmd == "list" then + Ch.Rescan() + local k = 1 + while k <= table.getn(Ch.list) do + local e = Ch.list[k] + P(e.num .. ". " .. e.name .. " - " .. e.count .. " Mitglieder" .. (e.stamp and "" or " (noch nicht abgefragt)")) + k = k + 1 + end + return + end + -- Standard: Rohdaten zeigen und Abfrage mit Rohausgabe starten + Ch.probe = true + local raw = { pcall(G.GetChannelList) } + local parts = {} + local i = 2 + while i <= table.getn(raw) do parts[table.getn(parts) + 1] = type(raw[i]) .. ":" .. tostring(raw[i]); i = i + 1 end + P("GetChannelList: " .. table.concat(parts, " | ")) + P("Filter=" .. tostring(Ch.filter) .. " ListChannelByName=" .. type(G.ListChannelByName)) + Ch.RefreshAll() + P("Abfrage laeuft (" .. Ch.Pending() .. " Kanaele). Rohdaten erscheinen unten. '/fwchans list' zeigt danach die Zahlen, '/fwchans find ' sucht.") + Ch.probeUntil = (G.GetTime and G.GetTime() or 0) + 60 +end + +function Ch.TotalMembers() + local n, k = 0, 1 + while k <= table.getn(Ch.list) do n = n + Ch.list[k].count; k = k + 1 end + return n +end + +-- Rohausgabe nach 60 s wieder abschalten +local off = CreateFrame("Frame") +off:SetScript("OnUpdate", function() + if Ch.probe and Ch.probeUntil and G.GetTime and G.GetTime() > Ch.probeUntil then + Ch.probe = false + P("Rohausgabe aus.") + end +end) diff --git a/Freewood-OctoWoW-Core.toc b/Freewood-OctoWoW-Core.toc index beebfb5..aadaccd 100644 --- a/Freewood-OctoWoW-Core.toc +++ b/Freewood-OctoWoW-Core.toc @@ -3,7 +3,7 @@ ## Notes: Base addon of the Freewood family: data storage, UI kit, export/import and the main window. ## Notes-deDE: Basis-Addon der Freewood-Familie: Datenspeicher, UI-Bausteine, Export/Import und Hauptfenster. ## Author: Lacustat -## Version: 0.1.226 +## Version: 0.1.227 ## SavedVariables: FreewoodDB ## SavedVariablesPerCharacter: FreewoodCharDB @@ -16,6 +16,8 @@ UI\Kit.lua UI\MainFrame.lua UI\MainWindow.lua UI\Tabs.lua +Core\Channels.lua +UI\ChannelsTab.lua Core\Tooltips.lua Core\TooltipInfo.lua Core\TooltipCompare.lua diff --git a/Locales.lua b/Locales.lua index 75830c9..36c9bc0 100644 --- a/Locales.lua +++ b/Locales.lua @@ -55,6 +55,21 @@ FW:AddLocale("enUS", { TAB_OVERVIEW = "Overview", TAB_EXCHANGE = "Exchange", TAB_SETTINGS = "Settings", + TAB_CHANNELS = "Channels", + CHAN_REFRESH = "Refresh", + CHAN_SEARCH = "Find player in all channels...", + CHAN_NONE = "You are not in any channel.", + CHAN_MEMBERS = "%s members", + CHAN_NOT_QUERIED = "not queried yet", + CHAN_PENDING = "Querying %s channels...", + CHAN_HIDDEN = "Freewood channel", + CHAN_WHISPER = "Whisper", + CHAN_INVITE = "Invite", + CHAN_IGNORE = "Ignore", + CHAN_SELECT = "Click a player for actions.", + CHAN_NO_HITS = "No player found.", + CHAN_MORE = "More hits than shown. Narrow the search.", + CHAN_EMPTY_LIST = "No members received yet. Press Refresh.", MONEY_G = "g", MONEY_S = "s", @@ -156,6 +171,21 @@ FW:AddLocale("deDE", { TAB_OVERVIEW = "Übersicht", TAB_EXCHANGE = "Austausch", TAB_SETTINGS = "Einstellungen", + TAB_CHANNELS = "Kanäle", + CHAN_REFRESH = "Aktualisieren", + CHAN_SEARCH = "Spieler in allen Kanälen suchen...", + CHAN_NONE = "Du bist in keinem Kanal.", + CHAN_MEMBERS = "%s Mitglieder", + CHAN_NOT_QUERIED = "noch nicht abgefragt", + CHAN_PENDING = "Frage %s Kanäle ab...", + CHAN_HIDDEN = "Freewood-Kanal", + CHAN_WHISPER = "Flüstern", + CHAN_INVITE = "Einladen", + CHAN_IGNORE = "Ignorieren", + CHAN_SELECT = "Spieler anklicken für Aktionen.", + CHAN_NO_HITS = "Kein Spieler gefunden.", + CHAN_MORE = "Mehr Treffer als angezeigt. Suche eingrenzen.", + CHAN_EMPTY_LIST = "Noch keine Mitglieder empfangen. Aktualisieren drücken.", MONEY_G = "G", MONEY_S = "S", diff --git a/UI/ChannelsTab.lua b/UI/ChannelsTab.lua new file mode 100644 index 0000000..05a56ac --- /dev/null +++ b/UI/ChannelsTab.lua @@ -0,0 +1,226 @@ +-- Freewood-OctoWoW-Core: Reiter "Kanaele" (Chat-Kanaele und ihre Mitglieder). UNGEPRUEFT im Spiel. +-- Links die Kanaele, rechts die Mitglieder des gewaehlten Kanals oder die Suchtreffer ueber alle Kanaele. + +local ADDON, FW = ... + +local UI = FW.UI +local Ch = FW.Channels + +local ROW = 20 +local MAX_ROWS = 300 + +local function MakeRows(holder) + local rows = {} + function rows.Get(i) + local r = rows[i] + if not r then + r = CreateFrame("Button", nil, holder.child) + r:SetHeight(ROW) + r:SetPoint("TOPLEFT", holder.child, "TOPLEFT", 0, -(i - 1) * ROW) + r:SetPoint("TOPRIGHT", holder.child, "TOPRIGHT", 0, -(i - 1) * ROW) + r.sel = r:CreateTexture(nil, "BACKGROUND") + r.sel:SetAllPoints() + r.sel:SetColorTexture(UI.Color("bronze", 0.35)) + r.sel:Hide() + r.hl = r:CreateTexture(nil, "HIGHLIGHT") + r.hl:SetAllPoints() + r.hl:SetColorTexture(UI.Color("gold", 0.15)) + r.text = UI.CreateLabel(r, "", { size = 12, justify = "LEFT" }) + r.text:SetPoint("LEFT", r, "LEFT", 6, 0) + r.text:SetPoint("RIGHT", r, "RIGHT", -4, 0) + rows[i] = r + end + return r + end + function rows.HideFrom(i) + local k = i + while rows[k] do rows[k]:Hide(); k = k + 1 end + end + return rows +end + +local function BuildChannels(content) + local L = FW.L + local f = CreateFrame("Frame", nil, content) + f:SetAllPoints(content) + + local selNum, selPlayer, seenVersion = nil, nil, -1 + local search = "" + local lastAuto = 0 + + -- links: Kanalliste + local title = UI.CreateLabel(f, L.TAB_CHANNELS, { font = "heading", size = 15, color = "gold" }) + title:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + local refresh = UI.CreateButton(f, L.CHAN_REFRESH, 110, 24, function() + Ch.RefreshAll() + end) + refresh:SetPoint("TOPLEFT", f, "TOPLEFT", 100, 2) + local left = UI.CreateScrollFrame(f) + left:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -34) + left:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0) + left:SetWidth(220) + local lrows = MakeRows(left) + + -- rechts: Suche, Status, Mitglieder, Aktionsleiste + local box = UI.CreatePanel(f, "bgDeep", "bronze") + box:SetHeight(26) + box:SetPoint("TOPLEFT", f, "TOPLEFT", 236, -2) + box:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -2) + local edit = CreateFrame("EditBox", nil, box) + edit:SetAutoFocus(false) + edit:SetPoint("TOPLEFT", box, "TOPLEFT", 8, -2) + edit:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -8, 2) + edit:SetMaxLetters(40) + if not edit:SetFont(UI.GetFontPath("body"), 12, "") then + edit:SetFont("Fonts\\FRIZQT__.TTF", 12, "") + end + edit:SetTextColor(UI.Color("text")) + edit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + local hint = UI.CreateLabel(box, L.CHAN_SEARCH, { size = 12, color = "textDim" }) + hint:SetPoint("LEFT", box, "LEFT", 10, 0) + + local status = UI.CreateLabel(f, "", { size = 11, color = "textDim", justify = "LEFT" }) + status:SetPoint("TOPLEFT", f, "TOPLEFT", 236, -34) + status:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -34) + + local right = UI.CreateScrollFrame(f) + right:SetPoint("TOPLEFT", f, "TOPLEFT", 236, -54) + right:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 36) + local rrows = MakeRows(right) + + local selLabel = UI.CreateLabel(f, L.CHAN_SELECT, { size = 12, color = "textDim", justify = "LEFT" }) + selLabel:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 236, 10) + selLabel:SetWidth(200) + local function Act(fn) + return function() if selPlayer then fn(selPlayer) end end + end + local bW = UI.CreateButton(f, L.CHAN_WHISPER, 90, 24, Act(Ch.Whisper)) + local bI = UI.CreateButton(f, L.CHAN_INVITE, 90, 24, Act(Ch.Invite)) + local bG = UI.CreateButton(f, L.CHAN_IGNORE, 90, 24, Act(Ch.Ignore)) + bG:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 4) + bI:SetPoint("RIGHT", bG, "LEFT", -6, 0) + bW:SetPoint("RIGHT", bI, "LEFT", -6, 0) + + local function Redraw() + seenVersion = Ch.version + -- Kanalliste + local n = table.getn(Ch.list) + local k = 1 + while k <= n do + local e = Ch.list[k] + local r = lrows.Get(k) + local num = e.num + r.text:SetText(e.num .. ". " .. e.name .. " |cff8c8a84(" .. (e.stamp and e.count or "?") .. ")|r") + if num == selNum then r.sel:Show() else r.sel:Hide() end + r:SetScript("OnClick", function() + selNum = num + selPlayer = nil + Redraw() + end) + r:Show() + k = k + 1 + end + lrows.HideFrom(n + 1) + left:SetContentHeight(math.max(1, n * ROW)) + + -- rechts + local items = {} -- { name=, extra= } + if search ~= "" then + local hits = Ch.Find(search, MAX_ROWS) + local h = 1 + while h <= table.getn(hits) do + items[h] = { name = hits[h].name, extra = table.concat(hits[h].chans, ", ") } + h = h + 1 + end + if table.getn(items) == 0 then + status:SetText(L.CHAN_NO_HITS) + elseif Ch.moreFound then + status:SetText(L.CHAN_MORE) + else + status:SetText("") + end + Ch.moreFound = nil + else + local e = selNum and Ch.byNum[selNum] + if n == 0 then + status:SetText(L.CHAN_NONE) + elseif not e then + status:SetText(L.CHAN_SELECT) + else + local txt = e.num .. ". " .. e.name + if e.stamp then + txt = txt .. " - " .. string.format(L.CHAN_MEMBERS, tostring(e.count)) + else + txt = txt .. " - " .. L.CHAN_NOT_QUERIED + end + if string.find(e.name, "^Freewood") then txt = txt .. " (" .. L.CHAN_HIDDEN .. ")" end + local pend = Ch.Pending() + if pend > 0 then txt = txt .. " - " .. string.format(L.CHAN_PENDING, tostring(pend)) end + status:SetText(txt) + local m = 1 + while m <= table.getn(e.members) and m <= MAX_ROWS do + items[m] = { name = e.members[m] } + m = m + 1 + end + end + end + local cnt = table.getn(items) + local i = 1 + while i <= cnt do + local it = items[i] + local r = rrows.Get(i) + local nm = it.name + if it.extra then + r.text:SetText(nm .. " |cff8c8a84" .. it.extra .. "|r") + else + r.text:SetText(nm) + end + if nm == selPlayer then r.sel:Show() else r.sel:Hide() end + r:SetScript("OnClick", function() + selPlayer = nm + selLabel:SetText(nm) + Redraw() + end) + r:Show() + i = i + 1 + end + rrows.HideFrom(cnt + 1) + right:SetContentHeight(math.max(1, cnt * ROW)) + end + + edit:SetScript("OnTextChanged", function(self) + search = self:GetText() or "" + if search == "" then hint:Show() else hint:Hide() end + right:ScrollToTop() + Redraw() + end) + + f.Refresh = function() + if Ch.dirty or table.getn(Ch.list) == 0 then + Ch.dirty = nil + Ch.RefreshAll() + else + Ch.Rescan() + end + if not selNum and Ch.list[1] then selNum = Ch.list[1].num end + Redraw() + end + + -- Aenderungen der Datenschicht nachziehen (hoechstens 2x pro Sekunde) + local acc = 0 + f:SetScript("OnUpdate", function(_, el) + acc = acc + (el or arg1 or 0) + if acc < 0.5 then return end + acc = 0 + if f:IsShown() and seenVersion ~= Ch.version then Redraw() end + end) + + return f +end + +UI:RegisterTab("channels", { + label = function() return FW.L.TAB_CHANNELS end, + order = 115, + build = BuildChannels, + onShow = function(f) f.Refresh() end, +})