UOX3 In-Game Forums (JS) — Features

Got any custom JavaScript additions/tweaks you think other people would like to see? Post 'em here!
Post Reply
dragon slayer
UOX3 Guru
Posts: 776
Joined: Thu Dec 21, 2006 7:37 am
Has thanked: 4 times
Been thanked: 26 times

UOX3 In-Game Forums (JS) — Features

Post by dragon slayer »

  • Persistent storage via JSON (Forums/forums.json) with safe load/save and first-use auto-init.
    Thread list with sticky/announcement ordering, status icons, and per-player [NEW] unread + [W] watched badges.
    Pagination, post count, and views/replies counters.
    Open thread view with scrollable post content and UTC timestamps.
    Create New Thread, Reply, and Reply (Quote last post).
    Edit Last: authors can edit their most recent post; staff can edit the thread’s last post.
    Watch/Unwatch threads and a Watched Only filter on the list.
    Mark All Read action that respects current filters (search/unread/watched).
    Staff tools: Announcement, Sticky, Staff-Only flags on new threads, plus Lock/Unlock and Delete/Restore.
    Per-player tracking of last-seen time per thread (tag-based), so indicators are accurate per character.
    Robust UI gumps (list/thread/post) with clean layout and single onGumpPress routing.
[forums]
{
get=base_item
NAME=forums
ID=0x0EDD
SCRIPT=7525
DECAY=0
WEIGHT=255
}
// ============================================================================
// In-Game Forums (UOX3 / SpiderMonkey 1.8.5 / ES5)
// ScriptID: 7525
// ============================================================================

/* --------------------------- Config --------------------------- */
var FORUMS_DIR = "Forums";
var FORUMS_FILE = "forums.json"; // flat filename (no slashes)

var BTN = 0xF7;                      // generic small button image id
var ROWS_PER_PAGE = 10;

var BID_REPLY_QUOTE = 5007;

/* Button encodings (so a single onGumpPress can route) */
function BID_THREAD_OPEN(id) { return 10000 + (id | 0); }
function BID_REPLY_OPEN(id) { return 20000 + (id | 0); }
function BID_TOGGLE_LOCK(id) { return 30000 + (id | 0); }
function BID_TOGGLE_DELETE(id) { return 31000 + (id | 0); }

var BID_NEWTHREAD = 5001;
var BID_NEXT = 5002;
var BID_PREV = 5003;
var BID_CANCEL = 5004;
var BID_SUBMIT = 5005;

var BID_EDIT_LAST = 5008; // "Edit Last" in a thread
var BID_TOGGLE_WATCH = 5009;
var BID_TOGGLE_FILTER_WATCHED = 5010;
var BID_MARK_ALL_READ = 5011;

// --- at top-level ---
var ForumsLoaded = false;

function ensureForumsLoaded()
{
    if (ForumsLoaded) return;
    try { loadForums(); } catch (e) { Console.Warning("Forums ensure error: " + e); }
    // If load failed or set nothing, still ensure usable defaults:
    if (!Forums || !Forums.data) Forums = { nextThreadId: 1, data: [] };
    ForumsLoaded = true;
}

/* --------------------------- Data Model --------------------------- */
// Thread: { id, subject, authorSer, authorName, createdAt, lastPostAt,
//           locked, deleted, staff, type: "regular"|"sticky"|"announce",
//           views, posts: [Post] }
// Post:   { id, authorSer, authorName, body, createdAt, editedAt|null }

var Forums = {
    nextThreadId: 1,
    data: [] // array of Thread
};

/* --------------------------- Persistence --------------------------- */

/* ---------- Safe file helper ---------- */
function fxOpen(fileName, mode, dir)
{
    var f = new UOXCFile();
    if (!f) return null;
    var ok = false;
    try { ok = f.Open(fileName, mode, dir); } catch (e) { ok = false; }
    if (!ok) { try { f.Free(); } catch (_) {} return null; }
    return f;
}

function saveForums()
{
    try
    {
        var out = JSON.stringify(Forums, null, 2);
        JSON.parse(out); // sanity
        var f = fxOpen(FORUMS_FILE, "w", FORUMS_DIR);
        if (!f) { Console.Warning("Forums: cannot write " + FORUMS_DIR + "/" + FORUMS_FILE + " (folder missing?)."); return; }
        f.Write(out); f.Close(); f.Free();
    } catch (e)
    {
        Console.Warning("Forums save error: " + e);
    }
}

