<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE muclient>
<muclient>
<plugin
   name="MIM_Chat"
   author="Murderous Intentions"
   id="9d3e6b1f4a7c2e85d0b6f193"
   language="Lua"
   purpose="Keeps the game's channels, says and tells in a window"
   date_written="2026-10-17"
   requires="4.73"
   version="1.0"
   save_state="y"
>
<description trim="y">
<![CDATA[
Murderous Intentions chat window.

Ported from Fiendish's Aardwolf chat capture window.  It needs the
MIM_GMCP_handler plugin, and mim_chat.lua in the same directory as this
plugin.

Every line of talk the game sends over GMCP (Comm.Channel) goes in the
window: the channels (gossip, ooc, clan gossip, quest gossip, grats,
quote, q/a, music, newbie, hero, clan, immortal), say, mobsay (mobs
talking), tell, gtell, yell, shout and info, in their colours.  The lines
still show in the main window as well.

  chats                    this help
  chats show               show the window
  chats hide               hide it
  chats scroll             scroll to the newest line
  chats reset              put the window back where it starts, at its
                           first size and font
  chats mute <name> [min]  don't capture what someone says (for that many
                           minutes, or until you unmute them)
  chats unmute <name>      capture them again
  chats mutes              who you have muted

Drag the title bar to move the window, and the corner to resize it.
Drag over the text to select it; double-click selects a word and
triple-click a line.  Links can be clicked.

Right-click the text for the menu: copying, the font, timestamps, logging
to a file, tabs (add, remove, name, and which channels each one keeps),
muting, and showing the title bar.

Tabs: each tab keeps the channels you pick for it.  A tab with new lines
you haven't seen is shown in orange.

What the window holds is kept when the plugin or MUSHclient is closed,
and comes back when it is opened again.

For other plugins (with CallPlugin), like Fiendish's original:

  storeFromOutside(text, tab, add_timestamp, omit_log)
     puts text (with the game's colour codes, {r or <F0F0>) in a tab
     (a name or number; the first if not given), with a timestamp unless
     add_timestamp is false, and logged unless omit_log is true

  CallPlugin("9d3e6b1f4a7c2e85d0b6f193", "storeFromOutside", "{RHello{x there", "1")
]]>
</description>

</plugin>

<aliases>

<alias
   match="^chats$"
   script="chat_help"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats show$"
   script="chat_show"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats hide$"
   script="chat_hide"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats scroll$"
   script="chat_scroll_to_end"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats reset$"
   script="chat_reset"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats mute ([A-Za-z]+)(?: +(\d+))?$"
   script="chat_mute"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats unmute ([A-Za-z]+)$"
   script="chat_unmute"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

<alias
   match="^chats mutes$"
   script="chat_mutes"
   enabled="y"
   regexp="y"
   ignore_case="y"
   sequence="100"
></alias>

</aliases>

