Skip to content

Commit 221bbdd

Browse files
ryanbarlow97claude
andcommitted
Show chat channels and hide the filter strip's scrollbar
RPCharacters channel chat sent by command (/looc, /fooc, /gooc, /ooc, /me, /shout…) is now chat for admins: tagged with its channel in the in-game colour, under the Chat filter, and audited like chat. Moderators still get only the command word, shown as "Spoke in LOOC". Chat rows are one line of text instead of "Said" over a quote. The site-wide themed scrollbar rule is unlayered, so it beat the Tailwind utility meant to hide the filter strip's bar on iOS; a plain .scroll-strip class in globals.css does it now, and the strip no longer runs past the panel's edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ca38309 commit 221bbdd

7 files changed

Lines changed: 133 additions & 14 deletions

File tree

‎backend/src/auth/test_players.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,27 @@ def test_admins_see_chat_and_whole_commands_and_it_is_audited(app, env, world, c
319319
assert "docks" not in rows[0]["detail_json"] and "private" not in rows[0]["detail_json"]
320320

321321

322+
def test_channel_commands_are_chat_for_admins_and_a_channel_name_for_mods(app, env, world, coreprotect):
323+
hazel = world["hazel"]
324+
coreprotect.command(hazel, 171, "/looc where is the beagle")
325+
coreprotect.command(hazel, 172, "/RPCharacters:FOOC raid at dusk")
326+
coreprotect.command(hazel, 173, "/me waves")
327+
coreprotect.command(hazel, 174, "/home")
328+
_, token = account(env, "boss", "admin")
329+
feed = client(app, token).get(f"/admin/players/{HAZEL}/activity").json()
330+
said = [(e["kind"], e["channel"], e["message"]) for e in feed["entries"] if e["kind"] == "chat"]
331+
assert said == [("chat", "Emote", "waves"), ("chat", "FOOC", "raid at dusk"), ("chat", "LOOC", "where is the beagle")]
332+
assert [e["target"] for e in feed["entries"] if e["kind"] == "command"] == ["/home", "/msg Bob a private thing"]
333+
only_chat = client(app, token).get(f"/admin/players/{HAZEL}/activity", params={"kinds": "chat"}).json()
334+
assert [e["channel"] for e in only_chat["entries"]] == ["Emote", "FOOC", "LOOC"]
335+
assert json.loads(message_audits(env)[0]["detail_json"])["rows"] == 5
336+
337+
mod = client(app, staff(env, "mod")).get(f"/admin/players/{HAZEL}/activity").json()
338+
commands = [(e["target"], e["channel"]) for e in mod["entries"] if e["kind"] == "command"]
339+
assert commands == [("/home", None), ("/me", "Emote"), ("/RPCharacters:FOOC", "FOOC"), ("/looc", "LOOC"), ("/msg", None)]
340+
assert "beagle" not in str(mod) and "dusk" not in str(mod) and "waves" not in str(mod)
341+
342+
322343
def test_pages_without_messages_are_not_audited(app, env, world):
323344
_, token = account(env, "boss", "admin")
324345
feed = client(app, token).get(f"/admin/players/{HAZEL}/activity", params={"kinds": "block,session"}).json()

‎backend/src/coreprotect/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ Activity responses also return `server_label` and `map_world`, so the panel
9696
calls the map's world by its name (Vardera). The panel merges consecutive
9797
changes to the same block within a minute into one row; the API does not.
9898

99+
RPCharacters chat sent by command (`/looc hi`, `/fooc`, `/me`, `/shout`…;
100+
the aliases are `CHANNELS` in `activity.py`, from Main's `chat.yml`) is chat:
101+
for admins it comes back as kind `chat` with its `channel` and the text after
102+
the command, under the Chat filter and audited like chat; moderators still get
103+
only the command word, with the `channel` named. Plain chat goes to the
104+
player's current channel, which CoreProtect does not record, so it has none.
105+
99106
Sessions are rebuilt from login, logout and ping rows; see `sessions.py` for how crashed sessions end.
100107

101108
Movement is a player's session rows in a window (up to 7 days for one

‎backend/src/coreprotect/activity.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@
4141
)
4242
_FULL_COMMAND_TEXT = f"CASE WHEN {_SKILL} THEN substr({_CAST}, 1, 80) ELSE substr(message, 1, {MESSAGE_MAX}) END"
4343

44+
# RPCharacters' chat channels, as sent by command (`/looc hi`): alias → label,
45+
# from Main's plugins/RPCharacters/chat.yml. Plain chat goes to the player's
46+
# current channel, which CoreProtect does not record.
47+
CHANNELS = {
48+
"rp": "RP", "shout": "Shout", "yell": "Yell", "y": "Yell", "whisper": "Whisper", "wh": "Whisper",
49+
"me": "Emote", "dm": "DM", "narrate": "DM", "scene": "Scene",
50+
"ooc": "OOC", "looc": "LOOC", "gooc": "GOOC", "fooc": "FOOC", "pooc": "POOC", "rooc": "ROOC",
51+
"admin": "Admin", "a": "Admin", "helper": "Helper", "h": "Helper",
52+
}
53+
_FIRST_WORD = "lower(CASE WHEN instr(message, ' ') > 0 THEN substr(message, 1, instr(message, ' ') - 1) ELSE message END)"
54+
_CHANNEL = "{} IN ({})".format(_FIRST_WORD, ", ".join(
55+
f"'/{prefix}{alias}'" for alias in CHANNELS for prefix in ("", "rpcharacters:")))
56+
57+
58+
def channel_of(command: str | None) -> str | None:
59+
"""The chat channel a command speaks in (`/looc hi` → LOOC), or None."""
60+
word = (command or "").split(" ", 1)[0].lower().removeprefix("/").removeprefix("rpcharacters:")
61+
return CHANNELS.get(word)
62+
4463

4564
@dataclass(frozen=True)
4665
class Source:
@@ -53,6 +72,8 @@ class Source:
5372
full_columns: str | None = None
5473
# Read only when the caller may read messages.
5574
sensitive: bool = False
75+
# Kinds used instead when the caller may read messages.
76+
full_kinds: dict[str, str] | None = None
5677

5778

5879
SOURCES = (
@@ -71,10 +92,15 @@ class Source:
7192
f"{_SKILL} AS is_skill, {_TELEPORT} > 0 AS teleport, {_COMMAND_TEXT} AS text, 0 AS truncated",
7293
{"skill": _SKILL, "command": f"NOT {_SKILL}"},
7394
full_columns=(f"{_SKILL} AS is_skill, {_TELEPORT} > 0 AS teleport, {_FULL_COMMAND_TEXT} AS text, "
74-
f"NOT {_SKILL} AND length(message) > {MESSAGE_MAX} AS truncated")),
95+
f"NOT {_SKILL} AND length(message) > {MESSAGE_MAX} AS truncated"),
96+
# Those who may read chat see channel commands as chat (source 10), not here.
97+
full_kinds={"skill": _SKILL, "command": f"NOT {_SKILL} AND NOT {_CHANNEL}"}),
7598
Source(8, "session", "co_session", "action", {"session": "action IN (0, 1)"}),
7699
Source(9, "chat", "co_chat", f"substr(message, 1, {MESSAGE_MAX}) AS text, length(message) > {MESSAGE_MAX} AS truncated",
77100
{"chat": "1"}, sensitive=True),
101+
Source(10, "channel", "co_command",
102+
f"substr(message, 1, {MESSAGE_MAX}) AS text, length(message) > {MESSAGE_MAX} AS truncated",
103+
{"chat": _CHANNEL}, sensitive=True),
78104
)
79105
# Kinds anyone with view_players may ask for, and the extra ones for view_player_messages.
80106
KINDS = tuple(dict.fromkeys(kind for source in SOURCES if not source.sensitive for kind in source.kinds))
@@ -190,7 +216,8 @@ def fetch(reader: Reader, maps: Maps, ids: list[int], kinds: tuple[str, ...], cu
190216
for source in SOURCES:
191217
if source.table not in maps.tables or (source.sensitive and not full_text):
192218
continue
193-
conditions = [cond for kind, cond in source.kinds.items() if kind in kinds]
219+
source_kinds = (source.full_kinds if full_text else None) or source.kinds
220+
conditions = [cond for kind, cond in source_kinds.items() if kind in kinds]
194221
if not conditions:
195222
continue
196223
upper = _upper(source, cursor)
@@ -332,6 +359,7 @@ def _entry(source: Source, row: dict, maps: Maps, victims: dict) -> dict:
332359
"truncated": False,
333360
"rolled_back": _rollback(row.get("rolled_back")),
334361
"target_info": None,
362+
"channel": None,
335363
}
336364
if source.name == "block":
337365
if action == 3:
@@ -372,10 +400,15 @@ def _entry(source: Source, row: dict, maps: Maps, victims: dict) -> dict:
372400
entry.update(kind="skill", verb="teleported with" if row["teleport"] else "cast",
373401
target=(row["text"] or "").strip() or "a skill")
374402
else:
375-
entry.update(kind="command", verb="ran", target=row["text"] or "/", truncated=bool(row["truncated"]))
403+
# Without full text this is just `/looc`: which channel, never what was said.
404+
entry.update(kind="command", verb="ran", target=row["text"] or "/", truncated=bool(row["truncated"]),
405+
channel=channel_of(row["text"]))
376406
elif source.name == "chat":
377407
entry.update(kind="chat", verb="said", target=None, message=row["text"] or "",
378408
truncated=bool(row["truncated"]))
409+
elif source.name == "channel":
410+
entry.update(kind="chat", verb="said", target=None, channel=channel_of(row["text"]),
411+
message=(row["text"] or "").partition(" ")[2].strip(), truncated=bool(row["truncated"]))
379412
else:
380413
entry.update(kind="session", verb="logged in" if action == 1 else "logged out", target=None)
381414
return entry

