@runmu.sh/ext-channels 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - First version: the Channels panel moved out of the μClient core into its own extension, on SDK 1.7 (`mu.channels.get/watch/select/markRead/send/configure`, `mu.panels.open` with `opts`). Same panel ids (`channels`, `channel`), testids, copy and look as the core panel it replaces. The core keeps the channel state, GMCP handling, mentions, alerts and per-world settings.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 runmu.sh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # Channels (`@runmu.sh/ext-channels`, id `channels`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Channels panel of [μClient](https://runmu.sh) (R-CHAN): the game's chat channels as a rail of chips, the selected channel's messages with search, a colour and alert setting per channel, a ❯ composer, and a pop-out per channel.
4
+
5
+ First-party, on the [marketplace](https://runmu.sh/marketplace/x/channels). Install it from **☰ → Extensions → Discover**, then turn it on per world in **Extensions → Installed**. Until 1.0.0 the panel was part of the client core. The core still tracks every session's channels. This extension only draws them.
6
+
7
+ ## Where the channels come from
8
+ The host keeps the channels per session and fills them from:
9
+
10
+ | Source | What |
11
+ |---|---|
12
+ | GMCP `Comm.Channel.List` | The channels: `name`, `caption` (shown), `command` (what to send) |
13
+ | GMCP `Comm.Channel.Text` | A message: `channel`, `talker`/`sender`/`from`, `text` (ANSI stripped), `reactions` (`{ "+": 2 }`) |
14
+ | GMCP `Comm.Channel.Players` | The online counts on the chips |
15
+ | Another extension: `mu.channels.push(channel, sender, text)` | A message |
16
+
17
+ The host also tracks mentions (a message that names your character, from `Char.Name` / `Char.Status` or the *Character name* setting), unread counts, alerts and the per-world settings. This panel reads them with `mu.channels.watch`.
18
+
19
+ ## What it shows
20
+ - **Rail**: one chip per channel. The active chip is a solid accent fill. A chip shows the unread count as a badge, `@` and a gold name when an unread message mentions you, the online count in `--ok`, and a struck-through name when the channel is muted. With a colour set, the chip name takes it. Click a chip to switch to it. Right-click it to open its settings.
21
+ - **Head**: the channel title, its topic in italics, and the tools **Settings**, **Search**, **Mute / Unmute** and **Pop out**.
22
+ - **Settings strip**: the colour swatches (default, then the SDK's `CHANNEL_COLORS`: accent, bright accent, gold, alert, ok, text, dim text; theme tokens only, so a colour follows the theme) and **Alerts**: *Every message*, *Mentions only* (default) or *None*. Both are saved per world through `mu.channels.configure`.
23
+ - **Search** (also **Ctrl+F** / **⌘F** inside the panel) follows the terminal's rules. Plain text matches case-insensitively, and `/regex/flags` is a regular expression. The search runs over `sender: text`. Matching rows take the terminal's hit tint and the active hit is outlined. The count shows `n/N`, *no match*, or *not a valid regex*. **Enter** or **↑** goes to the previous hit, **Shift+Enter** or **↓** to the next, and **Esc** closes the search and clears the query.
24
+ - **Messages**: the time, the sender (in the channel colour, gold by default) and the text, one line each, with the reactions after the text. A message that mentions you has a gold left rule. The view keeps to the bottom as messages arrive, unless a search hit is active.
25
+ - **Composer**: `❯ message <channel>`. Enter sends through the input pipeline with the world's *Reply format* (`{channel} {text}`), via `mu.channels.send`.
26
+ - **Pop out** opens the channel as its own panel (`channel`, instance = the channel key, titled `Channel · <caption>`). The pop-out shows only that channel, keeps its own composer and survives a layout reload. While it is shown it marks its channel read. If the game no longer has the channel, it says *channel X is not open here*.
27
+
28
+ Reading a channel clears its unread count and mention: the main panel selects the channel, and a pop-out marks its own channel read. Text scales with the dock's per-panel font size (`--shell-font-size`). The message list is the focus region `channels`, so **Alt+C** (*Go to channels*) focuses it.
29
+
30
+ ## Behaviour
31
+ - Panel `channels`: singleton, right bottom, Views order 20, title *Channels*.
32
+ - Panel `channel`: not a singleton, right bottom, not in Views, order 21, opened with `params.channel` (and `params.instance`).
33
+ - The first `Comm.Channel.*` package of a session adds `channels` if it is not open (R-AUTO-PANELS). The host does this once per world on this device, so a panel you closed stays closed.
34
+ - It sends nothing to the game except what you type in the composer. It adds no `Core.Supports` of its own: the backend's base set already has `Comm.Channel 1`.
35
+
36
+ ## Settings
37
+ The extension has no settings page of its own. The channel settings are the strip in the panel (colour, alerts, mute). *Reply format* and *Character name* are core settings, so they stay in μClient's Settings.
38
+
39
+ ## SDK
40
+ Needs SDK 1.7. It uses `mu.channels.get`, `watch`, `select`, `markRead`, `send` and `configure`, plus `CHANNEL_COLORS`, `mu.panels.register`, `mu.panels.vue`, `mu.panels.open` (with `opts`), `mu.panels.autoAdd`, `mu.gmcp.on` and `mu.ui.style`. It exports no API.
41
+
42
+ ## Develop
43
+ Made with `npm create @runmu.sh/extension` ([the quickstart](https://runmu.sh/docs/extensions/quickstart)).
44
+
45
+ ```sh
46
+ npm install
47
+ npm run build # src/index.ts → dist/index.js, then the manifest check
48
+ npm run typecheck
49
+ npm test # tests/*.test.mjs: the pure helpers, and the panel rendered through a fake mu
50
+ npm run dev # dev server with hot reload (☰ → Extensions → Advanced → Developer)
51
+ ```
52
+
53
+ | Path | What |
54
+ |---|---|
55
+ | `src/index.ts` | `setup(mu)`: the stylesheet, both panels, the auto-add |
56
+ | `src/panel.ts` | The panel, a Vue component in render functions (`vue` is the host's, external) |
57
+ | `src/logic.ts` | The pure parts: matcher and search, count and stepping, keys, colour and tint, rail, selection and unread |
58
+ | `src/style.ts` | The CSS, every rule under `.mu-channels`, tokens only |
59
+ | `src/copy.ts` | Every visible string |
60
+
61
+ ## Licence
62
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,508 @@
1
+ // src/index.ts
2
+ import { defineExtension } from "@muclient/sdk";
3
+
4
+ // src/copy.ts
5
+ var COPY = {
6
+ title: "Channels",
7
+ awaiting: "Waiting for channels",
8
+ none: "No channels",
9
+ noMessages: "No messages yet",
10
+ noChannel: "No channel.",
11
+ placeholder: (ch) => `message ${ch}`,
12
+ settings: "Settings",
13
+ settingsTitle: "channel settings",
14
+ muted: "Muted",
15
+ mute: "Mute",
16
+ unmute: "Unmute",
17
+ muteTitle: "mute channel",
18
+ alerts: "Alerts",
19
+ alertAll: "Every message",
20
+ alertMentions: "Mentions only",
21
+ alertNone: "None",
22
+ online: (n) => `${n} online`,
23
+ unread: (n) => `${n} unread`,
24
+ rail: "Channels",
25
+ color: "Colour",
26
+ colorDefault: "default",
27
+ colors: { accent: "accent", "accent-bright": "bright accent", gold: "gold", alert: "alert", ok: "ok", fg: "text", "fg-dim": "dim text" },
28
+ search: "Search",
29
+ searchTitle: "search this channel",
30
+ searchPlaceholder: "search channel",
31
+ noMatch: "no match",
32
+ badRegex: "not a valid regex",
33
+ prev: "previous",
34
+ next: "next",
35
+ close: "close",
36
+ prevLabel: "Prev",
37
+ nextLabel: "Next",
38
+ closeLabel: "Close",
39
+ popOut: "Pop out",
40
+ popOutTitle: (ch) => `pop out ${ch}`,
41
+ soloTitle: (ch) => `Channel \xB7 ${ch}`,
42
+ gone: (ch) => `channel ${ch} is not open here`
43
+ };
44
+
45
+ // src/style.ts
46
+ var CHANNELS_CSS = `
47
+ .mu-channels { --u: var(--shell-font-size, 15px); height: 100%; display: flex; flex-direction: column; background: var(--bg-elev); min-height: 0; font-size: var(--u); box-sizing: border-box; }
48
+ .mu-channels .awaiting, .mu-channels .rail-empty, .mu-channels .msgs .empty { color: var(--fg-faint); font-style: normal; font-size: calc(var(--u) * .64); letter-spacing: .14em; text-transform: uppercase; }
49
+ .mu-channels .awaiting { padding: 10px; margin: 0; }
50
+ .mu-channels .rail { display: flex; flex-wrap: wrap; gap: 2px; padding: 4px 6px; border-bottom: 1px solid var(--accent); flex: none; }
51
+ .mu-channels .cc { display: inline-flex; gap: .6ch; align-items: center; border: 0; background: none; padding: 2px .8ch; min-height: 24px; font-size: calc(var(--u) * .66); letter-spacing: .14em; text-transform: uppercase; color: var(--fg-dim); transition: color .12s ease, background-color .12s ease; }
52
+ .mu-channels .cc:hover { color: var(--fg); background: var(--tint-toggle); }
53
+ .mu-channels .cc.on { color: var(--bg-deep); background: var(--accent); }
54
+ .mu-channels .cc.tinted:not(.on) .name { color: var(--chan); }
55
+ .mu-channels .cc.mention:not(.on) .name { color: var(--gold); }
56
+ .mu-channels .cc.muted .name { opacity: .5; text-decoration: line-through; }
57
+ .mu-channels .at { color: var(--gold); }
58
+ .mu-channels .online { color: var(--ok); font-size: calc(var(--u) * .6); letter-spacing: 0; }
59
+ .mu-channels .cc.on .at, .mu-channels .cc.on .online { color: inherit; }
60
+ .mu-channels .bd { font-size: calc(var(--u) * .6); }
61
+ .mu-channels .cv { display: flex; flex-direction: column; min-height: 0; flex: 1; }
62
+ .mu-channels .head { display: flex; align-items: baseline; gap: 1ch; padding: 5px 10px; border-bottom: 1px solid var(--border); flex: none; }
63
+ .mu-channels .title { color: var(--accent-bright); text-transform: uppercase; letter-spacing: .16em; font-size: calc(var(--u) * .78); }
64
+ .mu-channels .topic { color: var(--fg-dim); font-size: calc(var(--u) * .72); font-style: italic; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
65
+ .mu-channels .tools { margin-left: auto; display: flex; gap: 2px; flex-wrap: wrap; justify-content: flex-end; }
66
+ .mu-channels .t { font-size: calc(var(--u) * .68); }
67
+ .mu-channels .ccfg { display: flex; flex-wrap: wrap; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--accent); background: var(--bg-deep); flex: none; }
68
+ .mu-channels .cfg-row { display: flex; align-items: center; gap: 6px; font-size: calc(var(--u) * .72); color: var(--fg-dim); }
69
+ .mu-channels .sel { color: var(--fg); background: var(--bg-deep); font-size: calc(var(--u) * .72); min-height: 24px; padding: 2px; }
70
+ .mu-channels .swatches { display: inline-flex; gap: 4px; flex-wrap: wrap; }
71
+ .mu-channels .sw { width: 24px; height: 24px; border: 1px solid var(--border-bright); background: var(--bg); display: inline-grid; place-items: center; color: var(--fg-faint); font-size: calc(var(--u) * .72); transition: border-color .12s ease; }
72
+ .mu-channels .sw:not(.none)::before { content: ''; width: 14px; height: 14px; background: var(--sw); }
73
+ .mu-channels .sw:hover { border-color: var(--accent); }
74
+ .mu-channels .sw.on { border-color: var(--accent-bright); box-shadow: inset 0 0 0 1px var(--accent-bright); }
75
+ .mu-channels .csearch { display: flex; align-items: center; gap: .5ch; padding: 3px 10px; border-bottom: 1px solid var(--accent); background: var(--bg-deep); flex: none; }
76
+ .mu-channels .s-glyph { color: var(--accent); }
77
+ .mu-channels .csearch input { flex: 1; min-width: 0; background: transparent; border: 0; color: var(--fg); font-size: calc(var(--u) * .82); padding: 2px 0; }
78
+ .mu-channels .csearch input::placeholder { color: var(--fg-faint); font-style: normal; letter-spacing: .14em; text-transform: uppercase; font-size: calc(var(--u) * .66); opacity: 1; }
79
+ .mu-channels .cnt { color: var(--fg-dim); font-size: calc(var(--u) * .72); min-width: 3.5em; text-align: right; }
80
+ .mu-channels .cnt.err { color: var(--alert); }
81
+ .mu-channels .s-btn { font-size: calc(var(--u) * .68); }
82
+ .mu-channels .msgs { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px; line-height: 1.5; display: flex; flex-direction: column; }
83
+ .mu-channels .msgs > :first-child { margin-top: auto; }
84
+ .mu-channels .msgs .empty { margin: 0; }
85
+ .mu-channels .msg { padding: 1px 0; font-size: calc(var(--u) * .85); overflow-wrap: anywhere; }
86
+ .mu-channels .mts { color: var(--fg-faint); font-size: .72em; margin-right: .6ch; user-select: none; }
87
+ .mu-channels .sender { color: var(--chan, var(--gold)); margin-right: .6ch; }
88
+ .mu-channels .text { color: var(--fg); white-space: pre-wrap; }
89
+ .mu-channels .msg.mention { border-left: 2px solid var(--gold); padding-left: 7px; margin-left: -9px; box-shadow: -4px 0 8px -6px var(--gold); }
90
+ .mu-channels .msg.hit { background: var(--tint-hit); }
91
+ .mu-channels .msg.hit.active { background: var(--tint-hit-active); outline: 1px solid var(--accent); outline-offset: -1px; }
92
+ .mu-channels .reacts { margin-left: .5ch; }
93
+ .mu-channels .react { border: 0; border-bottom: 1px solid var(--border-bright); background: none; color: var(--fg-dim); font-size: .72em; padding: 0 4px; margin-left: 3px; }
94
+ .mu-channels .composer { display: flex; align-items: center; gap: .6rem; padding: 6px 10px; border-top: 1px solid var(--accent); flex: none; }
95
+ .mu-channels .chev { color: var(--accent-bright); }
96
+ .mu-channels .in { flex: 1; min-width: 0; min-height: 24px; background: transparent; outline: none; color: var(--fg); caret-color: var(--accent-bright); font-size: calc(var(--u) * .85); padding: 2px; }
97
+ .mu-channels .in::placeholder { color: var(--fg-faint); font-style: normal; letter-spacing: .14em; text-transform: uppercase; font-size: calc(var(--u) * .66); opacity: 1; }
98
+ .mu-channels .composer:focus-within { box-shadow: inset 0 0 0 2px var(--accent-bright); }
99
+ `;
100
+
101
+ // src/panel.ts
102
+ import { computed, defineComponent, h, nextTick, ref, shallowRef, watch } from "vue";
103
+ import { CHANNEL_COLORS as CHANNEL_COLORS2 } from "@muclient/sdk";
104
+
105
+ // src/logic.ts
106
+ import { CHANNEL_COLORS } from "@muclient/sdk";
107
+ function makeMatcher(q) {
108
+ const t = q.trim();
109
+ if (!t) return null;
110
+ const m = /^\/(.+)\/([a-z]*)$/s.exec(t);
111
+ if (m) {
112
+ try {
113
+ const re = new RegExp(m[1], m[2].replace(/[gy]/g, ""));
114
+ return { ok: true, test: (s) => re.test(s) };
115
+ } catch {
116
+ return { ok: false, error: "bad" };
117
+ }
118
+ }
119
+ if (t.startsWith("/") && t.length > 1 && !t.endsWith("/")) {
120
+ try {
121
+ const re = new RegExp(t.slice(1), "i");
122
+ return { ok: true, test: (s) => re.test(s) };
123
+ } catch {
124
+ return { ok: false, error: "bad" };
125
+ }
126
+ }
127
+ const needle = t.toLowerCase();
128
+ return { ok: true, test: (s) => s.toLowerCase().includes(needle) };
129
+ }
130
+ var searchText = (m) => m.sender ? `${m.sender}: ${m.text}` : m.text;
131
+ function searchMessages(msgs, q) {
132
+ const m = makeMatcher(q);
133
+ if (!m) return { hits: [], error: false };
134
+ if (!m.ok) return { hits: [], error: true };
135
+ return { hits: msgs.filter((x) => m.test(searchText(x))).map((x) => x.id), error: false };
136
+ }
137
+ function countText(found, query, hitIdx) {
138
+ if (found.error) return COPY.badRegex;
139
+ if (!query.trim()) return "";
140
+ return found.hits.length ? `${hitIdx + 1}/${found.hits.length}` : COPY.noMatch;
141
+ }
142
+ function stepIndex(idx, dir, n) {
143
+ if (!n) return idx;
144
+ return (idx + dir + n) % n;
145
+ }
146
+ function clampHit(idx, n) {
147
+ let i = idx;
148
+ if (i >= n) i = n - 1;
149
+ if (i < 0 && n) i = n - 1;
150
+ return i;
151
+ }
152
+ var activeHitId = (found, idx) => idx >= 0 ? found.hits[idx] ?? -1 : -1;
153
+ function searchKey(e) {
154
+ if (e.key === "Escape") return "close";
155
+ if (e.key === "Enter") return e.shiftKey ? "next" : "prev";
156
+ if (e.key === "ArrowUp") return "prev";
157
+ if (e.key === "ArrowDown") return "next";
158
+ return null;
159
+ }
160
+ var isFindKey = (e) => !!(e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "f";
161
+ var isChannelColor = (v) => typeof v === "string" && CHANNEL_COLORS.includes(v);
162
+ var colorVar = (c) => isChannelColor(c) ? `var(--${c})` : null;
163
+ var tintStyle = (c) => {
164
+ const v = colorVar(c);
165
+ return v ? { "--chan": v } : {};
166
+ };
167
+ var swatchStyle = (c) => ({ "--sw": `var(--${c})` });
168
+ var soloOf = (params) => typeof params?.channel === "string" ? params.channel : "";
169
+ var activeKeyOf = (v, solo) => solo || v?.active || "";
170
+ var activeOf = (v, solo) => {
171
+ const k = activeKeyOf(v, solo);
172
+ return v?.channels.find((c) => c.key === k) ?? null;
173
+ };
174
+ var railOf = (v, solo) => !v ? [] : solo ? v.channels.filter((c) => c.key === solo) : v.channels;
175
+ var messagesOf = (v, solo) => {
176
+ const a = activeOf(v, solo);
177
+ return v && a ? v.messages[a.key] ?? [] : [];
178
+ };
179
+ function bodyOf(v, solo) {
180
+ if (solo && v?.known && !activeOf(v, solo)) return "gone";
181
+ if (!v || !v.known) return "awaiting";
182
+ return activeOf(v, solo) ? "view" : "none";
183
+ }
184
+ function readAction(v, solo) {
185
+ const a = activeOf(v, solo);
186
+ if (!a || !a.unread) return null;
187
+ return { op: solo ? "markRead" : "select", key: a.key };
188
+ }
189
+ var popOutArgs = (ch, sid) => ["channel", { channel: ch.key, instance: ch.key }, { sid, title: COPY.soloTitle(ch.caption) }];
190
+ var hhmm = (ts) => {
191
+ const d = new Date(ts);
192
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
193
+ };
194
+
195
+ // src/panel.ts
196
+ var cls = (...xs) => xs.filter(Boolean).join(" ");
197
+ var INPUT = '[data-testid="channel-input"]';
198
+ function snapshotDraft(el) {
199
+ const v = el.querySelector(INPUT)?.value;
200
+ return v ? v : void 0;
201
+ }
202
+ function restoreDraft(el, state) {
203
+ const input = el.querySelector(INPUT);
204
+ if (typeof state !== "string" || !state || !input) return;
205
+ input.value = state;
206
+ input.dispatchEvent(new Event("input", { bubbles: true }));
207
+ }
208
+ function createChannelsPanel(mu) {
209
+ const c = mu.ui.css;
210
+ return defineComponent({
211
+ name: "ChannelsPanel",
212
+ props: {
213
+ sid: { type: String, default: null },
214
+ worldId: { type: String, default: null },
215
+ params: { type: Object, default: () => ({}) }
216
+ },
217
+ setup(props) {
218
+ const solo = computed(() => soloOf(props.params));
219
+ const st = shallowRef(null);
220
+ watch(() => props.sid, (sid, _old, onCleanup) => {
221
+ st.value = null;
222
+ if (!sid) return;
223
+ onCleanup(mu.channels.watch((v) => {
224
+ st.value = v;
225
+ }, sid));
226
+ }, { immediate: true });
227
+ const activeKey = computed(() => activeKeyOf(st.value, solo.value));
228
+ const active = computed(() => activeOf(st.value, solo.value));
229
+ const rail = computed(() => railOf(st.value, solo.value));
230
+ const msgs = computed(() => messagesOf(st.value, solo.value));
231
+ const showCfg = ref(false);
232
+ const draft = ref("");
233
+ const view = ref(null);
234
+ const muted = computed(() => !!active.value?.muted);
235
+ const alert = computed(() => active.value?.alert ?? "mentions");
236
+ const color = computed(() => active.value?.color ?? null);
237
+ const configure = (patch) => {
238
+ if (props.sid && active.value) mu.channels.configure(active.value.key, patch, props.sid);
239
+ };
240
+ const searching = ref(false);
241
+ const query = ref("");
242
+ const searchInput = ref(null);
243
+ const hitIdx = ref(-1);
244
+ const found = computed(() => searchMessages(msgs.value, searching.value ? query.value : ""));
245
+ const hitSet = computed(() => new Set(found.value.hits));
246
+ const activeHit = computed(() => activeHitId(found.value, hitIdx.value));
247
+ const count = computed(() => countText(found.value, query.value, hitIdx.value));
248
+ async function reveal() {
249
+ await nextTick();
250
+ const id = activeHit.value;
251
+ if (id < 0 || !view.value) return;
252
+ view.value.querySelector(`[data-mid="${id}"]`)?.scrollIntoView?.({ block: "nearest" });
253
+ }
254
+ watch(() => [query.value, activeKey.value, searching.value], () => {
255
+ hitIdx.value = found.value.hits.length - 1;
256
+ void reveal();
257
+ });
258
+ watch(() => found.value.hits.length, (n) => {
259
+ hitIdx.value = clampHit(hitIdx.value, n);
260
+ });
261
+ function step(dir) {
262
+ const n = found.value.hits.length;
263
+ if (!n) return;
264
+ hitIdx.value = stepIndex(hitIdx.value, dir, n);
265
+ void reveal();
266
+ }
267
+ function openSearch() {
268
+ searching.value = true;
269
+ void nextTick(() => {
270
+ searchInput.value?.focus();
271
+ searchInput.value?.select();
272
+ });
273
+ }
274
+ function closeSearch() {
275
+ query.value = "";
276
+ searching.value = false;
277
+ view.value?.focus();
278
+ }
279
+ function toggleSearch() {
280
+ if (searching.value) closeSearch();
281
+ else openSearch();
282
+ }
283
+ function onSearchKey(e) {
284
+ const k = searchKey(e);
285
+ if (k === "close") {
286
+ closeSearch();
287
+ e.preventDefault();
288
+ e.stopPropagation();
289
+ } else if (k) {
290
+ step(k === "next" ? 1 : -1);
291
+ e.preventDefault();
292
+ }
293
+ }
294
+ function onPanelKey(e) {
295
+ if (isFindKey(e) && st.value?.known) {
296
+ openSearch();
297
+ e.preventDefault();
298
+ e.stopPropagation();
299
+ }
300
+ }
301
+ function pick(key) {
302
+ if (props.sid && !solo.value) mu.channels.select(key, props.sid);
303
+ }
304
+ function cfgFor(key) {
305
+ pick(key);
306
+ showCfg.value = true;
307
+ }
308
+ function popOut() {
309
+ const ch = active.value;
310
+ if (!props.sid || !ch) return;
311
+ mu.panels.open(...popOutArgs(ch, props.sid));
312
+ }
313
+ async function send(e) {
314
+ e.preventDefault();
315
+ if (!props.sid || !draft.value.trim()) return;
316
+ const t = draft.value;
317
+ draft.value = "";
318
+ await mu.channels.send(t, activeKey.value, props.sid);
319
+ }
320
+ watch([() => msgs.value[msgs.value.length - 1]?.id, activeKey, () => active.value?.unread], async () => {
321
+ const r = props.sid ? readAction(st.value, solo.value) : null;
322
+ if (r && props.sid) {
323
+ if (r.op === "markRead") mu.channels.markRead(r.key, props.sid);
324
+ else mu.channels.select(r.key, props.sid);
325
+ }
326
+ await nextTick();
327
+ if (view.value && activeHit.value < 0) view.value.scrollTop = view.value.scrollHeight;
328
+ }, { immediate: true });
329
+ const chip = (ch) => h("button", {
330
+ key: ch.key,
331
+ type: "button",
332
+ "data-key": ch.key,
333
+ class: cls("cc", ch.key === activeKey.value && "on", ch.muted && "muted", ch.mention && "mention", !!ch.color && "tinted"),
334
+ style: tintStyle(ch.color),
335
+ "data-color": ch.color || void 0,
336
+ "aria-pressed": String(ch.key === activeKey.value),
337
+ onClick: () => pick(ch.key),
338
+ onContextmenu: (e) => {
339
+ e.preventDefault();
340
+ cfgFor(ch.key);
341
+ }
342
+ }, [
343
+ h("span", { class: "name" }, ch.caption),
344
+ ch.mention ? h("span", { class: "at", "aria-hidden": "true" }, "@") : null,
345
+ ch.online !== null ? h("span", { class: "online", title: COPY.online(ch.online) }, String(ch.online)) : null,
346
+ ch.unread ? h("span", { class: cls("bd", c.count), "aria-label": COPY.unread(ch.unread) }, String(ch.unread)) : null
347
+ ]);
348
+ const tool = (on, extra, label) => h("button", { type: "button", class: cls(c.cmd, "t", on && "on"), ...extra }, label);
349
+ const cfgStrip = () => h("div", { class: "ccfg", "data-testid": "channel-cfg" }, [
350
+ h("div", { class: "cfg-row" }, [
351
+ h("span", COPY.color),
352
+ h("span", { class: "swatches", role: "radiogroup", "aria-label": COPY.color, "data-testid": "channel-colors" }, [
353
+ h("button", {
354
+ type: "button",
355
+ class: cls("sw", "none", !color.value && "on"),
356
+ role: "radio",
357
+ "aria-checked": String(!color.value),
358
+ title: COPY.colorDefault,
359
+ "aria-label": COPY.colorDefault,
360
+ "data-color": "",
361
+ onClick: () => configure({ color: null })
362
+ }, "\xD7"),
363
+ ...CHANNEL_COLORS2.map((k) => h("button", {
364
+ key: k,
365
+ type: "button",
366
+ class: cls("sw", color.value === k && "on"),
367
+ role: "radio",
368
+ "aria-checked": String(color.value === k),
369
+ style: swatchStyle(k),
370
+ title: COPY.colors[k],
371
+ "aria-label": COPY.colors[k],
372
+ "data-color": k,
373
+ onClick: () => configure({ color: k })
374
+ }))
375
+ ])
376
+ ]),
377
+ h("label", { class: "cfg-row" }, [
378
+ h("span", COPY.alerts),
379
+ h("select", {
380
+ class: cls("sel", c.field),
381
+ value: alert.value,
382
+ onChange: (e) => configure({ alert: e.target.value })
383
+ }, [
384
+ h("option", { value: "all", selected: alert.value === "all" }, COPY.alertAll),
385
+ h("option", { value: "mentions", selected: alert.value === "mentions" }, COPY.alertMentions),
386
+ h("option", { value: "none", selected: alert.value === "none" }, COPY.alertNone)
387
+ ])
388
+ ])
389
+ ]);
390
+ const searchStrip = () => h("div", { class: "csearch", "data-testid": "channel-search" }, [
391
+ h("span", { class: "s-glyph", "aria-hidden": "true" }, "\u2315"),
392
+ h("input", {
393
+ ref: searchInput,
394
+ value: query.value,
395
+ placeholder: COPY.searchPlaceholder,
396
+ "aria-label": COPY.searchPlaceholder,
397
+ spellcheck: "false",
398
+ "data-testid": "channel-search-input",
399
+ onInput: (e) => {
400
+ query.value = e.target.value;
401
+ },
402
+ onKeydown: onSearchKey
403
+ }),
404
+ h("span", { class: cls("cnt", found.value.error && "err"), "data-testid": "channel-search-count", "aria-live": "polite" }, count.value),
405
+ h("button", { type: "button", class: cls(c.cmd, "s-btn"), "aria-label": COPY.prev, "data-s": "prev", onClick: () => step(-1) }, COPY.prevLabel),
406
+ h("button", { type: "button", class: cls(c.cmd, "s-btn"), "aria-label": COPY.next, "data-s": "next", onClick: () => step(1) }, COPY.nextLabel),
407
+ h("button", { type: "button", class: cls(c.cmd, "s-btn"), "aria-label": COPY.close, "data-s": "close", onClick: closeSearch }, COPY.closeLabel)
408
+ ]);
409
+ const messages = (tint) => h("div", {
410
+ ref: view,
411
+ class: "msgs",
412
+ role: "log",
413
+ "data-focus-region": "channels",
414
+ tabindex: "-1",
415
+ "data-testid": "channel-msgs",
416
+ style: tintStyle(tint)
417
+ }, msgs.value.length ? msgs.value.map((m) => h("div", {
418
+ key: m.id,
419
+ "data-mid": m.id,
420
+ role: "article",
421
+ class: cls("msg", m.mention && "mention", hitSet.value.has(m.id) && "hit", m.id === activeHit.value && "active")
422
+ }, [
423
+ h("span", { class: "mts" }, hhmm(m.ts)),
424
+ h("span", { class: "sender" }, m.sender),
425
+ h("span", { class: "b text" }, m.text),
426
+ m.reactions ? h("span", { class: "reacts" }, Object.entries(m.reactions).map(([r, n]) => h("span", { key: r, class: "react" }, `${r} ${n}`))) : null
427
+ ])) : [h("p", { class: c.empty }, COPY.noMessages)]);
428
+ return () => {
429
+ const body = bodyOf(st.value, solo.value);
430
+ const kids = [];
431
+ if (body === "gone") {
432
+ kids.push(h("p", { class: cls(c.empty, "awaiting"), "data-testid": "channels-gone" }, COPY.gone(solo.value)));
433
+ } else {
434
+ kids.push(h("div", { class: "rail", role: "group", "aria-label": COPY.rail, "data-testid": "channel-rail" }, [
435
+ ...rail.value.map(chip),
436
+ rail.value.length ? null : h("span", { class: "rail-empty" }, COPY.none)
437
+ ]));
438
+ const a = active.value;
439
+ if (body === "awaiting") kids.push(h("p", { class: cls(c.empty, "awaiting"), tabindex: "-1", "data-focus-region": "channels", "data-testid": "channels-empty" }, COPY.awaiting));
440
+ else if (body === "none" || !a) kids.push(h("p", { class: cls(c.empty, "awaiting"), "data-testid": "channels-none" }, COPY.noChannel));
441
+ else kids.push(h("div", { class: "cv" }, [
442
+ h("div", { class: "head" }, [
443
+ h("span", { class: cls("title", c.glow), "data-testid": "channel-title" }, a.caption),
444
+ a.topic ? h("span", { class: "topic" }, a.topic) : null,
445
+ h("span", { class: "tools" }, [
446
+ tool(showCfg.value, { title: COPY.settingsTitle, "aria-expanded": String(showCfg.value), "data-testid": "channel-cfg-btn", onClick: () => {
447
+ showCfg.value = !showCfg.value;
448
+ } }, COPY.settings),
449
+ tool(searching.value, { title: COPY.searchTitle, "aria-expanded": String(searching.value), "data-testid": "channel-search-btn", onClick: toggleSearch }, COPY.search),
450
+ tool(muted.value, { title: COPY.muteTitle, "aria-pressed": String(muted.value), "data-testid": "channel-mute", onClick: () => configure({ muted: !muted.value }) }, muted.value ? COPY.unmute : COPY.mute),
451
+ solo.value ? null : tool(false, { title: COPY.popOutTitle(a.caption), "data-testid": "channel-popout", onClick: popOut }, COPY.popOut)
452
+ ])
453
+ ]),
454
+ showCfg.value ? cfgStrip() : null,
455
+ searching.value ? searchStrip() : null,
456
+ messages(a.color),
457
+ h("form", { class: "composer", onSubmit: send }, [
458
+ h("span", { class: cls("chev", c.glow), "aria-hidden": "true" }, "\u276F"),
459
+ h("input", {
460
+ value: draft.value,
461
+ class: cls("in", c.field),
462
+ autocomplete: "off",
463
+ placeholder: COPY.placeholder(a.caption),
464
+ "aria-label": COPY.placeholder(a.caption),
465
+ "data-testid": "channel-input",
466
+ onInput: (e) => {
467
+ draft.value = e.target.value;
468
+ }
469
+ })
470
+ ])
471
+ ]));
472
+ }
473
+ return h("section", {
474
+ class: cls("mu-channels", "chan", !!solo.value && "solo"),
475
+ "aria-label": COPY.title,
476
+ "data-testid": "channels",
477
+ "data-channel": solo.value || void 0,
478
+ onKeydown: onPanelKey
479
+ }, kids);
480
+ };
481
+ }
482
+ });
483
+ }
484
+
485
+ // src/index.ts
486
+ function setup(mu) {
487
+ mu.ui.style(CHANNELS_CSS);
488
+ const mount = mu.panels.vue(createChannelsPanel(mu));
489
+ mu.panels.register({ id: "channels", title: COPY.title, singleton: true, defaultPosition: "right-bottom", order: 20, mount, snapshot: snapshotDraft, restore: restoreDraft });
490
+ mu.panels.register({ id: "channel", title: COPY.title, singleton: false, defaultPosition: "right-bottom", inViewsMenu: false, order: 21, mount });
491
+ const added = /* @__PURE__ */ new Set();
492
+ mu.gmcp.on("Comm.Channel", (_d, { sid }) => {
493
+ if (added.has(sid)) return;
494
+ added.add(sid);
495
+ mu.panels.autoAdd("channels", sid);
496
+ });
497
+ }
498
+ var index_default = defineExtension({
499
+ activate(ctx) {
500
+ setup(ctx.mu);
501
+ }
502
+ });
503
+ export {
504
+ CHANNELS_CSS,
505
+ COPY,
506
+ index_default as default,
507
+ setup
508
+ };
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "@runmu.sh/ext-channels",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "The Channels panel: the game's chat channels as a rail of chips with unread and mention markers, one channel's messages with search, colours and alerts, a composer, and a pop-out per channel, from GMCP Comm.Channel.*.",
5
+ "keywords": [
6
+ "channels",
7
+ "chat",
8
+ "comms",
9
+ "gmcp",
10
+ "panel"
11
+ ],
12
+ "license": "MIT",
13
+ "homepage": "https://runmu.sh/marketplace/x/channels",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/runmu-sh/ext-channels.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/runmu-sh/ext-channels/issues"
20
+ },
21
+ "type": "module",
22
+ "exports": "./dist/index.js",
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "node scripts/build.mjs",
30
+ "dev": "node scripts/dev.mjs",
31
+ "typecheck": "tsc --noEmit",
32
+ "check": "node scripts/build.mjs --check",
33
+ "test": "node --experimental-strip-types --test \"tests/*.test.mjs\""
34
+ },
35
+ "devDependencies": {
36
+ "@muclient/sdk": "npm:@runmu.sh/sdk@^1.7.1",
37
+ "@runmu.sh/dev": "^0.1.0",
38
+ "esbuild": "^0.28.2",
39
+ "typescript": "^5.9.0",
40
+ "vue": "^3.5.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.6"
44
+ },
45
+ "muclient": {
46
+ "id": "channels",
47
+ "api": "^1.7",
48
+ "source": "src/index.ts",
49
+ "displayName": "Channels",
50
+ "description": "The Channels panel: the game's chat channels as a rail of chips with unread and mention markers, one channel's messages with search, colours and alerts, a composer, and a pop-out per channel, from GMCP Comm.Channel.*.",
51
+ "categories": [
52
+ "interface",
53
+ "communication"
54
+ ],
55
+ "contributes": {
56
+ "panels": [
57
+ {
58
+ "id": "channels",
59
+ "title": "Channels",
60
+ "defaultPosition": "right-bottom",
61
+ "order": 20
62
+ },
63
+ {
64
+ "id": "channel",
65
+ "title": "Channel",
66
+ "defaultPosition": "right-bottom",
67
+ "inViewsMenu": false,
68
+ "order": 21
69
+ }
70
+ ],
71
+ "gmcp": [
72
+ "Comm.Channel 1"
73
+ ]
74
+ },
75
+ "capabilities": [
76
+ "send-commands"
77
+ ],
78
+ "publisher": "bitmuse"
79
+ }
80
+ }