function loadForums()
{
    try
    {
        var f = fxOpen(FORUMS_FILE, "r", FORUMS_DIR);
        if (!f)
        {
            // First run / missing file: run with defaults; not fatal.
            Forums = { nextThreadId: 1, data: [] };
            return false;
        }

        var raw = "";
        while (!f.EOF()) { var line = f.ReadUntil("\n"); raw += line; if (!f.EOF()) raw += "\n"; }
        f.Close(); f.Free();

        if (!raw || raw.length === 0) { Forums = { nextThreadId: 1, data: [] }; return true; }

        var clean = raw.replace(/^\uFEFF/, "")
            .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, "")
            .replace(/^\s+|\s+$/g, "");

        try
        {
            var obj = JSON.parse(clean);
            if (obj && obj.data && (obj.nextThreadId | 0) >= 1) { Forums = obj; return true; }
        } catch (e1)
        {
            var s = clean.indexOf("{"), e = clean.lastIndexOf("}");
            if (s !== -1 && e > s)
            {
                var sub = clean.substring(s, e + 1);
                var obj2 = JSON.parse(sub);
                if (obj2 && obj2.data && (obj2.nextThreadId | 0) >= 1) { Forums = obj2; return true; }
            }
        }
    } catch (e)
    {
        Console.Warning("Forums load error: " + e);
    }
    // Fallback if anything went wrong
    Forums = { nextThreadId: 1, data: [] };
    return false;
}

/* --------------------------- Helpers --------------------------- */
function getBoolTag(p, k) { return ("" + (p.GetTag(k) || "")) === "1"; }
function setBoolTag(p, k, v) { p.SetTag(k, v ? "1" : ""); }

function _watchedGetList(pUser)
{
    var raw = "" + (pUser.GetTag("Forums_Watched") || "");
    if (!raw.length) return [];
    var parts = raw.split(",");
    var out = [];
    for (var i = 0; i < parts.length; i++)
    {
        var n = parseInt(parts[i], 10);
        if (!isNaN(n) && out.indexOf(n) === -1) out.push(n);
    }
    return out;
}
function isWatched(pUser, tid)
{
    var lst = _watchedGetList(pUser);
    return lst.indexOf((tid | 0)) !== -1;
}
function _watchedGetList(pUser)
{
    var raw = "" + (pUser.GetTag("Forums_Watched") || "");
    if (!raw.length) return [];
    var parts = raw.split(",");
    var out = [];
    for (var i = 0; i < parts.length; i++)
    {
        var n = parseInt(parts[i], 10);
        if (!isNaN(n) && out.indexOf(n) === -1) out.push(n);
    }
    return out;
}
function _watchedSetList(pUser, arr)
{
    pUser.SetTag("Forums_Watched", (arr && arr.length) ? arr.join(",") : "");
}
function isWatched(pUser, tid)
{
    var lst = _watchedGetList(pUser);
    return lst.indexOf((tid | 0)) !== -1;
}
function toggleWatch(pUser, tid)
{
    tid |= 0;
    var lst = _watchedGetList(pUser);
    var i = lst.indexOf(tid);
    if (i === -1) lst.push(tid);
    else lst.splice(i, 1);
    _watchedSetList(pUser, lst);
    return (i === -1); // true if now watched
}


function getIntTag(p, key, defVal)
{
    var v = p.GetTag(key);
    var n = parseInt(v, 10);
    return isNaN(n) ? defVal : n;
}

function findLastOwnPostIndex(t, pUser)
{
    var ps = t.posts || [];
    for (var i = ps.length - 1; i >= 0; i--)
    {
        if ((ps[i].authorSer | 0) === (pUser.serial | 0))
            return i;
    }
    return -1;
}

function makeQuoteBlock(author, whenMs, body)
{
    var who = ("" + (author || "Unknown"));
    var when = new Date(whenMs || 0).toUTCString();
    var lines = ("" + (body || "")).replace(/\r/g, "").split("\n");
    var out = [];
    out.push("[" + who + " | " + when + "]");
    for (var i = 0; i < lines.length; i++) out.push("> " + lines[i]);
    out.push(""); // one blank line after quote
    return out.join("\n");
}

function visibleThreadsFor(pUser)
{
    var out = [];
    for (var i = 0; i < Forums.data.length; i++)
    {
        var t = Forums.data[i];
        if (t.deleted) continue;                 // hide deleted
        if (t.staff && !isStaff(pUser)) continue;// hide staff threads for non-staff
        out.push(t);
    }
    out.sort(byStickyAnnounceThenDate);
    return out;
}

function nowUTC() { return Math.floor((new Date()).getTime()); }
function isStaff(pChar)
{
    return pChar.isGM;
} // MVP; refine if needed