‎frontend/app/components/admin/PlayerActivity.tsx‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ export const KIND_LABELS: Record<string, string> = {
3939

4040
const SOURCE_LABELS: Record<string, string> = { mmoitems: "MMOItems", mythicmobs: "MythicMobs" };
4141

42+
// Each channel in the colour its in-game tag uses (RPCharacters' chat.yml).
43+
const CHANNEL_COLOURS: Record<string, string> = {
44+
LOOC: "#55c455", OOC: "#7f8cff", GOOC: "#ffaa00", FOOC: "#55e0e0", POOC: "#ff77ff", ROOC: "#ff77ff",
45+
Admin: "#ff6b6b", Helper: "#ff77ff", Shout: "#ffaa00", Yell: "#ff6b6b", Whisper: "#55aaff", Emote: "#ff77ff",
46+
};
47+
48+
function ChannelTag({ channel }: { channel: string }) {
49+
const colour = CHANNEL_COLOURS[channel] ?? "var(--tfmc-mist)";
50+
return (
51+
<span
52+
className="mr-1.5 inline-block rounded-full border px-1.5 align-[1px] text-[10px] font-semibold uppercase tracking-wide"
53+
style={{ color: colour, borderColor: `color-mix(in srgb, ${colour} 45%, transparent)` }}
54+
>
55+
{channel}
56+
</span>
57+
);
58+
}
59+
4260
const strongClass = "font-semibold text-[var(--tfmc-cream)]";
4361
const badgeClass =
4462
"ml-1.5 inline-block rounded-full border border-[color-mix(in_srgb,var(--tfmc-cream)_22%,transparent)] px-1.5 align-[1px] text-[10px] font-medium uppercase tracking-wide text-[var(--tfmc-stone)]";
@@ -92,7 +110,14 @@ function Thing({ entry, count }: { entry: ActivityEntry; count: number }) {
92110

93111
function Sentence({ entry, count }: { entry: ActivityEntry; count: number }) {
94112
const verb = capital(entry.verb);
95-
if (entry.kind === "chat") return <>{verb}</>;
113+
if (entry.kind === "command" && entry.channel) {
114+
// A moderator's view of channel chat: where they spoke, not what.
115+
return (
116+
<>
117+
Spoke in <ChannelTag channel={entry.channel} />
118+
</>
119+
);
120+
}
96121
if (entry.kind === "command") {
97122
return (
98123
<>
@@ -153,16 +178,20 @@ function Row({ row, names }: { row: ActivityRow; names: WorldNames }) {
153178
<li className="grid grid-cols-[3rem_minmax(0,1fr)] gap-x-3 py-2.5">
154179
<Clock at={entry.time} />
155180
<div className="min-w-0">
156-
<p className="break-words text-[var(--tfmc-mist)]">
157-
<Sentence entry={entry} count={count} />
158-
{entry.truncated ? <span className="text-[var(--tfmc-stone)]"> (cut short)</span> : null}
159-
{entry.rolled_back ? <Rollback text={entry.rolled_back} /> : null}
160-
</p>
161181
{entry.kind === "chat" ? (
162-
<blockquote className="mt-1 break-words rounded-sm border-l-2 border-[color-mix(in_srgb,var(--tfmc-accent)_55%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-cream)_5%,transparent)] px-2.5 py-1 text-[var(--tfmc-cream)]">
163-
{entry.message}
164-
</blockquote>
165-
) : null}
182+
<p className="break-words border-l-2 border-[color-mix(in_srgb,var(--tfmc-cream)_22%,transparent)] pl-2.5 text-[var(--tfmc-cream)]">
183+
<span className="sr-only">Said </span>
184+
{entry.channel ? <ChannelTag channel={entry.channel} /> : null}
185+
<span>{entry.message}</span>
186+
{entry.truncated ? <span className="text-[var(--tfmc-stone)]"> (cut short)</span> : null}
187+
</p>
188+
) : (
189+
<p className="break-words text-[var(--tfmc-mist)]">
190+
<Sentence entry={entry} count={count} />
191+
{entry.truncated ? <span className="text-[var(--tfmc-stone)]"> (cut short)</span> : null}
192+
{entry.rolled_back ? <Rollback text={entry.rolled_back} /> : null}
193+
</p>
194+
)}
166195
<p className="mt-0.5 flex flex-wrap items-baseline gap-x-1 text-xs text-[var(--tfmc-stone)]">
167196
{where ? (
168197
<span>
@@ -291,7 +320,7 @@ export default function PlayerActivity({ uuid }: { uuid: string }) {
291320
<div
292321
role="group"
293322
aria-label="Show"
294-
className="-mx-5 mt-3 flex gap-1.5 overflow-x-auto px-5 py-1 [scrollbar-width:none] sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden"
323+
className="scroll-strip mt-3 flex gap-1.5 overflow-x-auto overscroll-x-contain py-1 sm:flex-wrap sm:overflow-visible"
295324
>
296325
<button type="button" aria-pressed={!kinds.length} className={chip(!kinds.length)} onClick={() => choose([])}>
297326
All

‎frontend/app/components/admin/PlayerProfile.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,24 @@ it("shows chat and whole commands to admins, and says views are logged", async (
249249
expect(within(feed).getByText(/Your views of these are logged/)).toBeTruthy();
250250
});
251251

252+
it("tags chat with its channel, and tells moderators only the channel", async () => {
253+
vi.mocked(getPlayerActivity).mockResolvedValue({
254+
entries: [
255+
entry("channel:1", { kind: "chat", verb: "said", target: null, message: "where is the beagle", channel: "LOOC" }),
256+
entry("chat:2", { kind: "chat", verb: "said", target: null, message: "hello" }),
257+
entry("command:3", { kind: "command", verb: "ran", target: "/fooc", channel: "FOOC" }),
258+
],
259+
next: null, searched_to: null, kinds: [...KINDS, "chat"], shows_messages: true, coreprotect: { status: "available" },
260+
});
261+
render(<PlayerProfile uuid={UUID} />);
262+
const feed = await screen.findByRole("region", { name: "Recent activity" });
263+
const looc = await within(feed).findByText("where is the beagle");
264+
expect(looc.parentElement!.textContent).toBe("Said LOOCwhere is the beagle");
265+
expect(within(feed).getByText("hello").parentElement!.textContent).toBe("Said hello");
266+
expect(within(feed).getByText(/Spoke in/).textContent).toBe("Spoke in FOOC");
267+
expect(within(feed).queryByText("/fooc")).toBeNull();
268+
});
269+
252270
it("keeps moderators' note when messages are not included", async () => {
253271
render(<PlayerProfile uuid={UUID} />);
254272
const feed = await screen.findByRole("region", { name: "Recent activity" });

‎frontend/app/internal/globals.css‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ html.map-fullscreen textarea {
7272
height: 8px;
7373
}
7474

75+
/* A sideways strip (the activity filters) that scrolls without a bar. Unlayered
76+
like the rules above, which a Tailwind utility could not override. */
77+
.scroll-strip {
78+
scrollbar-width: none;
79+
}
80+
.scroll-strip::-webkit-scrollbar {
81+
display: none;
82+
}
83+
7584
*::-webkit-scrollbar-track {
7685
background: color-mix(in srgb, var(--tfmc-forest) 70%, transparent);
7786
border-radius: 4px;

‎frontend/lib/admin/api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ export type ActivityEntry = {
272272
victim: { minecraft_name: string; uuid: string | null } | null;
273273
/** Chat text, for admins only. */
274274
message: string | null;
275+
/** The RPCharacters channel (`LOOC`, `Emote`) of chat sent by command; plain chat's channel is not recorded. */
276+
channel?: string | null;
275277
truncated: boolean;
276278
world: string | null;
277279
x: number;

0 commit comments

Comments
 (0)