This userscript shows Discourse shortcuts directly on the forum:
It’s disabled by default, click the top right icon to toggle it. ![]()
It’s not meant to be beautiful. Just enable it when you feel you want to use and memorize shortcuts ![]()
It doesn’t show all existing shortcuts, but most of them.
Bookmark-related shortcuts are not (yet?) included, there are many of them for what is a niche section of the site.
There are a few shortcuts I don’t understand in the shortcut list ?:
-
Ctrl + l → Filter sidebar
-
Topic selection:
Shift + d → Dismiss selected topics -
a Insert selection into open composer
If someone can explain to me what they do ![]()
Full code
// ==UserScript==
// @name Discourse Shortcut Overlay
// @namespace https://meta.discourse.org/
// @version 1.1
// @description Injects kbd hints directly into Discourse UI elements to show keyboard shortcuts in context.
// @match https://meta.discourse.org/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function discourseShortcutOverlay() {
"use strict";
if (window.__dso__) {
window.__dso__.stop();
}
// ── CSS ─────────────────────────────────────────────────────────────────────
const css = document.createElement("style");
css.id = "__dso_style";
css.textContent = `
.dso-wrap {
display: inline-flex; align-items: center; gap: 2px;
pointer-events: none; flex-shrink: 0; user-select: none; vertical-align: text-bottom;
}
.dso-inline { margin-left: auto; padding-left: 6px; }
.dso-abs { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); z-index: 99; }
.dso-abs-br { position: absolute; right: 4px; bottom: 4px; z-index: 99; }
.dso-abs-tr { position: absolute; right: 4px; top: 4px; z-index: 99; }
.dso-wrap kbd {
display: inline-flex; align-items: center; justify-content: center;
padding: 0 5px; font: 700 11px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
color: #fff; border-radius: 4px; border-color: rgba(255,255,255,.22);
box-shadow: 0 1px 3px rgba(0,0,0,.2); min-height: 20px; white-space: nowrap; letter-spacing: 0;
}
.dso-wrap.nav kbd { background: color-mix(in srgb, var(--success) 70%, var(--secondary)); }
.dso-wrap.action kbd { background: color-mix(in srgb, var(--tertiary) 70%, var(--secondary)); }
.dso-wrap.write kbd { background: color-mix(in srgb, #8848b8 70%, var(--secondary)); }
.dso-wrap.post kbd { background: color-mix(in srgb, var(--danger) 70%, var(--secondary)); }
.dso-wrap .dso-plus { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.5); margin: 0 1px; }
.dso-wrap .dso-seq { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.45); margin: 0 2px; }
.dso-gnav-label { font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; color: rgba(255,255,255,.35); }
.dso-gnav-panel { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 4px 0 6px; vertical-align: unset; }
.dso-gnav-panel .dso-gnav-row { display: inline-flex; align-items: center; gap: 5px; }
.dso-gnav-panel .dso-gnav-keys { display: inline-flex; align-items: center; }
.timeline-container .topic-timeline .timeline-scroller-content { overflow: visible !important; }
.timeline-container .topic-timeline .timeline-date-wrapper { max-width: none !important; }
body.dso-hidden .dso-wrap { display: none !important; }
#dso-toggle-btn {
position: fixed; top: 14px; right: 14px; z-index: 99999; cursor: pointer;
background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px;
color: var(--primary-medium); font-size: 13px; padding: 5px 7px; line-height: 1;
box-shadow: 0 2px 6px rgba(0,0,0,.1);
}
#dso-toggle-btn:hover { color: var(--primary); border-color: var(--primary-medium); }
#dso-toggle-btn.--off { color: var(--primary-low); border-color: var(--primary-very-low, var(--primary-low)); }
`;
document.head.appendChild(css);
// ── Hint registry ─────────────────────────────────────────────────────────────
const HINTS = [];
const $ = (s) => document.querySelector(s);
function keysToClass(keys) {
return (
"dso-hint-" +
keys
.map((k) =>
k
.replace(/⇧\s*/g, "shift-")
.replace(/↵/g, "enter")
.replace(/↓/g, "down")
.replace(/↑/g, "up")
.replace(/\//g, "slash")
.replace(/\?/g, "question")
.replace(/!/g, "bang")
.replace(/#/g, "hash")
.replace(/\./g, "dot")
.replace(/=/g, "eq")
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
)
.join("-")
);
}
// Single-target hint
function hint(
keys,
theme,
targetFn,
mode = "inline",
beforeSel = null,
seq = false,
title = ""
) {
HINTS.push({
keys,
theme,
targetFn,
mode,
beforeSel,
seq,
title,
hintClass: keysToClass(keys),
customBuild: null,
state: { el: null, wrap: null },
});
}
// Multi-target hint – injects after every element matching multiSel
function hintMulti(
keys,
theme,
multiSel,
mode = "after",
seq = false,
title = ""
) {
HINTS.push({
keys,
theme,
multiSel,
mode,
seq,
title,
hintClass: keysToClass(keys),
state: { pairs: [] },
});
}
// Custom-panel hint
function hintPanel(theme, mode, hintClass, targetFn, buildFn, opts = {}) {
HINTS.push({
keys: null,
theme,
mode,
beforeSel: opts.beforeSel ?? null,
seq: opts.seq ?? false,
hintClass,
targetFn,
customBuild: buildFn,
state: { el: null, wrap: null },
});
}
// ── DOM builders ──────────────────────────────────────────────────────────────
// Micro-helper: create an element with properties assigned
function el(tag, props = {}) {
return Object.assign(document.createElement(tag), props);
}
function buildWrap(keys, theme, mode, seq = false, title = "") {
const modeClass =
{ abs: "dso-abs", "abs-br": "dso-abs-br", "abs-tr": "dso-abs-tr" }[
mode
] ?? (mode === "sibling" || mode === "after" ? "" : "dso-inline");
const wrap = el("span", {
className: `dso-wrap ${theme} ${modeClass}`.trimEnd(),
});
if (title) {
wrap.title = title;
}
keys.forEach((k, i) => {
if (i > 0) {
wrap.appendChild(
el("span", {
className: seq ? "dso-seq" : "dso-plus",
textContent: seq ? "→" : "+",
})
);
}
wrap.appendChild(el("kbd", { textContent: k }));
});
return wrap;
}
// Generic panel: rows of kbd sequences with optional labels.
// usePlus: use "+" separators (combos) instead of "→" (sequences).
function buildPanel(theme, entries, wrapStyle = {}, usePlus = false) {
const wrap = el("span", { className: `dso-wrap ${theme}` });
Object.assign(wrap.style, wrapStyle);
for (const { keys, label } of entries) {
const row = el("span");
Object.assign(row.style, {
display: "inline-flex",
alignItems: "center",
gap: "4px",
});
keys.forEach((k, i) => {
if (i > 0) {
row.appendChild(
el("span", {
className: usePlus ? "dso-plus" : "dso-seq",
textContent: usePlus ? "+" : "→",
})
);
}
row.appendChild(el("kbd", { textContent: k }));
});
if (label) {
row.appendChild(
el("span", { className: "dso-gnav-label", textContent: label })
);
}
wrap.appendChild(row);
}
return wrap;
}
// G-nav uses dedicated CSS classes for its wrapping layout
function buildGNavPanel() {
const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
for (const { keys, label } of [
{ keys: ["u"], label: "Back" },
{ keys: ["g", "h"], label: "Home" },
{ keys: ["g", "l"], label: "Latest" },
{ keys: ["g", "n"], label: "New" },
{ keys: ["g", "u"], label: "Unread" },
{ keys: ["g", "y"], label: "Unseen" },
{ keys: ["g", "c"], label: "Categories" },
{ keys: ["g", "t"], label: "Top" },
]) {
const kg = el("span", { className: "dso-gnav-keys" });
keys.forEach((k, i) => {
if (i > 0) {
kg.appendChild(
el("span", { className: "dso-seq", textContent: "→" })
);
}
kg.appendChild(el("kbd", { textContent: k }));
});
const row = el("div", { className: "dso-gnav-row" });
row.appendChild(kg);
row.appendChild(
el("span", { className: "dso-gnav-label", textContent: label })
);
panel.appendChild(row);
}
return panel;
}
// ── Injection logic ───────────────────────────────────────────────────────────
function inject(h) {
const target = h.targetFn();
const { state, keys, theme, mode, beforeSel } = h;
if (target === state.el) {
return;
} // no change – already injected
if (state.wrap) {
state.wrap.remove();
}
if (state.el?.dataset.dsoPos !== undefined) {
state.el.style.position = state.el.dataset.dsoPos || "";
delete state.el.dataset.dsoPos;
}
state.el = target;
state.wrap = null;
if (!target) {
return;
}
if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
if (getComputedStyle(target).position === "static") {
target.dataset.dsoPos = "";
target.style.position = "relative";
}
}
const wrap = h.customBuild
? h.customBuild()
: buildWrap(keys, theme, mode, h.seq, h.title);
if (h.hintClass) {
wrap.classList.add(h.hintClass);
}
let insertParent, insertRef;
if (mode === "after") {
insertParent = target.parentNode;
insertRef = target.nextSibling;
} else {
const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
insertParent = beforeEl ? beforeEl.parentNode : target;
insertRef = beforeEl;
}
insertParent.insertBefore(wrap, insertRef);
state.wrap = wrap;
}
function injectMulti(h) {
const { state } = h;
const targets = new Set(document.querySelectorAll(h.multiSel));
state.pairs = state.pairs.filter(({ el: e, wrap }) => {
if (!targets.has(e)) {
wrap.remove();
return false;
}
return true;
});
const covered = new Set(state.pairs.map((p) => p.el));
targets.forEach((target) => {
if (covered.has(target)) {
return;
}
const wrap = buildWrap(h.keys, h.theme, h.mode, h.seq, h.title);
if (h.hintClass) {
wrap.classList.add(h.hintClass);
}
target.parentNode?.insertBefore(wrap, target.nextSibling);
state.pairs.push({ el: target, wrap });
});
}
// ────────────────────────────────────────────────────────────────────────────
// HINT DEFINITIONS
// ────────────────────────────────────────────────────────────────────────────
// ── Header ───────────────────────────────────────────────────────────────────
// / → search (hidden when the input is focused)
hint(
["/"],
"action",
() => {
const input = $("#header-search-input");
if (!input || input === document.activeElement) {
return null;
}
return input.closest(".search-input--header")?.parentElement ?? null;
},
"sibling",
".show-advanced-search",
false,
"Search"
);
// ↑ / ↓ → navigate search results
hint(
["↑ / ↓"],
"action",
() =>
$(".search-input--header")
? $(".search-menu-initial-options, .search-menu-assistant") || null
: null,
"abs-tr",
null,
false,
"Navigate results"
);
// Ctrl+↵ → open full page search (only when input is focused)
hint(
["Ctrl", "↵"],
"action",
() => {
const input = $("#header-search-input");
if (!input || input !== document.activeElement) {
return null;
}
return input;
},
"after",
null,
false,
"Full page search"
);
// c → new topic
hint(
["c"],
"write",
() => $("#create-topic"),
"inline",
null,
false,
"New topic"
);
// ? → keyboard shortcuts modal
hint(
["?"],
"action",
() => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
"inline",
null,
false,
"Shortcuts"
);
// . → load new/updated topics banner
hint(
["."],
"action",
() => $(".show-more.has-topics a"),
"inline",
null,
false,
"Load new topics"
);
// ── Topic list ───────────────────────────────────────────────────────────────
// Excludes .more-topics__list / .suggested-topics so hints don't bleed into that area.
function mainListRows() {
return [...document.querySelectorAll(".topic-list-item")].filter(
(r) => !r.closest(".more-topics__list, .suggested-topics, .more-topics")
);
}
hint(
["j ↓"],
"nav",
() => {
const rows = mainListRows();
if (!rows.length) {
return null;
}
const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
if (selIdx === -1) {
return rows[0]?.querySelector(".main-link") || null;
}
if (selIdx >= rows.length - 1) {
return null;
}
return rows[selIdx + 1]?.querySelector(".main-link") || null;
},
"abs",
null,
false,
"Next topic"
);
hint(
["k ↑"],
"nav",
() => {
const rows = mainListRows();
if (!rows.length) {
return null;
}
const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
if (selIdx <= 0) {
return null;
}
return rows[selIdx - 1]?.querySelector(".main-link") || null;
},
"abs",
null,
false,
"Prev topic"
);
// o / Enter → open selected topic
hint(
["o / ↵"],
"nav",
() =>
$(
".topic-list tr.selected a.title, .topic-list-item.selected .main-link a, .topic-list-item.selected .topic-title a"
),
"inline",
null,
false,
"Open topic"
);
// ⇧j / ⇧k → next/previous nav-pill section
hint(
["⇧ j"],
"nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
const i = pills.findIndex((p) => p.classList.contains("active"));
return pills[i + 1] || null;
},
"inline",
null,
false,
"Next section"
);
hint(
["⇧ k"],
"nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
const i = pills.findIndex((p) => p.classList.contains("active"));
return i > 0 ? pills[i - 1] : null;
},
"inline",
null,
false,
"Prev section"
);
// ── Composer ──────────────────────────────────────────────────────────────────
hint(
["Ctrl", "↵"],
"write",
() =>
$(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
"inline",
null,
false,
"Submit"
);
hint(
["Esc"],
"write",
() => $(".save-or-cancel .cancel, .reply-area .cancel"),
"inline",
null,
false,
"Cancel"
);
// ⇧c → return to minimized composer
hint(
["⇧ c"],
"write",
() => $("#reply-control.draft .draft-text"),
"inline",
null,
false,
"Return to composer"
);
// ⇧F11 → fullscreen (hidden when composer is a draft)
hint(
["⇧ F11"],
"write",
() => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
"after",
null,
false,
"Fullscreen"
);
hint(
["Esc"],
"write",
() => $(".toggle-minimize"),
"after",
null,
false,
"Minimize"
);
// ── Composer options (+) popup ────────────────────────────────────────────────
hint(
["Ctrl", "e"],
"write",
() => $('[data-name="format-code"]'),
"abs-br",
null,
false,
"Preformatted text"
);
hint(
["Ctrl", "⇧ 8"],
"write",
() => $('[data-name="apply-unordered-list"]'),
"abs-br",
null,
false,
"Bulleted list"
);
hint(
["Ctrl", "⇧ 7"],
"write",
() => $('[data-name="apply-ordered-list"]'),
"abs-br",
null,
false,
"Ordered list"
);
hint(
["Ctrl", "Alt", "0"],
"write",
() => $('[data-name="heading-paragraph"]'),
"abs-br",
null,
false,
"Paragraph"
);
hint(
["Ctrl", "Alt", "1"],
"write",
() => $('[data-name="heading-1"]'),
"abs-br",
null,
false,
"Heading 1"
);
hint(
["Ctrl", "Alt", "2"],
"write",
() => $('[data-name="heading-2"]'),
"abs-br",
null,
false,
"Heading 2"
);
hint(
["Ctrl", "Alt", "3"],
"write",
() => $('[data-name="heading-3"]'),
"abs-br",
null,
false,
"Heading 3"
);
hint(
["Ctrl", "Alt", "4"],
"write",
() => $('[data-name="heading-4"]'),
"abs-br",
null,
false,
"Heading 4"
);
hint(
["Ctrl", "l"],
"write",
() => $('.toolbar__button[title="Hyperlink"]'),
"abs-br",
null,
false,
"Insert link"
);
// ── Post actions (injected on every visible post) ─────────────────────────────
hintMulti(
["r"],
"post",
".topic-post .post-controls button.reply",
"after",
false,
"Reply to post"
);
hintMulti(
["e"],
"post",
".topic-post .post-controls button.edit",
"after",
false,
"Edit post"
);
hintMulti(
["l"],
"post",
".topic-post .discourse-reactions-double-button, .topic-post .post-controls button.toggle-like",
"after",
false,
"Like post"
);
hintMulti(
["b"],
"post",
".topic-post .post-controls .post-action-menu__bookmark",
"after",
false,
"Bookmark post"
);
hintMulti(
["s"],
"post",
".topic-post a.post-date",
"after",
false,
"Share post"
);
hintMulti(
["q"],
"post",
".topic-post .post-controls button.quote-post",
"after",
false,
"Quote post"
);
hintMulti(
["!"],
"post",
".topic-post .post-controls .post-action-menu__flag, .topic-post .post-controls button.create-flag",
"after",
false,
"Flag post"
);
hintMulti(
["d"],
"post",
".topic-post .post-controls .post-action-menu__delete, .topic-post .post-controls button.delete",
"after",
false,
"Delete post"
);
// ── Topic view – post navigation ──────────────────────────────────────────────
hint(
["j ↓"],
"nav",
() => {
if (!$(".timeline-container")) {
return null;
}
const posts = [...document.querySelectorAll(".topic-post")];
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
if (selIdx === -1) {
return posts[0] || null;
}
return selIdx < posts.length - 1 ? posts[selIdx + 1] : null;
},
"abs",
null,
false,
"Next post"
);
hint(
["k ↑"],
"nav",
() => {
if (!$(".timeline-container")) {
return null;
}
const posts = [...document.querySelectorAll(".topic-post")];
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
return selIdx > 0 ? posts[selIdx - 1] : null;
},
"abs",
null,
false,
"Prev post"
);
// j/k on the timeline scroller – positioned to its right
hintPanel(
"nav",
"abs",
"dso-hint-timeline-jk",
() => $(".timeline-container") && ($(".timeline-scroller-content") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["k ↑"], label: "Prev post" },
{ keys: ["j ↓"], label: "Next post" },
],
{
position: "absolute",
left: "calc(100% + 8px)",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "3px",
zIndex: "99",
}
)
);
// # → jump to post number
hint(
["#"],
"nav",
() => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
"inline",
null,
false,
"Jump to post"
);
// ⇧l → go to first unread – placed inside the # area
hint(
["⇧ l"],
"nav",
() => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
"inline",
null,
false,
"First unread"
);
// ⇧r → reply to topic (footer button)
hint(
["⇧ r"],
"post",
() =>
$(
".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
),
"inline",
null,
false,
"Reply to topic"
);
// ── G-navigation ──────────────────────────────────────────────────────────────
// u + g→h … g→t – panel injected above .nav-pills
hintPanel(
"nav",
"sibling",
"dso-hint-gnav",
() =>
$(".navigation-container, .list-controls .container, .navigation-bar"),
buildGNavPanel,
{ beforeSel: ".nav-pills", seq: true }
);
hint(
["g", "b"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/bookmarks'], .user-menu a[href*='/bookmarks']"
),
"inline",
null,
true,
"Bookmarks"
);
hint(
["g", "m"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/messages'], .user-menu a[href*='/messages']"
),
"inline",
null,
true,
"Messages"
);
hint(
["g", "d"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/drafts'], .user-menu a[href*='/drafts']"
),
"inline",
null,
true,
"Drafts"
);
hint(
["g", "p"],
"nav",
() => $("#user-menu-button-profile"),
"inline",
null,
true,
"Profile"
);
// g→k / g→j – prev/next topic panel beside the topic title
hintPanel(
"nav",
"abs",
"dso-hint-topic-nav",
() =>
$(".timeline-container") &&
($(".title-wrapper #topic-title, .title-wrapper .topic-title") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["g", "k"], label: "Prev topic" },
{ keys: ["g", "j"], label: "Next topic" },
],
{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "4px",
alignItems: "flex-start",
zIndex: "99",
}
),
{ seq: true }
);
// ── Header buttons ────────────────────────────────────────────────────────────
hint(
["="],
"action",
() => $(".header-sidebar-toggle"),
"abs-br",
null,
false,
"Sidebar"
);
hint(
["p"],
"action",
() => $("#current-user"),
"abs-br",
null,
false,
"Profile"
);
// ── Topic actions ─────────────────────────────────────────────────────────────
// f → bookmark topic
hint(
["f"],
"post",
() =>
$(".timeline-container") &&
($('.topic-footer-main-buttons [data-identifier="bookmark-menu"]') ||
null),
"inline",
null,
false,
"Bookmark topic"
);
hint(
["⇧ s"],
"post",
() => $("#topic-footer-buttons button.share-and-invite"),
"inline",
null,
false,
"Share topic"
);
hint(
["⇧ p"],
"nav",
() => $(".pinned-button button"),
"inline",
null,
false,
"Pin/Unpin"
);
hint(
["⇧ u"],
"nav",
() =>
$(".timeline-container") &&
($("#topic-footer-buttons button.defer-topic") || null),
"inline",
null,
false,
"Mark unread"
);
hint(
["⇧ a"],
"action",
() => $(".toggle-admin-menu"),
"abs-br",
null,
false,
"Admin actions"
);
hint(
["a"],
"action",
() =>
$(".timeline-container") &&
($("#topic-footer-buttons button.archive-topic") || null),
"inline",
null,
false,
"Archive"
);
// m→w / m→t / m→r / m→m – notification level panel
hintPanel(
"nav",
"inline",
"dso-hint-tracking",
() => $(".timeline-container") && ($(".timeline-footer-controls") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["m", "w"], label: "Watch" },
{ keys: ["m", "t"], label: "Track" },
{ keys: ["m", "r"], label: "Normal" },
{ keys: ["m", "m"], label: "Mute" },
],
{ flexDirection: "column", gap: "3px" }
),
{ seq: true }
);
// ── Bulk select ───────────────────────────────────────────────────────────────
hint(
["⇧ b"],
"action",
() => $("button.bulk-select"),
"abs-br",
null,
false,
"Bulk select"
);
hint(
["⇧ d"],
"action",
() => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
"inline",
null,
false,
"Dismiss"
);
hint(
["x"],
"nav",
() =>
$(".topic-list-item.selected td.bulk-select") ||
$(".topic-list-item td.bulk-select"),
"abs-br",
null,
false,
"Select row"
);
// ── Logout ────────────────────────────────────────────────────────────────────
hint(
["⇧ z", "⇧ z"],
"action",
() => $("li.logout button"),
"inline",
null,
true,
"Log out"
);
// ── Chat ──────────────────────────────────────────────────────────────────────
hint(
["-"],
"action",
() => $(".chat-header-icon"),
"abs-br",
null,
false,
"Toggle chat"
);
// Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – panel appended to the chat drawer
hintPanel(
"action",
"inline",
"dso-hint-chat-composer",
() => $(".chat-drawer-content"),
() =>
buildPanel(
"action",
[
{ keys: ["Ctrl", "k"], label: "Quick channel" },
{ keys: ["Alt", "↑ / ↓"], label: "Switch channel" },
{ keys: ["Alt", "⇧ ↑ / ↓"], label: "Switch unread" },
{ keys: ["⇧", "Esc"], label: "Mark all read" },
],
{
flexWrap: "wrap",
gap: "6px 12px",
padding: "4px 6px",
borderTop: "1px solid rgba(255,255,255,.08)",
marginTop: "2px",
},
true
)
);
hint(
["Esc"],
"action",
() => $(".c-navbar__close-drawer-button"),
"after",
null,
false,
"Close chat"
);
// ── Toggle button ─────────────────────────────────────────────────────────────
let visible = localStorage.getItem("dso-visible") === "true";
const toggleBtn = el("button", {
id: "dso-toggle-btn",
title: "Toggle shortcut overlay",
textContent: "⌨",
});
if (!visible) {
toggleBtn.classList.add("--off");
}
document.body.appendChild(toggleBtn);
toggleBtn.addEventListener("click", () => {
window.__dso__.toggle();
});
// ── MutationObserver ──────────────────────────────────────────────────────────
let rafPending = false;
function scheduleRefresh() {
if (!rafPending) {
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
});
}
}
const observer = new MutationObserver((mutations) => {
const onlyOurs = mutations.every((m) => {
if (m.type === "attributes") {
return false;
}
return [...m.addedNodes, ...m.removedNodes].every(
(n) => n.nodeType !== 1 || n.classList?.contains("dso-wrap")
);
});
if (!onlyOurs) {
scheduleRefresh();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
document.addEventListener("focusin", scheduleRefresh, { passive: true });
document.addEventListener("focusout", scheduleRefresh, { passive: true });
document.body.classList.toggle("dso-hidden", !visible);
HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
// ── Public API ────────────────────────────────────────────────────────────────
window.__dso__ = {
stop() {
observer.disconnect();
document.removeEventListener("focusin", scheduleRefresh);
document.removeEventListener("focusout", scheduleRefresh);
HINTS.forEach(({ state }) => {
state.wrap?.remove();
state.pairs?.forEach(({ wrap }) => wrap.remove());
if (state.el?.dataset.dsoPos !== undefined) {
state.el.style.position = state.el.dataset.dsoPos || "";
delete state.el.dataset.dsoPos;
}
});
toggleBtn.remove();
document.body.classList.remove("dso-hidden");
css.remove();
delete window.__dso__;
console.log("%c[DSO] removed.", "color:#888");
},
toggle() {
visible = !visible;
localStorage.setItem("dso-visible", visible);
toggleBtn.classList.toggle("--off", !visible);
document.body.classList.toggle("dso-hidden", !visible);
},
};
console.log(
"%c[DSO] Discourse Shortcut Overlay active.",
"color:#6af;font-weight:bold"
);
})();
Replace or add URLs matches (// @match https://meta.discourse.org/*) if you want to use it on another Discourse forum.
To install userscripts, use a browser extension: Greasemonkey, Tampermonkey, ScriptCat, or any external software that provides such a functionality, for example AdGuard.