function byStickyAnnounceThenDate(a, b)
{
    // emulate DateSort (Announcements before Sticky before Recent)
    function rank(t) { return t.type === "announce" ? 2 : t.type === "sticky" ? 1 : 0; }
    var ra = rank(a), rb = rank(b);
    if (ra !== rb) return rb - ra;
    return (b.lastPostAt | 0) - (a.lastPostAt | 0);
}

function threadById(id)
{
    id |= 0;
    for (var i = 0; i < Forums.data.length; i++) if ((Forums.data[i].id | 0) === id) return Forums.data[i];
    return null;
}

function getThreadStatusIcon(t, pUser)
{
    // Track "seen since last update" via per-player tag
    var lastSeen = parseInt(pUser.GetTag("Forums_Seen_" + t.id), 10) || 0;
    var viewed = lastSeen > 0;
    var viewedUpdate = viewed && (lastSeen >= (t.lastPostAt | 0)); // true if seen after last update

    // Poster check
    var poster = false;
    if (t.posts)
    {
        for (var i = 0; i < t.posts.length; i++)
        {
            if ((t.posts[i].authorSer | 0) === (pUser.serial | 0)) { poster = true; break; }
        }
    }

    // Map like RunUO
    // Locked overrides
    if (t.locked) return 4017;

    // Poster + up-to-date
    if (poster && viewedUpdate) return 4011;
    // Poster (but not up-to-date)
    if (poster) return 4012;
    // Viewed and up-to-date
    if (viewedUpdate) return 4014;
    // Viewed but not up-to-date
    if (viewed) return 4015;

    // Default (unseen)
    return 4014;
}

function isUnreadFor(pUser, t)
{
    var lastSeen = parseInt(pUser.GetTag("Forums_Seen_" + (t.id | 0)), 10) || 0;
    return lastSeen < ((t.lastPostAt | 0) || 0);
}

function onUseChecked(pUser, iBoard)
{
    var pSock = pUser.socket;

    // Tags are strings; treat any non-empty value as "initialized"
    var inited = ("" + (iBoard.GetTag("Init") || "")).length > 0;

    if (!inited)
    {
        // Ensure an in-memory model exists before first save
        if (!Forums || !Forums.data) Forums = { nextThreadId: 1, data: [] };

        // Create Forums/forums.json on first use (folder must already exist)
        saveForums();

        // Mark this board initialized (string tag)
        iBoard.SetTag("Init", "1");
    }

    ensureForumsLoaded();
    openForumList(pSock, 0);
    return false;
}