<script>
<![CDATA[
-- the library lives in the same directory as this plugin
package.path = GetPluginInfo (GetPluginID (), 20) .. "?.lua;" .. package.path
require "mim_chat"
require "movewindow"
require "serialize"
require "pairsbykeys"

GMCP_HANDLER = "c8de491c3162475692a1d8d4"

local gap = 1
MAX_LINES = 1000 -- how many lines each tab keeps

default_width = 657 + Theme.RESIZER_SIZE + 2
default_height = 160
default_x = 0
default_y = 0
default_font_size = 9
default_log_file_size = 0 -- MB, or 0 not to split

-- the first of these fonts that is installed
local function preferred_font ()
   local fonts = utils.getfontfamilies ()
   if not fonts.Dina then
      AddFont (GetInfo (66) .. "\\Dina.fon")  -- comes with some MUSHclient packages
      fonts = utils.getfontfamilies ()
   end -- if
   for _, name in ipairs ({ "Dina", "Consolas", "Lucida Console", "Courier New" }) do
      if fonts [name] then
         return name
      end -- if
   end -- for
   return "Courier New"
end -- preferred_font

default_font_name = preferred_font ()

date_format = GetVariable ("date_format") or "[%H:%M:%S] "
width = tonumber (GetVariable ("WINDOW_WIDTH")) or default_width
height = tonumber (GetVariable ("WINDOW_HEIGHT")) or default_height
log_to_file = tonumber (GetVariable ("log_to_file")) or 0
log_colour_codes = tonumber (GetVariable ("log_colour_codes")) or 1
log_timestamps = tonumber (GetVariable ("log_timestamps")) or 1
log_file_size = tonumber (GetVariable ("log_file_size")) or default_log_file_size

-- what the game sends as Comm.Channel's chan
local init_channel_keys = {
   "ooc", "gossip", "clan gossip", "quest gossip", "grats", "quote", "q/a", "music",
   "newbie", "hero", "clan", "immortal",
   "say", "mobsay", "tell", "gtell", "yell", "shout", "info",
}
channels_table = {}
loadstring (GetVariable ("channels_table") or "") ()

tabs_activity = {}

loadstring (GetVariable ("tabs_channel_captures") or "") ()
if (tabs_channel_captures == nil) or (#tabs_channel_captures == 0) then
   tabs_channel_captures = { {} }
end -- if
num_tabs = #tabs_channel_captures
current_tab = math.max (1, math.min (tonumber (GetVariable ("current_tab")) or 1, num_tabs))

tabs_names = {}
loadstring (GetVariable ("tabs_names") or "") ()

tabs_active_seen_highlight = {}
loadstring (GetVariable ("tabs_active_seen_highlight") or "") ()
for i = 1, num_tabs do
   tabs_active_seen_highlight [i] = (tabs_active_seen_highlight [i] == 0) and 0 or 2
end -- for

-- who not to capture: lower case name -> when it ends (os.time), or 0 for never
mute_list = {}
loadstring (GetVariable ("mute_list") or "") ()

window_title = GetVariable ("window_title") or "Communication Log"
if window_title == "" then
   window_title = nil
end -- if

z_order = tonumber (GetVariable ("z_order") or "")

function clearTab (tab)
   if tab == nil then
      tab = current_tab or 1
   elseif type (tab) == "string" then
      tab = findTab (tab)
   end -- if
   if tab and tabs_rects [tab] then
      tabs_rects [tab]:clear (true)
   else
      ColourNote ("white", "red", "Chat window: there is no such tab.")
   end -- if
   Redraw ()
   SaveState ()
end -- clearTab

-- the channels any tab captures
function reduce_diverse_channels ()
   channels_table = {}
   for _, channels in ipairs (tabs_channel_captures) do
      for chan, v in pairs (channels) do
         if v ["capture"] then
            channels_table [chan] = { ["capture"] = true }
         end -- if
      end -- for
   end -- for
end -- reduce_diverse_channels

function addNewChannel (chan)
   for _, channels in ipairs (tabs_channel_captures) do
      channels [chan] = channels [chan] or { ["capture"] = true }
      if channels [chan] ["capture"] then
         channels_table [chan] = { ["capture"] = true }
      end -- if
   end -- for
end -- addNewChannel

function addDefaultChannels ()
   for _, v in ipairs (init_channel_keys) do
      addNewChannel (v)
   end -- for
end -- addDefaultChannels
addDefaultChannels ()
reduce_diverse_channels ()

function makeRect (i)
   rect_serial = (rect_serial or 0) + 1    -- names stay unique when tabs are removed
   tabs_rects [i] = TextRect.new (win, "tr" .. rect_serial, tr_left, tr_top, tr_right, tr_bottom,
      MAX_LINES, true, Theme.PRIMARY_BODY, 3, font_name, font_size)
   tabs_scrollbars [i] = ScrollBar.new (win, "sb" .. rect_serial, tr_right, tr_top, draw_right,
      draw_bottom - Theme.RESIZER_SIZE + 1)

   tabs_rects [i]:addUpdateCallback (tabs_scrollbars [i], tabs_scrollbars [i].setScroll)
   tabs_scrollbars [i]:addUpdateCallback (tabs_rects [i], tabs_rects [i].setScroll)

   tabs_rects [i]:setExternalMenuFunction (extend_rightclick_menu)
   tabs_rects [i]:loadFont (font_name, font_size)

   bump_top_for_tabs ()
   tabs_rects [i]:setRect (tr_left, tr_top, tr_right, tr_bottom)
   tabs_scrollbars [i]:setRect (tr_right, tr_top, draw_right, tr_bottom - Theme.RESIZER_SIZE + 1)

   if temp_tabs_data and temp_tabs_data [i] then
      tabs_rects [i]:deserializeContents (temp_tabs_data [i].contents, true)
      tabs_activity [i] = temp_tabs_data [i].activity
      temp_tabs_data [i] = nil
   end -- if
end -- makeRect

-- a tab by name (first) or number
function findTab (name_or_number)
   for i = 1, num_tabs do
      if tabs_names [i] and (tabs_names [i]:upper () == tostring (name_or_number):upper ()) then
         return i
      end -- if
   end -- for
   for i = 1, num_tabs do
      if tostring (i) == tostring (name_or_number) then
         return i
      end -- if
   end -- for
end -- findTab

function setTabByName (name)
   if type (name) == "string" then
      local tab = findTab (name)
      if tab then
         setTab (tab)
      end -- if
   end -- if
end -- setTabByName

function setTab (i)
   if type (i) ~= "number" then
      return
   end -- if

   if tabs_rects [current_tab] then
      tabs_rects [current_tab]:unInit ()
      tabs_scrollbars [current_tab]:unInit ()
   end -- if
   current_tab = math.min (math.max (i, 1), num_tabs)
   tabs_activity [current_tab] = false

   setUpDisplay ()
   Redraw ()
   return current_tab
end -- setTab

function nextTab ()
   local prev_tab = current_tab
   if prev_tab == setTab (current_tab + 1) then
      setTab (1)
   end -- if
end -- nextTab

function prevTab ()
   local prev_tab = current_tab
   if prev_tab == setTab (current_tab - 1) then
      setTab (num_tabs)
   end -- if
end -- prevTab

function addTab ()
   clearTabHotspots ()
   num_tabs = num_tabs + 1
   tabs_channel_captures [num_tabs] = {}
   tabs_activity [num_tabs] = false
   tabs_active_seen_highlight [num_tabs] = 2

   addDefaultChannels ()
   setUpDisplay ()

   return num_tabs
end -- addTab

function nameTab (tab, name)
   if (tab >= 1) and (tab <= num_tabs) then
      name = Trim (name or "")
      if name ~= "" then
         tabs_names [tab] = name
      else
         tabs_names [tab] = nil
      end -- if
      clearTabHotspots ()
      drawTabs ()
      return tab
   end -- if
end -- nameTab

function getNamedTab (name)
   return findTab (name) or nameTab (addTab (), name)
end -- getNamedTab

function clearTabHotspots ()
   for i = 1, num_tabs do
      WindowDeleteHotspot (win, "tab" .. tostring (i))
   end -- for
end -- clearTabHotspots

function chat_scroll_to_end ()
   local tr = tabs_rects [current_tab]
   tr:setScroll (tr.num_wrapped_lines - tr.rect_lines + 1)
   tr:draw ()
   Redraw ()
end -- chat_scroll_to_end

function removeTab ()
   clearTabHotspots ()
   tabs_rects [current_tab]:unInit ()
   tabs_scrollbars [current_tab]:unInit ()

   table.remove (tabs_names, current_tab)
   table.remove (tabs_rects, current_tab)
   table.remove (tabs_scrollbars, current_tab)
   table.remove (tabs_channel_captures, current_tab)
   table.remove (tabs_active_seen_highlight, current_tab)
   table.remove (tabs_activity, current_tab)

   num_tabs = num_tabs - 1
   reduce_diverse_channels ()
   setTab (current_tab)
end -- removeTab

function chat_reset ()
   font_name = default_font_name
   font_size = default_font_size
   width, height = default_width, default_height
   WindowFont (win, "channels_font", font_name, font_size, false, false, false, false, 0)
   tab_font_height = Theme.TextHeight (win, "channels_font")
   for i = 1, num_tabs do
      tabs_rects [i]:loadFont (font_name, font_size)
   end -- for
   windowinfo.window_left, windowinfo.window_top = default_x, default_y
   WindowPosition (win, default_x, default_y, 0, miniwin.create_absolute_location + miniwin.create_keep_hotspots)
   ResizeReleaseCallback ()
   WindowShow (win, true)
   SaveState ()
   ColourNote ("yellow", "", "Chat window reset.")
end -- chat_reset

function OnPluginInstall ()
   init ()
   setUpDisplay ()
   OnPluginEnable ()
end -- OnPluginInstall

function bump_top_for_tabs ()
   if num_tabs > 1 then
      tab_top = draw_top + 2
      tr_top = tab_top + (2 * gap) + tab_font_height + 2
   end -- if
end -- bump_top_for_tabs

function set_tr_shape ()
   tr_left = draw_left
   tr_top = draw_top

   bump_top_for_tabs ()

   tr_right = draw_right - Theme.RESIZER_SIZE + 1
   tr_bottom = draw_bottom - 1
   return tr_left, tr_top, tr_right, tr_bottom
end -- set_tr_shape

-- right-click on the title bar: the menu; only the left button drags
function LeftClickOnly (flags, hotspot_id, win)
   return bit.band (flags, miniwin.hotspot_got_rh_mouse) ~= 0
end -- LeftClickOnly

function TitleMouseUp (flags, hotspot_id, win)
   if bit.band (flags, miniwin.hotspot_got_rh_mouse) ~= 0 then
      tabs_rects [current_tab]:rightClickMenu ()
      return true
   end -- if
end -- TitleMouseUp

function init ()
   win = GetPluginID ()

   -- the window, where movewindow last saved it
   windowinfo = movewindow.install (win, miniwin.pos_top_left, miniwin.create_absolute_location, false, nil,
      { mouseup = TitleMouseUp, mousedown = LeftClickOnly, dragmove = LeftClickOnly, dragrelease = LeftClickOnly },
      { x = default_x, y = default_y })
   WindowCreate (win, windowinfo.window_left, windowinfo.window_top, width, height,
      windowinfo.window_mode, windowinfo.window_flags, Theme.SECONDARY_BODY)
   if z_order and WindowSetZOrder then
      WindowSetZOrder (win, z_order)
   end -- if

   WindowShow (win, true)

   font_name = GetVariable ("font_name") or default_font_name
   font_size = tonumber (GetVariable ("font_size")) or default_font_size

   WindowFont (win, "channels_font", font_name, font_size, false, false, false, false, 0)
   tab_font_height = Theme.TextHeight (win, "channels_font")

   tabs_rects = {}
   tabs_scrollbars = {}

   -- what the window held when it was last closed
   local f = io.open (tmpfile, "r")
   if f then
      local data = f:read ("*a") or ""
      f:close ()
      os.remove (tmpfile)
      local fn = loadstring ("return " .. data)
      local ok, t = pcall (fn or function () end)
      if ok and type (t) == "table" then
         temp_tabs_data = t
      end -- if
   end -- if
end -- init

function drawTabs ()
   if num_tabs > 1 then
      local tab_left = draw_left + 5
      local tab_padding = Theme.DYNAMIC_BUTTON_PADDING
      local active_left = nil
      local active_width = nil
      WindowRectOp (win, miniwin.rect_fill, draw_left, draw_top, draw_right + 1,
         draw_top + (4 * gap) + tab_font_height + 1, Theme.SECONDARY_BODY)

      if added_to_current then
         for k, _ in pairs (tabs_activity) do
            if tabs_active_seen_highlight [k] == 2 then
               tabs_activity [k] = nil
            end -- if
         end -- for
      end -- if
      for i, _ in ipairs (tabs_channel_captures) do
         local name = tabs_names [i] or tostring (i)
         local tab_width = WindowTextWidth (win, "channels_font", name) + (tab_padding * 2)

         if not WindowHotspotInfo (win, "tab" .. tostring (i), 1) then
            WindowAddHotspot (win, "tab" .. tostring (i), tab_left, tab_top, tab_left + tab_width,
               tab_top + (3 * gap) + tab_font_height,
               "tabMouseOver", "tabCancelMouseOver", "tabMouseDown", nil, "tabMouseUp", "", miniwin.cursor_hand, 0)
         else
            WindowMoveHotspot (win, "tab" .. tostring (i), tab_left, tab_top, tab_left + tab_width,
               tab_top + (3 * gap) + tab_font_height)
         end -- if

         local text_color = Theme.BODY_TEXT
         if i == current_tab then
            active_left = tab_left
            active_width = tab_width
         else
            local bg_color
            if i ~= mouseover then
               if tabs_activity [i] then
                  bg_color = Theme.CLICKABLE_HOT
                  text_color = Theme.CLICKABLE_HOT_TEXT
               else
                  bg_color = Theme.CLICKABLE
                  text_color = Theme.CLICKABLE_TEXT
               end -- if
            else
               bg_color = Theme.CLICKABLE_HOVER
               text_color = Theme.CLICKABLE_HOVER_TEXT
            end -- if

            WindowCircleOp (win, miniwin.circle_round_rectangle,
               tab_left, tab_top, tab_left + tab_width, tab_top + (4 * gap) + (2 * tab_font_height),
               Theme.THREE_D_HIGHLIGHT, miniwin.pen_solid, 1,
               bg_color, miniwin.brush_solid,
               16, (4 * gap) + (2 * tab_font_height))
         end -- if

         tab_left = tab_left + tab_padding
         tab_left = tab_left + WindowText (win, "channels_font", name, tab_left, tab_top + gap + 1, draw_right,
            draw_top + (3 * gap) + tab_font_height, text_color, false)
         tab_left = tab_left + tab_padding
      end -- for

      WindowLine (win, draw_left, tab_top + (2 * gap) + tab_font_height + 1, draw_right + 1,
         tab_top + (2 * gap) + tab_font_height + 1, Theme.THREE_D_HIGHLIGHT, miniwin.pen_solid + 0x0200, 1)
      WindowCircleOp (win, miniwin.circle_round_rectangle,
         active_left, tab_top, active_left + active_width, tab_top + (4 * gap) + (2 * tab_font_height),
         Theme.THREE_D_HIGHLIGHT, miniwin.pen_solid, 1,
         Theme.PRIMARY_BODY, miniwin.brush_solid,
         16, (4 * gap) + (2 * tab_font_height))
      WindowText (win, "channels_font", tabs_names [current_tab] or tostring (current_tab),
         active_left + tab_padding, tab_top + gap + 1, draw_right, draw_top + (3 * gap) + tab_font_height,
         Theme.BODY_TEXT, false)
   end -- if
   tabs_rects [current_tab]:draw ()
   tabs_scrollbars [current_tab]:draw ()
   Redraw ()
end -- drawTabs

function tabMouseOver (flags, hotspot_id)
   mouseover = tonumber (hotspot_id:sub (4))
   drawTabs ()
end -- tabMouseOver

function tabCancelMouseOver (flags, hotspot_id)
   mouseover = nil
   drawTabs ()
end -- tabCancelMouseOver

function tabMouseDown (flags, hotspot_id)
   setTab (tonumber (hotspot_id:sub (4)))
end -- tabMouseDown

function tabMouseUp (flags, hotspot_id)
   if bit.band (flags, miniwin.hotspot_got_rh_mouse) ~= 0 then
      local tab = tonumber (hotspot_id:sub (4)) or current_tab
      if tabs_rects [tab] then
         tabs_rects [tab]:rightClickMenu ()
      end -- if
   end -- if
end -- tabMouseUp

function drawBackground ()
   draw_left, draw_top, draw_right, draw_bottom = Theme.DressWindow (win, "channels_font", window_title)
   Theme.AddResizeTag (win, 2, nil, nil, "MouseDown", "ResizeMoveCallback", "ResizeReleaseCallback")
end -- drawBackground

function ResizeMoveCallback ()
   local posx, posy = WindowInfo (win, 17), WindowInfo (win, 18)
   width = width + posx - startx
   startx = posx
   local min_width = WindowTextWidth (win, "channels_font", "Communication Log") + 2 * Theme.RESIZER_SIZE
   if min_width > width then
      width = min_width
      startx = windowinfo.window_left + width
   elseif windowinfo.window_left + width > GetInfo (281) then
      width = GetInfo (281) - windowinfo.window_left
      startx = GetInfo (281)
   end -- if
   height = height + posy - starty
   starty = posy
   if 3 * Theme.RESIZER_SIZE + 70 > height then
      height = 3 * Theme.RESIZER_SIZE + 70
      starty = windowinfo.window_top + height
   elseif windowinfo.window_top + height > GetInfo (280) then
      height = GetInfo (280) - windowinfo.window_top
      starty = GetInfo (280)
   end -- if
   if utils.timer () - lastRefresh > 0.0333 then
      setUpDisplay ()
      lastRefresh = utils.timer ()
   end -- if
end -- ResizeMoveCallback

lastRefresh = 0

function ResizeReleaseCallback ()
   setUpDisplay ()

   for i = 1, num_tabs do
      tabs_rects [i]:reWrapLines ()
   end -- for

   tabs_rects [current_tab]:draw ()
   tabs_scrollbars [current_tab]:draw ()
   Redraw ()
   SaveState ()
end -- ResizeReleaseCallback

function setUpDisplay ()
   WindowResize (win, width, height, Theme.SECONDARY_BODY)
   drawBackground ()
   set_tr_shape ()
   for i = 1, num_tabs do
      if tabs_rects [i] then
         tabs_rects [i]:setRect (tr_left, tr_top, tr_right, tr_bottom)
         tabs_scrollbars [i]:setRect (tr_right, tr_top, draw_right, tr_bottom - Theme.RESIZER_SIZE + 1)
      else
         makeRect (i)
      end -- if
   end -- for
   temp_tabs_data = nil
   drawTabs ()
end -- setUpDisplay

tmpfile = GetInfo (66) .. GetPluginID () .. "_" .. GetWorldID () .. "_chat_data.txt"

function OnPluginClose ()
   -- keep what the window holds, for next time
   if tabs_rects then
      local data = {}
      for i, v in ipairs (tabs_rects) do
         data [i] = { contents = v:serializeContents (), activity = tabs_activity [i] }
      end -- for
      local f = io.open (tmpfile, "w")
      if f then
         f:write (serialize.save_simple (data))
         f:close ()
      end -- if
   end -- if
   WindowDelete (win)
end -- OnPluginClose

function OnPluginEnable ()
   WindowShow (win, true)
end -- OnPluginEnable

function OnPluginDisable ()
   WindowShow (win, false)
end -- OnPluginDisable

function OnPluginSaveState ()
   movewindow.save_state (win)

   SetVariable ("font_name", font_name)
   SetVariable ("font_size", font_size)
   SetVariable ("date_format", date_format)
   if WindowInfo (win, 3) and WindowInfo (win, 4) then
      width = WindowInfo (win, 3)
      height = WindowInfo (win, 4)
   end -- if
   SetVariable ("WINDOW_WIDTH", width)
   SetVariable ("WINDOW_HEIGHT", height)
   SetVariable ("log_to_file", log_to_file)
   SetVariable ("log_file_size", log_file_size)
   SetVariable ("log_colour_codes", log_colour_codes)
   SetVariable ("log_timestamps", log_timestamps)
   SetVariable ("current_tab", current_tab)
   SetVariable ("channels_table", serialize.save ("channels_table"))
   SetVariable ("tabs_names", serialize.save ("tabs_names"))
   SetVariable ("tabs_channel_captures", serialize.save ("tabs_channel_captures"))
   SetVariable ("tabs_active_seen_highlight", serialize.save ("tabs_active_seen_highlight"))
   SetVariable ("mute_list", serialize.save ("mute_list"))
   SetVariable ("window_title", window_title or "")
   SetVariable ("z_order", z_order or "")
end -- OnPluginSaveState

function sanitize_filename (str)
   str = string.gsub (str, "[^%w%s()_-]", "")
   return Trim (str or "")
end -- sanitize_filename

-- the log file: <world name>ChatLog.txt in MUSHclient's log directory
function log_filename (prefix)
   return GetInfo (58):gsub ("^.\\", GetInfo (56)) .. (prefix or "") .. sanitize_filename (GetInfo (2)) .. "ChatLog.txt"
end -- log_filename

function log (multiline_styles, timestamp)
   local log_text
   if log_colour_codes == 0 then
      log_text = strip_colours_from_styles (multiline_styles)
   else
      log_text = StylesToColours (multiline_styles)
   end -- if

   if log_timestamps == 1 and timestamp ~= "" then
      if log_colour_codes == 0 then
         log_text = timestamp .. log_text
      else
         log_text = "{w" .. timestamp:gsub ("{", "{{") .. "{x" .. log_text
      end -- if
   end -- if

   local filename = log_filename ()
   local f, err = io.open (filename, "a+")
   if f then
      f:write (log_text .. "\n")
      local size = f:seek ("end")
      f:close ()
      if log_file_size > 0 and (size / 1048576) >= log_file_size then
         os.rename (filename, log_filename (os.time () .. "-"))  -- start a new one
      end -- if
   else
      ColourNote ("white", "red", "Chat window: can't write the log file:")
      ColourNote ("white", "red", tostring (err))
      ColourNote ("white", "red", "Fix the problem, or turn off logging to a file in the chat window's menu.")
   end -- if
end -- log

function stampAndLog (multiline_styles, timestamp, omit_log)
   timestamp = timestamp or ""

   -- no control characters (%c, not %g: that would spoil multibyte characters)
   for _, line in ipairs (multiline_styles) do
      for _, style in ipairs (line) do
         style.text = style.text:gsub ("%c", "")
         style.length = #style.text
      end -- for
   end -- for

   if (log_to_file == 1) and not omit_log then
      log (multiline_styles, timestamp)
   end -- if
   if timestamp ~= "" then
      table.insert (multiline_styles [1], 1, {
         text = timestamp,
         length = #timestamp,
         textcolour = GetNormalColour (8),
         code = "{w",
         bold = false
      })
   end -- if
   return multiline_styles
end -- stampAndLog

-- For other plugins (CallPlugin):
--   1. the text, with the game's colour codes (or a serialized table of styles)
--   2. (optional) the tab's name or number; the first if not given
--   3. (optional) false for no timestamp
--   4. (optional) true to leave it out of the log file
function storeFromOutside (text, tab, add_timestamp, omit_log)
   if add_timestamp == nil or add_timestamp == "" then
      add_timestamp = true
   elseif add_timestamp == "false" or add_timestamp == "0" then
      add_timestamp = false
   end -- if
   if omit_log == "true" or omit_log == "1" then
      omit_log = true
   elseif omit_log == "false" or omit_log == "0" or omit_log == "" then
      omit_log = false
   end -- if
   if tab == nil or tab == "" then
      tab = 1
   else
      tab = findTab (tab)
   end -- if

   local res = nil
   if tab then
      local multiline_styles
      local is_styles, styles = pcall (function ()
         return ToMultilineStyles (loadstring ("return " .. text) (), nil, nil, true)
      end)
      if is_styles then
         multiline_styles = styles
      else
         multiline_styles = ToMultilineStyles (tostring (text or ""), nil, nil, true)
      end -- if
      multiline_styles = stampAndLog (multiline_styles, add_timestamp and os.date (date_format) or "", omit_log)
      added_to_current = false
      res = addToTab (multiline_styles, tab)
      if res == 1 then
         drawTabs ()
      end -- if
   end -- if

   return res and (res >= 0) or false
end -- storeFromOutside

-- returns 1 if the tabs need drawing again (a tab now has new lines), 0 if
-- not, -1 if there's no such tab
function addToTab (multiline_styles, tab)
   if (tab >= 1) and (tab <= num_tabs) then
      tabs_rects [tab]:addText (multiline_styles)
      if tab == current_tab then
         added_to_current = true
         tabs_rects [tab]:draw ()
         tabs_scrollbars [tab]:draw ()
         Redraw ()
      elseif not tabs_activity [tab] then
         tabs_activity [tab] = true
         return 1
      end -- if
      return 0
   end -- if
   return -1
end -- addToTab

function storeMessage (multiline_styles, label, captures)
   local will_draw = false
   added_to_current = false
   for tab, whats_inside_the_tab in ipairs (captures) do
      if whats_inside_the_tab [label] and whats_inside_the_tab [label] ["capture"] then
         -- each tab gets its own copy: wrapping changes them
         if addToTab (copytable.deep (multiline_styles), tab) == 1 then
            will_draw = true
         end -- if
      end -- if
   end -- for
   if will_draw then
      drawTabs ()
   end -- if
end -- storeMessage

function storeChannel (multiline_styles, label)
   multiline_styles = stampAndLog (multiline_styles, os.date (date_format))
   for _, whats_inside_the_tab in ipairs (tabs_channel_captures) do
      if not whats_inside_the_tab [label] then
         addNewChannel (label)
      end -- if
   end -- for
   storeMessage (multiline_styles, label, tabs_channel_captures)
end -- storeChannel

-- the GMCP handler's data for a package, as a table
function gmcp (what)
   local rc, s = CallPlugin (GMCP_HANDLER, "gmcpdata_as_string", what)
   if rc ~= error_code.eOK or s == nil or s == "" then
      return nil
   end -- if
   local f = loadstring ("return " .. s)
   local ok, v = pcall (f or function () end)
   if ok then
      return v
   end -- if
end -- gmcp

function is_muted (player)
   local key = tostring (player or ""):lower ()
   local ends = mute_list [key]
   if not ends then
      return false
   end -- if
   if ends ~= 0 and os.time () >= ends then
      mute_list [key] = nil
      return false
   end -- if
   return true
end -- is_muted

function OnPluginBroadcast (msg, id, name, text)
   if id == GMCP_HANDLER and text == "comm.channel" then
      local comm = gmcp ("comm.channel")
      if type (comm) ~= "table" then
         return
      end -- if
      local chan = tostring (comm.chan or "")
      local msg_text = tostring (comm.msg or "")
      local player = tostring (comm.player or "")
      if chan == "" or msg_text == "" or is_muted (player) then
         return
      end -- if
      if (not channels_table [chan]) or channels_table [chan] ["capture"] then
         storeChannel (ColoursToStyles (msg_text, nil, nil, true), chan)
      end -- if
   end -- if
end -- OnPluginBroadcast

function MouseDown (flags, hotspot_id)
   if hotspot_id == "$resizer" then
      startx, starty = WindowInfo (win, 17), WindowInfo (win, 18)
   end -- if
end -- MouseDown

function chat_help ()
   ColourNote ("cyan", "", GetPluginInfo (GetPluginID (), 3))
end -- chat_help

function chat_show (name, line, wildcards)
   WindowShow (win, true)
   ColourNote ("yellow", "", "Chat window now shown. Type 'chats hide' to hide it.")
end -- chat_show

function chat_hide (name, line, wildcards)
   WindowShow (win, false)
   ColourNote ("yellow", "", "Chat window now hidden. Type 'chats show' to see it again.")
end -- chat_hide

function mute_name (name, minutes)
   name = Trim (tostring (name or "")):lower ()
   if name == "" then
      return
   end -- if
   minutes = tonumber (minutes)
   mute_list [name] = (minutes and minutes > 0) and (os.time () + minutes * 60) or 0
   ColourNote ("yellow", "", "Chat window: not capturing " .. name:sub (1, 1):upper () .. name:sub (2)
      .. ((minutes and minutes > 0) and (" for " .. minutes .. " minute" .. (minutes == 1 and "" or "s")) or "")
      .. ".")
   SaveState ()
end -- mute_name

function unmute_name (name)
   name = Trim (tostring (name or "")):lower ()
   local was = mute_list [name] ~= nil
   mute_list [name] = nil
   ColourNote ("yellow", "", "Chat window: " .. (was and "capturing " or "you hadn't muted ")
      .. name:sub (1, 1):upper () .. name:sub (2) .. (was and " again." or "."))
   SaveState ()
end -- unmute_name

function chat_mute (name, line, wildcards)
   mute_name (wildcards [1], wildcards [2])
end -- chat_mute

function chat_unmute (name, line, wildcards)
   unmute_name (wildcards [1])
end -- chat_unmute

-- the muted names, capitalized and sorted (dropping any whose time is up)
function muted_names ()
   local names = {}
   for k, _ in pairs (mute_list) do
      if is_muted (k) then
         table.insert (names, k:sub (1, 1):upper () .. k:sub (2))
      end -- if
   end -- for
   table.sort (names)
   return names
end -- muted_names

function chat_mutes ()
   local names = muted_names ()
   if #names == 0 then
      ColourNote ("yellow", "", "Chat window: you haven't muted anyone.")
      return
   end -- if
   ColourNote ("yellow", "", "Chat window: not capturing:")
   for _, n in ipairs (names) do
      local ends = mute_list [n:lower ()]
      ColourNote ("white", "", "  " .. n .. (ends ~= 0
         and string.format (" (%d more minute%s)", math.ceil ((ends - os.time ()) / 60),
             math.ceil ((ends - os.time ()) / 60) == 1 and "" or "s") or ""))
   end -- for
end -- chat_mutes

local function set_date_format (format, example)
   date_format = format
   if format == "" then
      ColourNote ("yellow", "", "Timestamps in the chat window DISABLED.")
   else
      ColourNote ("yellow", "", "Timestamps in the chat window ENABLED, like '" .. example .. "'.")
   end -- if
   SaveState ()
end -- set_date_format

function extend_rightclick_menu ()
   local menu_items = {}
   local function timestamp_item (format, example)
      return {
         ((date_format == format) and "+" or "") .. example,
         function () set_date_format (format, example) end
      }
   end -- timestamp_item

   for _, v in ipairs ({
      {
         "Configure Font",
         function ()
            local wanted_font = utils.fontpicker (font_name, font_size)
            if wanted_font then
               font_name = wanted_font.name
               font_size = wanted_font.size
               WindowFont (win, "channels_font", font_name, font_size, false, false, false, false, 0)
               tab_font_height = Theme.TextHeight (win, "channels_font")
               for i = 1, num_tabs do
                  tabs_rects [i]:loadFont (font_name, font_size)
                  tabs_rects [i]:reWrapLines ()
               end -- for
               setUpDisplay ()
            end -- if
            SaveState ()
         end
      },
      { ">Timestamp", "" },
      {
         ((date_format == "") and "+" or "") .. "No Timestamps",
         function () set_date_format ("", "") end
      },
      timestamp_item ("[%Y-%m-%d %H:%M:%S] ", "2026-10-17 13:20:12"),
      timestamp_item ("[%Y-%m-%d %I:%M:%S %p] ", "2026-10-17 01:20:12 PM"),
      timestamp_item ("[%d %b %H:%M:%S] ", "17 Oct 13:29:49"),
      timestamp_item ("[%d %b %I:%M:%S %p] ", "17 Oct 01:20:12 PM"),
      timestamp_item ("[%H:%M:%S] ", "13:29:08"),
      timestamp_item ("[%I:%M:%S %p] ", "01:22:06 PM"),
      { "<", "" },
      { ">Logging to File", "" },
      {
         (log_to_file == 1) and "Disable" or "Enable",
         function ()
            log_to_file = (log_to_file == 0) and 1 or 0
            if log_to_file == 0 then
               ColourNote ("yellow", "", "Logging to a file is now DISABLED.")
            else
               ColourNote ("yellow", "", "Logging to a file is now ENABLED: " .. log_filename ())
            end -- if
            SaveState ()
         end
      },
      {
         ((log_colour_codes == 1) and "+" or "") .. "Log with Color Codes",
         function ()
            log_colour_codes = (log_colour_codes == 0) and 1 or 0
            if log_colour_codes == 0 then
               ColourNote ("yellow", "", "Color codes will be left out of the log file.")
            else
               ColourNote ("yellow", "", "Color codes will be kept in the log file.")
            end -- if
            SaveState ()
         end
      },
      {
         ((log_timestamps == 1) and "+" or "") .. "Log with Timestamps",
         function ()
            log_timestamps = (log_timestamps == 0) and 1 or 0
            if log_timestamps == 0 then
               ColourNote ("yellow", "", "Timestamps will be left out of the log file.")
            else
               ColourNote ("yellow", "", "Timestamps will be kept in the log file.")
            end -- if
            SaveState ()
         end
      },
      { "-", "" },
      {
         "Set Log File Split Size (" .. ((log_file_size == 0) and "None" or (tostring (log_file_size) .. "MB")) .. ")",
         function ()
            local ls = nil
            while not ls do
               ls = utils.inputbox ("Input a size in MB. (0 for None)", "Log File Split Size", log_file_size, "", 10,
                  { prompt_height = 16, box_width = 180, box_height = 130, reply_width = 40, reply_height = 20 })
               if not ls then
                  return -- cancelled
               end -- if
               ls = tonumber (ls)
            end -- while
            log_file_size = ls
            if log_file_size == 0 then
               ColourNote ("yellow", "", "The log file will not be split.")
            else
               ColourNote ("yellow", "", "Log files will be split at " .. log_file_size .. " MB.")
            end -- if
            SaveState ()
         end
      },
      { "<", "" },
      {
         "Add New Tab",
         function ()
            setTab (addTab ())
            SaveState ()
         end
      },
      { "-", "" },
      (num_tabs < 2) and { "^Remove This Tab", "" } or {
         "Remove This Tab",
         function ()
            removeTab ()
            SaveState ()
         end
      },
      { "-", "" },
      {
         "Name This Tab",
         function ()
            local name = utils.inputbox ("Choose a (preferably short) name for this tab", "Name Chat Tab",
               tostring (tabs_names [current_tab] or current_tab))
            if not name then
               return -- cancelled: keep the name
            end -- if
            nameTab (current_tab, name)
            SaveState ()
         end
      },
      {
         "Edit Channel Captures",
         function ()
            local defaults = {}
            local tbl = {}
            for k, v in pairs (tabs_channel_captures [current_tab]) do
               tbl [k] = k
               defaults [k] = v ["capture"]
            end -- for

            local capture_result = utils.multilistbox ("Choose which channels this tab keeps.",
               "Chat Window Channels", tbl, defaults)
            if capture_result then
               for k, v in pairs (tabs_channel_captures [current_tab]) do
                  v ["capture"] = capture_result [k] and true or false
               end -- for
            end -- if
            reduce_diverse_channels ()
            SaveState ()
         end
      },
      { ">Tab Highlighting On New Messages", "" },
      {
         ((tabs_active_seen_highlight [current_tab] == 0) and "+" or "") .. "Always Highlight On New Message If Not Active",
         function ()
            tabs_active_seen_highlight [current_tab] = 0
            SaveState ()
         end
      },
      {
         ((tabs_active_seen_highlight [current_tab] == 2) and "+" or "") .. "Highlight If New Message Not Shown In Active Tab",
         function ()
            tabs_active_seen_highlight [current_tab] = 2
            SaveState ()
         end
      },
      { "<", "" },
      {
         "Clear Tab",
         function ()
            clearTab (current_tab)
         end
      },
      { "-", "" },
      { ">Mute List Options", "" },
      {
         "Add player to Mute List",
         function ()
            local name = utils.inputbox ("Mute whom?", "Mute player")
            if not name or Trim (name) == "" then
               return
            end -- if
            local function number_or_blank (input)
               return input == "" or tonumber (input) ~= nil
            end -- number_or_blank
            local minutes = utils.inputbox ("For how many minutes? Blank means until you unmute them.",
               "How many minutes?", nil, nil, nil, { validate = number_or_blank })
            if minutes ~= nil then
               mute_name (name, minutes)
            end -- if
         end
      },
      {
         "Edit Mute List",
         function ()
            local names = muted_names ()
            if #names > 0 then
               local capture_result = utils.multilistbox ("Unmute which of these players?", "Muted Players List", names)
               if capture_result then
                  for k, _ in pairs (capture_result) do
                     unmute_name (names [k])
                  end -- for
               end -- if
            else
               ColourNote ("yellow", "", "Chat window: you haven't muted anyone.")
            end -- if
         end
      },
      { "<", "" },
      { "-", "" },
      WindowSetZOrder and {
         "Bring to Front",
         function ()
            z_order = 100
            WindowSetZOrder (win, z_order)
            Redraw ()
            SaveState ()
         end
      } or { "^Bring to Front", "" },
      WindowSetZOrder and {
         "Send to Back",
         function ()
            z_order = -100
            WindowSetZOrder (win, z_order)
            Redraw ()
            SaveState ()
         end
      } or { "^Send to Back", "" },
      {
         "Show/Hide Titlebar",
         function ()
            if window_title == nil then
               window_title = "Communication Log"
            else
               window_title = nil
            end -- if
            setUpDisplay ()
            SaveState ()
         end
      }
   }) do
      table.insert (menu_items, v)
   end -- for

   local menu_strings = {}
   local menu_functions = {}
   for _, v in ipairs (menu_items) do
      table.insert (menu_strings, v [1])
      if type (v [2]) == "function" then
         table.insert (menu_functions, v [2])
      end -- if
   end -- for

   return table.concat (menu_strings, "|"), menu_functions
end -- extend_rightclick_menu
]]>
</script>
</muclient>
