- 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
}
{
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
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> " + 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;
}
}
}
// 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
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> " + 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;
}
}
}