/* --------------------------- Gumps --------------------------- */
function openForumList(socket, page)
{
    ensureForumsLoaded();
    var pUser = socket.currentChar;
    if (!ValidateObject(pUser))
        return;

    var threads = visibleThreadsFor(pUser);                // <-- filtered & sorted
    var watchedOnly = getBoolTag(pUser, "Forums_FilterWatched");
    if (watchedOnly)
    {
        var w = _watchedGetList(pUser);
        // Filter to watched IDs only
        var filtered = [];
        for (var i = 0; i < threads.length; i++)
        {
            if (w.indexOf(threads[i].id | 0) !== -1) filtered.push(threads[i]);
        }
        threads = filtered;
    }

    var total = threads.length;
    var pages = Math.max(1, Math.ceil(total / ROWS_PER_PAGE));
    page = Math.max(0, Math.min(page | 0, pages - 1));

    pUser.SetTag("Forums_View", "list");
    pUser.SetTag("Forums_Page", page);

    var start = page * ROWS_PER_PAGE, end = Math.min(total, start + ROWS_PER_PAGE);

    var g = new Gump;
    g.AddBackground(71, 53, 618, 489, 9200);
    g.AddGump(631, 9, 5536);
    g.AddTiledGump(80, 63, 549, 63, 2624);
    g.AddCheckerTrans(80, 63, 549, 63);
    g.AddGump(655, 368, 10411);
    g.AddGump(657, 181, 10410);
    g.AddGump(21, 481, 10402);

    g.AddText(87, 68, 0x481, "Forums (" + total + " threads)  Page " + (page + 1) + "/" + pages);

    // New Thread
    g.AddButton(94, 507, 4029, 1, 0, BID_NEWTHREAD);
    g.AddText(131, 507, 0x481, "New Thread");

    g.AddButton(240, 507, 4029, 1, 0, BID_TOGGLE_FILTER_WATCHED);
    g.AddText(272, 507, 0x481, watchedOnly ? "Showing: Watched" : "Showing: All");

    g.AddButton(430, 507, 4029, 1, 0, BID_MARK_ALL_READ);
    g.AddText(462, 507, 0x481, "Mark All Read");

    g.AddText(80, 124, 0x481, "   Posts       Subject                           Views/Replys");
    g.AddTiledGump(80, 144, 491, 354, 2624);
    g.AddCheckerTrans(80, 144, 491, 354);

    // -------- Listing (RunUO-style layout in your box) --------
    var LIST_X = 80, LIST_Y = 144, LIST_W = 491, LIST_H = 354;
    var ROW_H = 28;

    // Columns under: "   Posts       Subject                           Views/Replys"
    var COL_POSTS_X = LIST_X + 8;        // numeric posts count
    var COL_SUBJECT_BTN_X = LIST_X + 60;        // status icon button
    var COL_SUBJECT_TEXT_X = LIST_X + 88;        // subject text (x+40 from button, like RunUO)
    var COL_VR_X = LIST_X + LIST_W - 90; // views/replies at right

    var rowsDrawn = 0;
    var offsetSkips = 0; // like RunUO's 'offset' for skipped rows (deleted/staff hidden)

    for (var i = start; i < threads.length; i++)
    {
        if (rowsDrawn >= ROWS_PER_PAGE) break;

        var t = threads[i];
        if (t.deleted) { offsetSkips += ROW_H; continue; }

        // Staff-only thread visibility (optional parity with RunUO)
        if (t.staff && !isStaff(pUser)) { offsetSkips += ROW_H; continue; }

        // Row Y (start a little inside the box like their 155 base)
        var y = LIST_Y + 11 + (rowsDrawn * ROW_H);

        // Counts
        var totalPosts = t.posts ? t.posts.length : 0;
        var replies = totalPosts > 0 ? (totalPosts - 1) : 0;
        var vrText = (t.views | 0) + "/" + replies;

        // Subject label with type prefixes (Announcement/Sticky)
        var label = (t.type === "announce" ? "Announcement: " :
            (t.type === "sticky" ? "Sticky: " : (t.staff ? "Staff: " : ""))) +
            (t.subject || "(no subject)");

        // NEW / watched indicators
        var unread = isUnreadFor(pUser, t);
        if (typeof isWatched === "function" && isWatched(pUser, t.id)) label = "[W] " + label;
        if (unread) label = "[NEW] " + label;

        // Choose a hue that stands out for NEW (yellow) vs normal (grey)
        var subjectHue = unread ? 0x34 : 0x481;

        // Compute status button image like RunUO (4011/4012/4014/4015/4017)
        var btnImg = getThreadStatusIcon(t, pUser);

        // Posts column
        g.AddText(COL_POSTS_X + 2, y + 2, 0x481, "" + totalPosts);

        // Subject button + text
        g.AddButton(COL_SUBJECT_BTN_X - 4, y, btnImg, 1, 0, BID_THREAD_OPEN(t.id));
        g.AddText(COL_SUBJECT_TEXT_X + 2, y + 2, subjectHue, label);

        // Views/Replies column
        g.AddText(COL_VR_X + 2, y + 2, 0x481, vrText);

        rowsDrawn++;
    }

    if (total === 0)
    {
        // Friendly first-run message inside the list area
        g.AddText(90, 160, 0x481, "No threads yet.");
        g.AddText(90, 180, 0x481, "Click 'New Thread' to create the first post.");
    }

    // Paging
    if (page < pages - 1)
        g.AddButton(644, 507, BTN, 1, 0, BID_NEXT);
    if (page > 0)
        g.AddButton(621, 507, BTN, 1, 0, BID_PREV);
    g.Send(socket)
    g.Free();
}

