@runmu.sh/ext-feeds 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
+ - The Feeds panel as an extension. It was part of the μClient core; it now draws the feeds through `mu.feeds` (SDK 1.7). The panel ids (`feeds`, `feed`), test ids and copy are unchanged, so saved layouts keep working. Install it from Extensions → Discover.
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 ADDED
@@ -0,0 +1,40 @@
1
+ # Feeds (`@runmu.sh/ext-feeds`, id `feeds`)
2
+
3
+ The Feeds panel of [μClient](https://runmu.sh) (R-FEEDS): the lines your routing rules copy or move out of the terminal, one tab per feed.
4
+
5
+ First-party, on the [marketplace](https://runmu.sh/marketplace/x/feeds). Install it from **☰ → Extensions → Discover** and enable it per world in **Extensions → Installed**.
6
+
7
+ Until 1.0.0 the panel was part of the client core. The core still routes the lines (Settings → Feeds, a rule stage) and keeps up to 500 lines per feed per session; this extension only draws them.
8
+
9
+ ## Where the lines come from
10
+ No GMCP. Feeds are filled by the player's rules in **Settings → Feeds** (a pattern, a feed label, and whether the line stays in the terminal). The panel reads them through `mu.feeds` (SDK 1.7): the labels in order (the enabled rules' labels, then any other feed holding lines), the lines with their spans, and unread counts.
11
+
12
+ ## What it shows
13
+ - **`feeds`** (Views → Feeds, right bottom, order 50): a tab per feed with an unread badge (lines that arrived while you looked elsewhere). Arrow keys move between tabs (they wrap), Home and End go to the first and last.
14
+ - Tools: **Search** (filters the feed, with a count), **Times** (HH:MM:SS before each line), **Pop out** (the feed in its own floating `feed` panel, titled "Feed · <label>"), **Rules** (opens Settings → Feeds) and **Clear** (empties the feed after a confirm).
15
+ - Lines keep the terminal's colours and highlights (each span's classes and style); spans with a link render as links that open in a new tab.
16
+ - Scrolled up, a pill counts what arrived ("3 new lines ↓") and jumps back to the end. A feed you are at the end of is marked read.
17
+ - With no feed yet the panel says **Add a feed**, which opens Settings → Feeds.
18
+ - **`feed`** (not in Views, floating, one per feed, `params.feed` = the label): the popped-out single feed with the same tools, less Pop out.
19
+
20
+ The first line a session's feeds receive adds the `feeds` panel to that session's workspace if it is not open (R-AUTO-PANELS, once per world on this device, so a panel you closed stays closed).
21
+
22
+ ## Settings
23
+ None of its own. The feeds themselves are configured in **Settings → Feeds** (core).
24
+
25
+ ## SDK
26
+ Uses `mu.feeds.get/watch/viewing/clear` (1.7), `mu.panels.register/open/autoAdd/vue` (`open` opts 1.7), `mu.commands.run('settings.open', 'feeds')`, `mu.sessions.list/on`, `mu.ui.style` and `mu.ui.css`. It exports no API. The pure helpers in `src/model.ts` are tested in `tests/`.
27
+
28
+ ## Develop
29
+ Made with `npm create @runmu.sh/extension` ([the quickstart](https://runmu.sh/docs/extensions/quickstart)).
30
+
31
+ ```sh
32
+ npm install
33
+ npm run build # src/index.ts → dist/index.js, then the manifest check
34
+ npm run typecheck # tsc --noEmit against the SDK types
35
+ npm test # node --test tests/*.test.mjs
36
+ npm run dev # dev server on http://localhost:5199/ with hot reload
37
+ ```
38
+
39
+ ## License
40
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,322 @@
1
+ // src/index.ts
2
+ import { defineExtension } from "@muclient/sdk";
3
+
4
+ // src/panel.ts
5
+ import { computed, defineComponent, h, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
6
+
7
+ // src/model.ts
8
+ var COPY = {
9
+ search: "Search",
10
+ times: "Times",
11
+ rules: "Rules",
12
+ popOut: "Pop out",
13
+ clear: "Clear",
14
+ addFeed: "Add a feed",
15
+ empty: "Empty.",
16
+ noMatches: "No matches.",
17
+ title: "Feeds",
18
+ feedTitle: "Feed",
19
+ popTitle: (label) => `Feed \xB7 ${label}`,
20
+ confirmClear: (label) => `Clear ${label}?`,
21
+ latest: (n) => `${n} new line${n === 1 ? "" : "s"} \u2193`
22
+ };
23
+ var FEEDS_CSS = `
24
+ .mu-feeds { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--bg-elev); }
25
+ .mu-feeds .fbar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-bottom: 1px solid var(--accent); flex: 0 0 auto; }
26
+ .mu-feeds .ftabs { display: flex; gap: 2px; flex-wrap: wrap; flex: 1; min-width: 0; }
27
+ .mu-feeds .solo { flex: 1; color: var(--accent-bright); font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; }
28
+ .mu-feeds .ftab { display: inline-flex; align-items: center; background: none; border: 0; border-radius: 0; color: var(--fg-dim); font-size: .64rem; letter-spacing: .14em; text-transform: uppercase; padding: 2px .8ch; min-height: 24px; transition: color .12s ease, background-color .12s ease; }
29
+ .mu-feeds .ftab:hover { color: var(--fg); background: var(--tint-toggle); }
30
+ .mu-feeds .ftab.on { color: var(--bg-deep); background: var(--accent); }
31
+ .mu-feeds .fbadge { margin-left: .6ch; }
32
+ .mu-feeds .tools { display: flex; gap: 3px; flex: 0 0 auto; }
33
+ .mu-feeds .fsearch { display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
34
+ .mu-feeds .fsearch input { flex: 1; min-width: 0; }
35
+ .mu-feeds .cnt { color: var(--fg-dim); font-size: .72rem; }
36
+ .mu-feeds .lines-wrap { position: relative; flex: 1; min-height: 0; display: flex; }
37
+ .mu-feeds .flines { flex: 1; overflow-y: auto; padding: 6px 10px; line-height: var(--shell-line-height); }
38
+ .mu-feeds .fline { white-space: pre-wrap; word-break: break-word; }
39
+ .mu-feeds .ts { color: var(--fg-faint); margin-right: .8ch; font-size: .82em; user-select: none; }
40
+ .mu-feeds .fempty { padding: 10px 0; margin: 0; color: var(--fg-faint); font-style: normal; font-size: .64rem; letter-spacing: .14em; text-transform: uppercase; }
41
+ .mu-feeds .latest { position: absolute; right: 14px; bottom: 10px; z-index: 5; background: var(--accent); border: 0; border-radius: 0; color: var(--bg-deep); font-size: .64rem; letter-spacing: .14em; text-transform: uppercase; padding: 3px 10px; min-height: 24px; transition: background-color .12s ease; }
42
+ .mu-feeds .latest:hover { background: var(--accent-bright); }
43
+ .mu-feeds .intro { padding: 12px; color: var(--fg-dim); font-size: .8rem; }
44
+ `;
45
+ var soloOf = (params) => typeof params?.feed === "string" ? params.feed : "";
46
+ var labelsOf = (view, solo) => solo ? [solo] : view ? [...view.labels] : [];
47
+ var keepSelection = (labels, sel) => labels.includes(sel) ? sel : labels[0] ?? "";
48
+ function tabTarget(key, i, n) {
49
+ if (n <= 0) return -1;
50
+ switch (key) {
51
+ case "ArrowRight":
52
+ return (i + 1) % n;
53
+ case "ArrowLeft":
54
+ return (i - 1 + n) % n;
55
+ case "Home":
56
+ return 0;
57
+ case "End":
58
+ return n - 1;
59
+ default:
60
+ return -1;
61
+ }
62
+ }
63
+ function hhmmss(ts) {
64
+ const d = new Date(ts), p = (n) => String(n).padStart(2, "0");
65
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
66
+ }
67
+ function filterLines(lines, q) {
68
+ const needle = q.trim().toLowerCase();
69
+ return needle ? lines.filter((l) => l.text.toLowerCase().includes(needle)) : lines;
70
+ }
71
+ var unreadOf = (view, label, sel) => label !== sel ? view?.unread[label] ?? 0 : 0;
72
+ var tabLabel = (label, unread) => unread ? `${label}, ${unread} unread` : label;
73
+ var nearEnd = (b) => b.scrollHeight - b.scrollTop - b.clientHeight < 24;
74
+ var newTail = () => ({ sel: "", lastId: -Infinity, atEnd: true, fresh: 0 });
75
+ function stepTail(t, sel, shown) {
76
+ const lastId = shown.length ? shown[shown.length - 1].id : t.lastId;
77
+ if (sel !== t.sel) return { sel, lastId, atEnd: true, fresh: 0 };
78
+ if (t.atEnd) return { ...t, lastId, fresh: 0 };
79
+ const added = shown.reduce((n, l) => n + (l.id > t.lastId ? 1 : 0), 0);
80
+ return { ...t, lastId: Math.max(lastId, t.lastId), fresh: t.fresh + added };
81
+ }
82
+ var hasLines = (view) => !!view && Object.values(view.lines).some((ls) => ls.length > 0);
83
+ function watchFirstLines(mu, onFirst) {
84
+ const watches = /* @__PURE__ */ new Map();
85
+ const done = /* @__PURE__ */ new Set();
86
+ let alive = true;
87
+ const stop = (sid) => {
88
+ const d = watches.get(sid);
89
+ watches.delete(sid);
90
+ d?.();
91
+ };
92
+ const isLive = (sid) => mu.sessions.list().some((s) => s.id === sid);
93
+ const ensure = (sid) => {
94
+ if (!alive || !sid || done.has(sid) || watches.has(sid)) return;
95
+ let ended = false;
96
+ const end = () => {
97
+ ended = true;
98
+ stop(sid);
99
+ };
100
+ const d = mu.feeds.watch((view) => {
101
+ if (ended) return;
102
+ if (!isLive(sid)) {
103
+ end();
104
+ return;
105
+ }
106
+ if (!hasLines(view)) return;
107
+ done.add(sid);
108
+ onFirst(sid);
109
+ end();
110
+ }, sid);
111
+ if (ended) d();
112
+ else watches.set(sid, d);
113
+ };
114
+ const sync = () => {
115
+ const live = new Set(mu.sessions.list().map((s) => s.id));
116
+ for (const sid of [...watches.keys()]) if (!live.has(sid)) stop(sid);
117
+ for (const sid of [...done]) if (!live.has(sid)) done.delete(sid);
118
+ for (const sid of live) ensure(sid);
119
+ };
120
+ sync();
121
+ const offSwitch = mu.sessions.on("switch", (s) => {
122
+ sync();
123
+ if (s) ensure(s.id);
124
+ });
125
+ const offLine = mu.sessions.on("line", (_l, { sid }) => {
126
+ if (!done.has(sid) && !watches.has(sid)) ensure(sid);
127
+ });
128
+ return () => {
129
+ alive = false;
130
+ offSwitch();
131
+ offLine();
132
+ for (const sid of [...watches.keys()]) stop(sid);
133
+ };
134
+ }
135
+
136
+ // src/panel.ts
137
+ function createPanel(mu) {
138
+ const c = mu.ui.css;
139
+ return defineComponent({
140
+ name: "FeedsPanel",
141
+ props: { sid: { type: String, default: null }, worldId: { type: String, default: null }, params: { type: Object, default: () => ({}) } },
142
+ setup(props) {
143
+ const view = shallowRef(null);
144
+ let off = null;
145
+ watch(() => props.sid, (sid) => {
146
+ off?.();
147
+ off = null;
148
+ view.value = null;
149
+ if (sid) off = mu.feeds.watch((v) => {
150
+ view.value = v;
151
+ }, sid);
152
+ }, { immediate: true });
153
+ const solo = computed(() => soloOf(props.params));
154
+ const labels = computed(() => labelsOf(view.value, solo.value));
155
+ const sel = ref("");
156
+ watch(labels, (ls) => {
157
+ sel.value = keepSelection(ls, sel.value);
158
+ }, { immediate: true });
159
+ const tell = (label) => {
160
+ if (props.sid) mu.feeds.viewing(label || null, props.sid);
161
+ };
162
+ watch([sel, () => props.sid], () => tell(sel.value), { immediate: true });
163
+ const lines = computed(() => sel.value ? view.value?.lines[sel.value] ?? [] : []);
164
+ const searching = ref(false), q = ref(""), times = ref(false);
165
+ const shown = computed(() => filterLines(lines.value, q.value));
166
+ const box = ref(null);
167
+ const search = ref(null);
168
+ const tail = ref(newTail());
169
+ const scrollEnd = () => nextTick(() => {
170
+ if (box.value) box.value.scrollTop = box.value.scrollHeight;
171
+ });
172
+ const markRead = () => {
173
+ if (sel.value && (view.value?.unread[sel.value] ?? 0) > 0) tell(sel.value);
174
+ };
175
+ watch([sel, shown], () => {
176
+ tail.value = stepTail(tail.value, sel.value, shown.value);
177
+ if (tail.value.atEnd) {
178
+ scrollEnd();
179
+ markRead();
180
+ }
181
+ }, { immediate: true });
182
+ const setAtEnd = (atEnd) => {
183
+ if (atEnd === tail.value.atEnd) return;
184
+ tail.value = { ...tail.value, atEnd, fresh: atEnd ? 0 : tail.value.fresh };
185
+ if (atEnd) markRead();
186
+ };
187
+ const onScroll = () => {
188
+ if (box.value?.clientHeight) setAtEnd(nearEnd(box.value));
189
+ };
190
+ const toEnd = () => {
191
+ setAtEnd(true);
192
+ scrollEnd();
193
+ };
194
+ const tabs = /* @__PURE__ */ new Map();
195
+ const tabKey = (e, i) => {
196
+ const j = tabTarget(e.key, i, labels.value.length);
197
+ if (j < 0) return;
198
+ e.preventDefault();
199
+ sel.value = labels.value[j];
200
+ nextTick(() => tabs.get(sel.value)?.focus());
201
+ };
202
+ const toggleSearch = () => {
203
+ searching.value = !searching.value;
204
+ if (!searching.value) q.value = "";
205
+ else nextTick(() => search.value?.focus());
206
+ };
207
+ const clear = () => {
208
+ if (props.sid && sel.value && confirm(COPY.confirmClear(sel.value))) mu.feeds.clear(sel.value, props.sid);
209
+ };
210
+ const editRules = () => mu.commands.run("settings.open", "feeds");
211
+ const popOut = () => {
212
+ if (!sel.value) return;
213
+ mu.panels.open("feed", { feed: sel.value, instance: sel.value }, { title: COPY.popTitle(sel.value), ...props.sid ? { sid: props.sid } : {} });
214
+ };
215
+ onBeforeUnmount(() => {
216
+ off?.();
217
+ off = null;
218
+ if (!solo.value) tell(null);
219
+ });
220
+ const tool = (label, attrs, on = false) => h("button", { type: "button", class: [c.tool, { on }], ...attrs }, label);
221
+ const spanOf = (s, k) => s.href ? h("a", { key: k, href: s.href, target: "_blank", rel: "noopener", class: s.cls, style: s.style }, s.text) : h("span", { key: k, class: s.cls, style: s.style }, s.text);
222
+ return () => {
223
+ const ls = labels.value, cur = sel.value;
224
+ if (!ls.length) {
225
+ return h("div", { class: "mu-feeds", "data-testid": "feeds-panel" }, [
226
+ h("div", { class: "intro" }, [h("button", { type: "button", class: [c.cmd, "primary", "make"], onClick: editRules }, COPY.addFeed)])
227
+ ]);
228
+ }
229
+ const bar = h("div", { class: "fbar" }, [
230
+ solo.value ? h("span", { class: "solo" }, solo.value) : h("div", { class: "ftabs", role: "tablist", "aria-label": "Feeds" }, ls.map((l, i) => {
231
+ const n = unreadOf(view.value, l, cur);
232
+ return h("button", {
233
+ key: l,
234
+ type: "button",
235
+ role: "tab",
236
+ class: ["ftab", { on: l === cur }],
237
+ ref: (el) => {
238
+ if (el) tabs.set(l, el);
239
+ else tabs.delete(l);
240
+ },
241
+ "aria-selected": l === cur,
242
+ tabindex: l === cur ? 0 : -1,
243
+ "aria-label": tabLabel(l, n),
244
+ onClick: () => {
245
+ sel.value = l;
246
+ },
247
+ onKeydown: (e) => tabKey(e, i)
248
+ }, [l, n ? h("span", { class: ["fbadge", c.count], "aria-hidden": "true" }, String(n)) : null]);
249
+ })),
250
+ h("span", { class: "tools", role: "toolbar", "aria-label": "Feed tools" }, [
251
+ tool(COPY.search, { "aria-pressed": searching.value, "aria-label": "Search this feed", title: "search", onClick: toggleSearch }, searching.value),
252
+ tool(COPY.times, { "aria-pressed": times.value, "aria-label": "Timestamps", title: "timestamps", onClick: () => {
253
+ times.value = !times.value;
254
+ } }, times.value),
255
+ !solo.value && cur ? tool(COPY.popOut, { title: "own panel", "aria-label": `Open ${cur} in its own panel`, onClick: popOut }) : null,
256
+ tool(COPY.rules, { "aria-label": "Edit feed rules", title: "rules", onClick: editRules }),
257
+ cur ? h("button", { type: "button", class: [c.tool, "danger"], title: "clear", "aria-label": `Clear ${cur} feed`, onClick: clear }, COPY.clear) : null
258
+ ])
259
+ ]);
260
+ const searchRow = searching.value ? h("div", { class: "fsearch" }, [
261
+ h("input", {
262
+ ref: search,
263
+ class: c.field,
264
+ value: q.value,
265
+ placeholder: `search ${cur}\u2026`,
266
+ "aria-label": `Search ${cur}`,
267
+ onInput: (e) => {
268
+ q.value = e.target.value;
269
+ },
270
+ onKeydown: (e) => {
271
+ if (e.key === "Escape") {
272
+ searching.value = false;
273
+ q.value = "";
274
+ }
275
+ }
276
+ }),
277
+ h("span", { class: "cnt", "aria-live": "polite" }, [String(shown.value.length), " ", h("span", { class: "sr-only" }, "matching lines")])
278
+ ]) : null;
279
+ const rows = shown.value.map((l) => h("div", { key: l.id, class: ["fline", l.rowCls], "data-testid": "feed-line" }, [
280
+ times.value ? h("span", { class: "ts" }, hhmmss(l.ts)) : null,
281
+ ...l.spans.map(spanOf)
282
+ ]));
283
+ const t = tail.value;
284
+ const pill = t.atEnd ? null : t.fresh > 0 ? h("button", { type: "button", class: "latest", onClick: toEnd }, COPY.latest(t.fresh)) : h("button", { type: "button", class: "latest", "aria-label": "Jump to the latest line", onClick: toEnd }, "\u2193");
285
+ return h("div", { class: "mu-feeds", "data-testid": "feeds-panel" }, [
286
+ bar,
287
+ searchRow,
288
+ h("div", { class: "lines-wrap" }, [
289
+ h("div", {
290
+ ref: box,
291
+ class: "flines",
292
+ role: "log",
293
+ "aria-live": "off",
294
+ tabindex: 0,
295
+ "aria-label": `${cur} feed`,
296
+ onScroll,
297
+ onWheel: (e) => {
298
+ if (e.deltaY < 0) setAtEnd(false);
299
+ }
300
+ }, [...rows, shown.value.length ? null : h("p", { class: [c.empty, "fempty"] }, q.value.trim() ? COPY.noMatches : COPY.empty)]),
301
+ pill
302
+ ])
303
+ ]);
304
+ };
305
+ }
306
+ });
307
+ }
308
+
309
+ // src/index.ts
310
+ var index_default = defineExtension({
311
+ activate(ctx) {
312
+ const mu = ctx.mu;
313
+ mu.ui.style(FEEDS_CSS);
314
+ const mount = mu.panels.vue(createPanel(mu));
315
+ mu.panels.register({ id: "feeds", title: COPY.title, mount, perSession: true, defaultPosition: "right-bottom", order: 50 });
316
+ mu.panels.register({ id: "feed", title: COPY.feedTitle, mount, perSession: true, singleton: false, defaultPosition: "float", inViewsMenu: false });
317
+ ctx.subscriptions.push(watchFirstLines(mu, (sid) => mu.panels.autoAdd("feeds", sid)));
318
+ }
319
+ });
320
+ export {
321
+ index_default as default
322
+ };
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@runmu.sh/ext-feeds",
3
+ "version": "1.0.0",
4
+ "description": "The Feeds panel: a tab per feed your routing rules fill (Settings → Feeds), with unread badges, search, timestamps, pop-out and clear.",
5
+ "keywords": [
6
+ "feeds",
7
+ "rules",
8
+ "routing",
9
+ "spawns",
10
+ "panel"
11
+ ],
12
+ "license": "MIT",
13
+ "homepage": "https://runmu.sh/marketplace/x/feeds",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/runmu-sh/ext-feeds.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/runmu-sh/ext-feeds/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": "feeds",
47
+ "api": "^1.7",
48
+ "source": "src/index.ts",
49
+ "displayName": "Feeds",
50
+ "description": "The Feeds panel: a tab per feed your routing rules fill (Settings → Feeds), with unread badges, search, timestamps, pop-out and clear.",
51
+ "categories": [
52
+ "interface",
53
+ "communication"
54
+ ],
55
+ "contributes": {
56
+ "panels": [
57
+ {
58
+ "id": "feeds",
59
+ "title": "Feeds",
60
+ "defaultPosition": "right-bottom",
61
+ "order": 50
62
+ },
63
+ {
64
+ "id": "feed",
65
+ "title": "Feed",
66
+ "defaultPosition": "float",
67
+ "inViewsMenu": false
68
+ }
69
+ ]
70
+ },
71
+ "capabilities": [
72
+ "read-output"
73
+ ],
74
+ "publisher": "bitmuse"
75
+ }
76
+ }