function htmlEscape(s) { s = "" + (s || ""); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
function buildThreadHTML(t)
{
    var html = "";
    var posts = t.posts || [];
    for (var i = 0; i < posts.length; i++)
    {
        var p = posts[i] || {};
        var who = (p.authorName && ("" + p.authorName).length) ? p.authorName : "Unknown";
        var when = new Date(p.createdAt || 0).toUTCString();
        var body = htmlEscape("" + (p.body || "")).replace(/\r/g, "").replace(/\n/g, "<BR>");
        html += "<B>" + htmlEscape(who) + "</B> &nbsp; " + when + "<BR>" + body + "<P>";
    }
    if (html === "") html = "(no content)";
    // wrap in a tiny basefont so UO client renders consistent size
    return "<BASEFONT size=3>" + html + "</BASEFONT>";
}

function openThread(socket, threadId)
{
    ensureForumsLoaded();

    var pUser = socket.currentChar;
    if (!ValidateObject(pUser))
        return;

    var t = threadById(threadId);
    if (!t || t.deleted)
    {
        socket.SysMessage("Thread missing.");
        openForumList(socket, 0);
        return;
    }

    t.views = (t.views | 0) + 1;
    // mark this player as having seen the thread now
    pUser.SetTag("Forums_Seen_" + t.id, "" + nowUTC());
    pUser.SetTag("Forums_View", "thread");
    pUser.SetTag("Forums_ThreadId", t.id);

    var g = new Gump;

    // Frame & close button (RunUO coords)
    g.AddBackground(9, 15, 477, 450, 9200);
    g.AddButton(450, 20, 1151, 1, 0, BID_CANCEL); // "Close" / Back

    // Labels
    g.AddText(22, 24, 0x481, "Subject:");

    if (!t.deleted)
    {
        var creatorName = (t.authorName && ("" + t.authorName).length) ? t.authorName : "unknown author";
        g.AddText(22, 64, 0x481, "Author: " + creatorName);
        // Date = thread create datetime
        var createdStr = new Date(t.createdAt || 0).toUTCString();
        g.AddText(202, 64, 0x481, "Date: " + createdStr);
    }

    // Subject bar (tiled + alpha/transparent)
    g.AddTiledGump(21, 44, 447, 21, 2624);
    g.AddCheckerTrans(21, 44, 446, 20);

    // Subject text, like AddHtml in RunUO (single line area)
    var subj = "" + (t.subject || "(no subject)");
    g.AddHTMLGump(23, 46, 446, 20, false, false, "<BASEFONT color=#ffffff size=7>" + htmlEscape(subj + (t.locked ? " [LOCKED]" : "")) + "</BASEFONT>");

    // Content panel (tiled + alpha/transparent)
    g.AddTiledGump(22, 88, 446, 302, 2624);
    g.AddCheckerTrans(22, 87, 446, 302);

    // Thread content with scrollbar (aggregate posts)
    var threadHTML = buildThreadHTML(t);
    g.AddHTMLGump(24, 89, 446, 302, false, true, threadHTML);

    // Bottom actions (RunUO-style positions)
    if (!t.locked)
    {
        g.AddButton(22, 395, 4029, 1, 0, BID_REPLY_OPEN(t.id));
        g.AddText(54, 395, 0x481, "Reply");
        g.AddButton(98, 395, 4029, 1, 0, BID_REPLY_QUOTE);
        g.AddText(130, 395, 0x481, "Reply (Quote)");
    }

    // Watch / Unwatch toggle
    var watched = isWatched(pUser, t.id);
    g.AddButton(22, 420, 4029, 1, 0, BID_TOGGLE_WATCH);
    g.AddText(54, 422, 0x481, watched ? "Unwatch" : "Watch");

    if (isStaff(pUser) || (pUser.serial === t.authorSer))
    {
        // Delete
        g.AddButton(240, 395, 4020, 1, 0, BID_TOGGLE_DELETE(t.id));
        g.AddText(270, 395, 0x481, "Delete");

        // Lock / Unlock
        g.AddButton(320, 395, 4017, 1, 0, BID_TOGGLE_LOCK(t.id));
        g.AddText(350, 396, 0x481, t.locked ? "Unlock" : "Lock");
    }


    // Show “Edit Last” if player authored something here OR is staff
    var canEdit =
        isStaff(pUser) || (findLastOwnPostIndex(t, pUser) >= 0);

    if (canEdit)
    {
        g.AddButton(390, 395, 4029, 1, 0, BID_EDIT_LAST);
        g.AddText(420, 395, 0x481, "Edit Last");
    }

    g.Send(socket);
    g.Free();
}

var BID_SUBMIT_LONG = 5006; // treat same as submit for now

var BID_SUBMIT_LONG = 5006; // treat same as submit for now

var CB_STAFF = 9001;
var CB_STICKY = 9002;
var CB_ANNOUNCE = 9003;

function gdHasButton(gd, targetId)
{
    // probe a small, safe range; stops when index runs out
    for (var i = 0; i < 16; i++)
    {
        var v = gd.getButton(i);
        if (v === null || v === undefined) break;   // no more entries
        if ((v | 0) === (targetId | 0)) return true;    // found our checkbox/radio id
    }
    return false;
}

function openPostGump(socket, mode, threadId)
{
    ensureForumsLoaded();

    var pUser = socket.currentChar;
    if (!ValidateObject(pUser))
        return;

    pUser.SetTag("Forums_View", "post");
    pUser.SetTag("Forums_PostMode", mode);
    pUser.SetTag("Forums_ThreadId", threadId | 0);

    // For replies we show the existing subject line
    var subjText = "(no subject)";
    var prefillBody = " ";
    if (mode !== "new")
    {
        var te = threadById(threadId);
        if (te && te.subject)
            subjText = "" + te.subject;

        var q = pUser.GetTag("Forums_QuotePayload");
        if (q && ("" + q).length)
            prefillBody = "" + q;

        pUser.SetTag("Forums_QuotePayload", null);

    }

    if (mode === "edit")
    {
        var tedit = threadById(threadId);
        var idx = getIntTag(pUser, "Forums_EditPostIdx", -1);
        if (tedit && tedit.posts && idx >= 0 && idx < tedit.posts.length)
        {
            prefillBody = "" + (tedit.posts[idx].body || " ");
            // Subject stays static during edit
        }
    }

    var g = new Gump;
    // Frame like RunUO
    g.AddBackground(10, 15, 477, 467, 9200);

    // ---- AddText FIRST (defines TextIDs 0..N for entries) ----
    var tid = 0;

    // Title
    g.AddText(202, 20, 0x481,
        (mode === "new") ? "New Post" :
            (mode === "edit") ? "Edit Post" : "Post Reply");
    tid++;

    // Labels
    g.AddText(22, 38, 0x481, "Subject:");
    tid++;
    g.AddText(22, 78, 0x481, "Content:");
    tid++;
    g.AddText(202, 78, 0x481, "Date:");
    tid++;

    // Date text (UTC like their sample intent)
    var dateStr = new Date(nowUTC()).toUTCString();
    g.AddText(242, 78, 0x481, dateStr);
    tid++;

    // Subject & Content panels (tiled + alpha/transparent)
    g.AddTiledGump(21, 58, 447, 21, 2624);
    g.AddCheckerTrans(21, 58, 446, 20);
    g.AddTiledGump(22, 102, 446, 302, 2624);
    g.AddCheckerTrans(22, 101, 446, 302);

    // Buttons (lower-left), 4029 like RunUO
    g.AddButton(22, 409, 4029, 1, 0, BID_SUBMIT);
    g.AddText(55, 409, 0x481, (mode === "edit") ? "Save" : "Post");
    tid++;

    g.AddButton(22, 434, 4029, 1, 0, BID_SUBMIT_LONG);
    g.AddText(55, 434, 0x481, (mode === "edit") ? "Save" : "Long Post");
    tid++;

    // Staff flags (optional UI like RunUO)
    if (mode === "new" && isStaff(pUser))
    {
        // UOX3 checkbox signature: (top, left, checkImage, defaultStatus, id)
        g.AddCheckbox(279, 411, 2706, 0, CB_ANNOUNCE);
        g.AddText(186, 412, 0x481, "Announcement");
        tid++;
        g.AddCheckbox(350, 411, 2706, 0, CB_STICKY);
        g.AddText(305, 412, 0x481, "Sticky");
        tid++;
        g.AddCheckbox(449, 411, 2706, 0, CB_STAFF);
        g.AddText(378, 412, 0x481, "Staff Only");
        tid++;
        // (Hook-up for saving these flags can be added in onGumpPress later)
    }

    // Subject field: entry if new thread, else static text
    if (mode === "new")
    {
        // This text entry will be getEdit(0)
        g.AddTextEntry(22, 59, 442, 20, 0x0026, 1, tid, " "); // subject
        tid++;
    }
    else
    {
        // Show the existing subject (static)
        g.AddText(22, 59, 0x481, subjText);
        tid++;
    }

    // Content text entry
    // new thread => this will be getEdit(1)
    // reply      => this will be getEdit(0)
    if (mode === "new")
    {
        g.AddTextEntry(22, 102, 446, 304, 0x0026, 1, tid, " "); // body default blank
    }
    else
    {
        g.AddTextEntry(22, 102, 446, 304, 0x0026, 1, tid, prefillBody);
    }
    //g.AddTextEntry(22, 102, 446, 304, 0x0026, 1, tid, " "); // body

    g.Send(socket);
    g.Free();
}

/* --------------------------- Gump Press --------------------------- */
function onGumpPress(pSock, buttonID, gumpData)
{
    var p = pSock.currentChar;
    if (!ValidateObject(p)) return;

    var view = p.GetTag("Forums_View") || "list";

    // List level
    if (view === "list")
    {
        var page = parseInt(p.GetTag("Forums_Page"), 10) || 0;
        if (buttonID === BID_NEWTHREAD) { openPostGump(pSock, "new", 0); return; }
        if (buttonID === BID_NEXT) { openForumList(pSock, page + 1); return; }
        if (buttonID === BID_PREV) { openForumList(pSock, page - 1); return; }
        if (buttonID === BID_TOGGLE_FILTER_WATCHED)
        {
            var cur = getBoolTag(p, "Forums_FilterWatched");
            setBoolTag(p, "Forums_FilterWatched", !cur);
            openForumList(pSock, 0);
            return;
        }
        if (buttonID === BID_MARK_ALL_READ)
        {
            var threads = visibleThreadsFor(p);
            // Respect watched filter if active
            if (getBoolTag(p, "Forums_FilterWatched"))
            {
                var w = _watchedGetList(p);
                var tmp = [];
                for (var i = 0; i < threads.length; i++)
                {
                    if (w.indexOf(threads[i].id | 0) !== -1) tmp.push(threads[i]);
                }
                threads = tmp;
            }
            // Mark seen time to lastPostAt for each shown thread
            for (var j = 0; j < threads.length; j++)
            {
                var t = threads[j];
                p.SetTag("Forums_Seen_" + t.id, "" + (t.lastPostAt | 0));
            }
            pSock.SysMessage("All threads on this view marked as read.");
            openForumList(pSock, parseInt(p.GetTag("Forums_Page"), 10) || 0);
            return;
        }
        if (buttonID >= 10000 && buttonID < 20000)
        {
            var tid = (buttonID - 10000) | 0;
            openThread(pSock, tid); return;
        }
        return;
    }

    // Thread level
    if (view === "thread")
    {
        var tid = parseInt(p.GetTag("Forums_ThreadId"), 10) || 0;
        if (buttonID === BID_CANCEL) { openForumList(pSock, parseInt(p.GetTag("Forums_Page"), 10) || 0); return; }
        if (buttonID === BID_EDIT_LAST)
        {
            var tt = threadById(tid);
            if (!tt || tt.deleted) { pSock.SysMessage("Thread missing."); openForumList(pSock, 0); return; }

            var idx = -1;
            if (isStaff(p))
            {
                // staff edits the last post in the thread
                idx = (tt.posts && tt.posts.length) ? (tt.posts.length - 1) : -1;
            } else
            {
                // author edits their last post
                idx = findLastOwnPostIndex(tt, p);
            }

            if (idx < 0) { pSock.SysMessage("No post to edit."); return; }

            // allow staff to edit even if locked; block non-staff on locked threads
            if (tt.locked && !isStaff(p)) { pSock.SysMessage("Thread is locked."); return; }

            p.SetTag("Forums_EditPostIdx", "" + idx);
            openPostGump(pSock, "edit", tid);
            return;
        }
        if (buttonID === BID_TOGGLE_WATCH)
        {
            var tt = threadById(tid);
            if (!tt || tt.deleted) { pSock.SysMessage("Thread missing."); openForumList(pSock, 0); return; }
            // Optional: block staff-only visibility for non-staff (already filtered elsewhere)
            var nowWatched = toggleWatch(p, tid);
            pSock.SysMessage(nowWatched ? "Thread added to your watched list." : "Thread removed from your watched list.");
            openThread(pSock, tid);
            return;
        }
        if (buttonID === BID_REPLY_QUOTE)
        {
            var tt = threadById(tid);
            if (!tt || tt.locked || tt.deleted) { pSock.SysMessage("Cannot quote here."); return; }
            var posts = tt.posts || [];
            if (posts.length === 0) { openPostGump(pSock, "reply", tid); return; }

            var last = posts[posts.length - 1] || {};
            var q = makeQuoteBlock(last.authorName, last.createdAt, last.body);
            // stash the quote into a temp tag; open reply
            p.SetTag("Forums_QuotePayload", q);
            openPostGump(pSock, "reply", tid);
            return;
        }
        if (buttonID >= 20000 && buttonID < 30000) { openPostGump(pSock, "reply", tid); return; }
        if (buttonID >= 30000 && buttonID < 31000)
        {
            var t = threadById(tid); if (!t) { openForumList(pSock, 0); return; }
            if (!(isStaff(p) || p.serial === t.authorSer)) { pSock.SysMessage("Not allowed."); return; }
            t.locked = !t.locked; saveForums(); openThread(pSock, tid); return;
        }
        if (buttonID >= 31000 && buttonID < 32000)
        {
            var t2 = threadById(tid); if (!t2) { openForumList(pSock, 0); return; }
            if (!(isStaff(p) || p.serial === t2.authorSer)) { pSock.SysMessage("Not allowed."); return; }

            t2.deleted = !t2.deleted;
            saveForums();

            // Recompute pages from *filtered* list and clamp current page
            var curPage = parseInt(p.GetTag("Forums_Page"), 10) || 0;
            var visCnt = visibleThreadsFor(p).length;
            var pages = Math.max(1, Math.ceil(visCnt / ROWS_PER_PAGE));
            if (curPage > pages - 1) curPage = pages - 1;

            openForumList(pSock, curPage);
            return;
        }
        return;
    }

    // Post composer
    if (view === "post")
    {
        if (buttonID === BID_CANCEL)
        {
            var mode = p.GetTag("Forums_PostMode");
            var tid = parseInt(p.GetTag("Forums_ThreadId"), 10) || 0;
            if (mode === "edit")
            {
                p.SetTag("Forums_EditPostIdx", null); // clear
                openThread(pSock, tid);
            } else if (mode === "reply")
            {
                openThread(pSock, tid);
            } else
            {
                openForumList(pSock, parseInt(p.GetTag("Forums_Page"), 10) || 0);
            }
            return;
        }

        if (buttonID === BID_SUBMIT || buttonID === BID_SUBMIT_LONG)
        {
            var mode = p.GetTag("Forums_PostMode");
            var tid = parseInt(p.GetTag("Forums_ThreadId"), 10) || 0;

            // Staff flags (only when creating NEW thread)
            var staffOnly = false, sticky = false, announce = false;
            if (mode === "new" && isStaff(p))
            {
                staffOnly = gdHasButton(gumpData, CB_STAFF);
                sticky = gdHasButton(gumpData, CB_STICKY);
                announce = gdHasButton(gumpData, CB_ANNOUNCE);

                // If both were ticked, Announcement wins (like forum UX usually does)
                if (announce) sticky = false;
            }

            // Read entries by position:
            // new thread => subject=getEdit(0), body=getEdit(1)
            // reply      => body=getEdit(0)
            var subject = null;
            var body = "";

            if (mode === "new")
            {
                subject = "" + (gumpData.getEdit(0) || "");
                subject = subject.replace(/\r/g, "");
                body = "" + (gumpData.getEdit(1) || "");
            }
            else
            {
                body = "" + (gumpData.getEdit(0) || "");
            }
            body = body.replace(/\r/g, "");

            // Validate
            if (mode === "new" && subject.length < 3) { pSock.SysMessage("Subject too short."); return; }
            if (body.replace(/\s+/g, "").length < 1) { pSock.SysMessage("Message too short."); return; }

            if (mode === "new")
            {
                var t = {
                    id: (Forums.nextThreadId | 0),
                    subject: subject,
                    authorSer: p.serial, authorName: p.name,
                    createdAt: nowUTC(), lastPostAt: nowUTC(),
                    locked: false, deleted: false,
                    staff: (isStaff(p) && staffOnly) ? true : false,
                    type: (announce && isStaff(p)) ? "announce" : (sticky && isStaff(p)) ? "sticky" : "regular",
                    views: 0,
                    posts: []
                };
                Forums.nextThreadId++;
                t.posts.push({
                    id: 1, authorSer: p.serial, authorName: p.name,
                    body: body, createdAt: nowUTC(), editedAt: null
                });
                Forums.data.push(t);
                saveForums();
                openThread(pSock, t.id);
            }
            else if (mode === "edit")
            {
                var t3 = threadById(tid);
                if (!t3 || t3.deleted) { pSock.SysMessage("Thread missing."); openForumList(pSock, 0); return; }

                // staff may edit even if locked; authors cannot
                if (t3.locked && !isStaff(p)) { pSock.SysMessage("Thread is locked."); openThread(pSock, t3.id); return; }

                var idx = getIntTag(p, "Forums_EditPostIdx", -1);
                if (!(t3.posts && idx >= 0 && idx < t3.posts.length))
                {
                    pSock.SysMessage("Edit target missing."); openThread(pSock, t3.id); return;
                }

                var target = t3.posts[idx];
                if (!isStaff(p) && ((target.authorSer | 0) !== (p.serial | 0)))
                {
                    pSock.SysMessage("Not your post."); openThread(pSock, t3.id); return;
                }

                target.body = body;
                target.editedAt = nowUTC();

                // bump thread activity timestamp
                t3.lastPostAt = nowUTC();

                saveForums();
                p.SetTag("Forums_EditPostIdx", null); // clear
                openThread(pSock, t3.id);
            }
            else
            {
                var t3 = threadById(tid);
                if (!t3 || t3.deleted) { pSock.SysMessage("Thread missing."); openForumList(pSock, 0); return; }
                if (t3.locked) { pSock.SysMessage("Thread is locked."); openThread(pSock, t3.id); return; }
                t3.posts.push({
                    id: t3.posts.length + 1,
                    authorSer: p.serial, authorName: p.name,
                    body: body, createdAt: nowUTC(), editedAt: null
                });
                t3.lastPostAt = nowUTC();
                saveForums();
                openThread(pSock, t3.id);
            }
            return;
        }
    }
}
Post Reply