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.0.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 = `
/* Wrapper injected as a child of the real element */
.dso-wrap {
display: inline-flex;
align-items: center;
gap: 2px;
pointer-events: none;
flex-shrink: 0;
user-select: none;
vertical-align: text-bottom;
}
/* "inline" mode – pushed to right inside a flex parent */
.dso-inline {
margin-left: auto;
padding-left: 6px;
}
/* "abs" mode – absolute-positioned to the right edge of the parent */
.dso-abs {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 99;
}
/* "abs-br" mode – absolute-positioned to the bottom-right corner */
.dso-abs-br {
position: absolute;
right: 4px;
bottom: 4px;
z-index: 99;
}
/* "abs-tr" mode – absolute-positioned to the top-right corner */
.dso-abs-tr {
position: absolute;
right: 4px;
top: 4px;
z-index: 99;
}
/* Key chips */
.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 2px 8px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.1);
min-height: 20px;
white-space: nowrap;
letter-spacing: 0;
}
/* Colour themes */
.dso-wrap.nav kbd { background: rgba( 18, 108, 55, .93); }
.dso-wrap.action kbd { background: rgba( 20, 78, 175, .93); }
.dso-wrap.write kbd { background: rgba(105, 35, 175, .93); }
.dso-wrap.post kbd { background: rgba(175, 65, 15, .93); }
/* "+" separator for combos like Ctrl+↵ */
.dso-wrap .dso-plus {
font: 700 9px/1 ui-monospace, monospace;
color: rgba(255,255,255,.5);
margin: 0 1px;
}
/* "→" separator for sequential keys like g→h */
.dso-wrap .dso-seq {
font: 700 9px/1 ui-monospace, monospace;
color: rgba(255,255,255,.45);
margin: 0 2px;
}
/* Label text (used in panels and timeline j/k) */
.dso-gnav-label {
font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
color: rgba(255,255,255,.35);
}
/* ── G-nav panel (injected above .nav-pills) ── */
.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;
}
/* Allow timeline j/k hints to overflow the scroller-content boundary */
.timeline-container .topic-timeline .timeline-scroller-content {
overflow: visible !important;
}
/* Hidden state – applied via body class so newly injected wraps are also hidden */
body.dso-hidden .dso-wrap { display: none !important; }
/* ── Toggle button ── */
#dso-toggle-btn {
position: fixed;
top: 14px;
right: 14px;
z-index: 99999;
cursor: pointer;
background: rgba(14,14,20,.85);
border: 1px solid rgba(255,255,255,.12);
border-radius: 6px;
color: rgba(255,255,255,.45);
font-size: 13px;
padding: 5px 7px;
line-height: 1;
backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0,0,0,.4);
}
#dso-toggle-btn:hover { color: rgba(255,255,255,.85); border-color: rgba(255,255,255,.25); }
#dso-toggle-btn.--off { color: rgba(255,255,255,.18); border-color: rgba(255,255,255,.06); }
`;
document.head.appendChild(css);
// ── Hint registry ────────────────────────────────────────────────────────────
// mode:
// "inline" – appended as flex child, pushed right via margin-left:auto
// "abs" – absolutely positioned to the right edge of the parent element
// (parent gets position:relative if it doesn't already have one)
// "sibling" – inserted as a sibling before beforeSel (no extra positioning)
// beforeSel: CSS selector (relative to target) – insert wrap before that child
// title: tooltip shown on hover
// hintClass: unique dso-hint-* class added to the wrap element
const HINTS = [];
// Converts a keys array into a stable CSS class name, e.g. ["⇧ L"] → "dso-hint-shift-l"
function keysToClass(keys) {
const part = 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("-");
return "dso-hint-" + part;
}
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 } });
}
// ── DOM helpers ──────────────────────────────────────────────────────────────
const $ = (s) => document.querySelector(s);
function buildWrap(keys, theme, mode, seq = false, title = "") {
const wrap = document.createElement("span");
const modeClass = mode === "abs" ? "dso-abs" : mode === "abs-br" ? "dso-abs-br" : mode === "abs-tr" ? "dso-abs-tr" : mode === "sibling" || mode === "after" ? "" : "dso-inline";
wrap.className = `dso-wrap ${theme} ${modeClass}`.trimEnd();
if (title) { wrap.title = title; }
keys.forEach((k, i) => {
if (i > 0) {
const sep = document.createElement("span");
if (seq) {
sep.className = "dso-seq";
sep.textContent = "→";
} else {
sep.className = "dso-plus";
sep.textContent = "+";
}
wrap.appendChild(sep);
}
const kbd = document.createElement("kbd");
kbd.textContent = k;
wrap.appendChild(kbd);
});
return wrap;
}
function buildGNavPanel() {
const entries = [
{ 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 panel = document.createElement("div");
panel.className = "dso-wrap dso-gnav-panel nav";
entries.forEach(({ keys, label }) => {
const row = document.createElement("div");
row.className = "dso-gnav-row";
const kbdGroup = document.createElement("span");
kbdGroup.className = "dso-gnav-keys";
keys.forEach((k, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "dso-seq";
sep.textContent = "→";
kbdGroup.appendChild(sep);
}
const kbd = document.createElement("kbd");
kbd.textContent = k;
kbdGroup.appendChild(kbd);
});
const lbl = document.createElement("span");
lbl.className = "dso-gnav-label";
lbl.textContent = label;
row.appendChild(kbdGroup);
row.appendChild(lbl);
panel.appendChild(row);
});
return panel;
}
// j↓ / k↑ stacked vertically – positioned to the right of .timeline-scroller-content
function buildTimelineJK() {
const wrap = document.createElement("span");
wrap.className = "dso-wrap nav";
Object.assign(wrap.style, {
position: "absolute",
left: "calc(100% + 8px)",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "3px",
zIndex: "99",
});
[{ key: "k ↑", label: "Prev post" }, { key: "j ↓", label: "Next post" }].forEach(({ key, label }) => {
const row = document.createElement("span");
Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "4px" });
const kbd = document.createElement("kbd");
kbd.textContent = key;
row.appendChild(kbd);
const lbl = document.createElement("span");
lbl.className = "dso-gnav-label";
lbl.textContent = label;
row.appendChild(lbl);
wrap.appendChild(row);
});
return wrap;
}
// g→k / g→j panel – placed to the right of the topic title
function buildTopicNavHints() {
const wrap = document.createElement("span");
wrap.className = "dso-wrap nav";
Object.assign(wrap.style, {
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "4px",
alignItems: "flex-start",
zIndex: "99",
});
[{ keys: ["g", "k"], label: "Prev topic" },
{ keys: ["g", "j"], label: "Next topic" }].forEach(({ keys, label }) => {
const row = document.createElement("span");
Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "4px" });
keys.forEach((k, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "dso-seq";
sep.textContent = "→";
row.appendChild(sep);
}
const kbd = document.createElement("kbd");
kbd.textContent = k;
row.appendChild(kbd);
});
const lbl = document.createElement("span");
lbl.className = "dso-gnav-label";
lbl.textContent = label;
row.appendChild(lbl);
wrap.appendChild(row);
});
return wrap;
}
// m→w / m→t / m→r / m→m panel – appended in .timeline-footer-controls
function buildTopicTrackingPanel() {
const wrap = document.createElement("span");
wrap.className = "dso-wrap nav";
Object.assign(wrap.style, {
flexDirection: "column",
gap: "3px",
});
[
{ keys: ["m", "w"], label: "Watch" },
{ keys: ["m", "t"], label: "Track" },
{ keys: ["m", "r"], label: "Normal" },
{ keys: ["m", "m"], label: "Mute" },
].forEach(({ keys, label }) => {
const row = document.createElement("span");
Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "3px" });
keys.forEach((k, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "dso-seq";
sep.textContent = "→";
row.appendChild(sep);
}
const kbd = document.createElement("kbd");
kbd.textContent = k;
row.appendChild(kbd);
});
const lbl = document.createElement("span");
lbl.className = "dso-gnav-label";
lbl.textContent = label;
row.appendChild(lbl);
wrap.appendChild(row);
});
return wrap;
}
// Ctrl+K, Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc panel – appended at end of .chat-drawer-content
function buildChatComposerHints() {
const wrap = document.createElement("span");
wrap.className = "dso-wrap action";
Object.assign(wrap.style, {
flexWrap: "wrap",
gap: "6px 12px",
padding: "4px 6px",
borderTop: "1px solid rgba(255,255,255,.08)",
marginTop: "2px",
});
[
{ keys: ["Ctrl", "K"], label: "Quick channel" },
{ keys: ["Alt", "↑ / ↓"], label: "Switch channel" },
{ keys: ["Alt", "⇧ ↑ / ↓"], label: "Switch unread" },
{ keys: ["⇧", "Esc"], label: "Mark all read" },
].forEach(({ keys, label }) => {
const row = document.createElement("span");
Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "3px" });
keys.forEach((k, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "dso-plus";
sep.textContent = "+";
row.appendChild(sep);
}
const kbd = document.createElement("kbd");
kbd.textContent = k;
row.appendChild(kbd);
});
const lbl = document.createElement("span");
lbl.className = "dso-gnav-label";
lbl.textContent = label;
row.appendChild(lbl);
wrap.appendChild(row);
});
return wrap;
}
// ── 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
// ① Remove from old target
if (state.wrap) { state.wrap.remove(); }
if (state.el && 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; }
// ② For "abs"/"abs-br"/"abs-tr" mode: make parent position:relative if it's static
if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
const cur = getComputedStyle(target).position;
if (cur === "static") {
target.dataset.dsoPos = ""; // mark as modified (empty = was static)
target.style.position = "relative";
}
}
// ③ Build and inject
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") {
// Insert as next sibling of the target, not as a child
insertParent = target.parentNode;
insertRef = target.nextSibling;
} else {
const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
// insertBefore requires the reference to be a direct child of the parent,
// so use beforeEl.parentNode rather than target when they differ.
insertParent = beforeEl ? beforeEl.parentNode : target;
insertRef = beforeEl;
}
insertParent.insertBefore(wrap, insertRef);
state.wrap = 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 (top-right of whichever result list is visible)
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 (text button → inline, kbd appears after the label)
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");
// . → show new/updated topics (the banner that appears at the top of the list)
hint(["."], "action",
() => $(".show-more.has-topics a"),
"inline", null, false, "Load new topics");
// ── Topic list ──────────────────────────────────────────────────────────────
// j → row ABOVE the selected one (or first row when nothing is selected).
// k → row BELOW the selected one (hidden when nothing is selected).
// o → the selected row's title link.
//
// Excludes items inside .more-topics__list / .suggested-topics (related topics
// section at the bottom of topic view) 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"));
// No selection → first row; selected → row below it
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"));
// Only shown when something is selected and there is a row above it
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 section (advances the active nav-pill tab)
hint(["⇧ J"], "nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
if (!pills.length) { return null; }
const activeIdx = pills.findIndex((p) => p.classList.contains("active"));
return pills[activeIdx + 1] || null;
},
"inline", null, false, "Next section");
hint(["⇧ K"], "nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
if (!pills.length) { return null; }
const activeIdx = pills.findIndex((p) => p.classList.contains("active"));
if (activeIdx <= 0) { return null; }
return pills[activeIdx - 1] || null;
},
"inline", null, false, "Prev section");
// ── Composer ─────────────────────────────────────────────────────────────────
// Ctrl+↵ → submit (injected into the create/send button)
hint(["Ctrl", "↵"], "write",
() => $(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
"inline", null, false, "Submit");
// Esc → close (injected into the cancel button)
hint(["Esc"], "write",
() => $(".save-or-cancel .cancel, .reply-area .cancel"),
"inline", null, false, "Cancel");
// ⇧ C → return to minimized composer (only visible when composer is a draft)
hint(["⇧ C"], "write",
() => $("#reply-control.draft .draft-text"),
"inline", null, false, "Return to composer");
// ⇧ F11 → fullscreen (not shown when composer is in draft/minimized state)
hint(["⇧ F11"], "write",
() => $("#reply-control.draft") ? null : $(".toggle-fullscreen"),
"after", null, false, "Fullscreen");
// Esc → collapse/minimize (inserted as sibling after the button, avoids overflow clipping)
hint(["Esc"], "write",
() => $(".toggle-minimize"),
"after", null, false, "Minimize");
// ── Composer options (+) popup items (only in DOM when the popup is open) ─────
// Ctrl+E → preformatted text
hint(["Ctrl", "E"], "write",
() => $('[data-name="format-code"]'),
"abs-br", null, false, "Preformatted text");
// Ctrl+⇧8 → bulleted list
hint(["Ctrl", "⇧ 8"], "write",
() => $('[data-name="apply-unordered-list"]'),
"abs-br", null, false, "Bulleted list");
// Ctrl+⇧7 → ordered list
hint(["Ctrl", "⇧ 7"], "write",
() => $('[data-name="apply-ordered-list"]'),
"abs-br", null, false, "Ordered list");
// ── Composer heading popup items (only in DOM when the popup is open) ─────────
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");
// Ctrl+L → insert link (composer toolbar hyperlink button)
hint(["Ctrl", "L"], "write",
() => $('.toolbar__button[title="Hyperlink"]'),
"abs-br", null, false, "Insert link");
// ── Post actions ─────────────────────────────────────────────────────────────
// Prefer the keyboard-selected post; fall back to the first post.
function postBtn(sel) {
return $(
`.topic-post.selected .post-controls ${sel}, ` +
`.topic-post.selected .actions ${sel}, ` +
`.topic-post:first-child .post-controls ${sel}, ` +
`.topic-post:first-child .actions ${sel}`
);
}
hint(["r"], "post", () => postBtn("button.reply"), "inline", null, false, "Reply to post");
hint(["e"], "post", () => postBtn("button.edit"), "inline", null, false, "Edit post");
hint(["l"], "post", () => postBtn("button.toggle-like"), "inline", null, false, "Like post");
hint(["b"], "post", () => postBtn(".post-action-menu__bookmark"), "inline", null, false, "Bookmark post");
hint(["s"], "post",
() => {
const sel = $(
".topic-post.selected .post-action-menu__share, " +
".topic-post.selected .actions .share"
);
return sel || $(
".topic-post:first-child .post-action-menu__share, " +
".topic-post:first-child .actions .share, " +
".topic-post:first-child a.post-date"
);
},
"inline", null, false, "Share post");
hint(["q"], "post", () => postBtn("button.quote-post"), "inline", null, false, "Quote post");
hint(["!"], "post", () => postBtn(".post-action-menu__flag, button.create-flag"), "inline", null, false, "Flag post");
hint(["d"], "post", () => postBtn(".post-action-menu__delete, button.delete"), "inline", null, false, "Delete post");
// ── Topic view – post navigation ─────────────────────────────────────────────
// j on the post below selected (or first post); k on the post above selected.
// Only active in topic view (detected via .timeline-container, not by absence
// of .topic-list-item which also exists in the related-topics section).
hint(["j ↓"], "nav",
() => {
if (!$(".timeline-container")) { return null; }
const posts = [...document.querySelectorAll(".topic-post")];
if (!posts.length) { return null; }
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
if (selIdx === -1) { return posts[0] || null; }
if (selIdx >= posts.length - 1) { return null; }
return posts[selIdx + 1] || null;
},
"abs", null, false, "Next post");
hint(["k ↑"], "nav",
() => {
if (!$(".timeline-container")) { return null; }
const posts = [...document.querySelectorAll(".topic-post")];
if (!posts.length) { return null; }
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
if (selIdx <= 0) { return null; }
return posts[selIdx - 1] || null;
},
"abs", null, false, "Prev post");
// j/k on the timeline scroller – positioned to its right via abs on the element itself
HINTS.push({
keys: null, theme: "nav", mode: "abs", beforeSel: null, seq: false,
hintClass: "dso-hint-timeline-jk",
targetFn: () => {
if (!$(".timeline-container")) { return null; }
return $(".timeline-scroller-content") || null;
},
customBuild: buildTimelineJK,
state: { el: null, wrap: null },
});
// # → jump to post number (opens timeline input in topic view)
hint(["#"], "nav",
() => {
if (!$(".timeline-container")) { return null; }
return $(".timeline-date-wrapper") || null;
},
"inline", null, false, "Jump to post");
// ⇧L → go to first unread post – inserted at the left of the timeline footer
hint(["⇧ L"], "nav",
() => {
if (!$(".timeline-container")) { return null; }
return $(".timeline-footer-controls") || null;
},
"sibling", ":scope > :first-child", 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 (sequential: press g, then the second key) ──────────────────
// u + g→h … g→t Navigation panel injected above .nav-pills
HINTS.push({
keys: null, theme: "nav", mode: "sibling", seq: true,
hintClass: "dso-hint-gnav",
targetFn: () => $(
".navigation-container, " +
".list-controls .container, " +
".navigation-bar"
),
beforeSel: ".nav-pills",
customBuild: buildGNavPanel,
state: { el: null, wrap: null },
});
// g→b Bookmarks (sidebar link or user-menu when open)
hint(["g", "b"], "nav",
() => $(
".sidebar-section-link-wrapper a[href*='/bookmarks'], " +
".user-menu a[href*='/bookmarks']"
),
"inline", null, true, "Bookmarks");
// g→m Messages
hint(["g", "m"], "nav",
() => $(
".sidebar-section-link-wrapper a[href*='/messages'], " +
".user-menu a[href*='/messages']"
),
"inline", null, true, "Messages");
// g→d Drafts
hint(["g", "d"], "nav",
() => $(
".sidebar-section-link-wrapper a[href*='/drafts'], " +
".user-menu a[href*='/drafts']"
),
"inline", null, true, "Drafts");
// g→p Profile tab in user menu (only when user menu is open)
hint(["g", "p"], "nav",
() => $("#user-menu-button-profile"),
"inline", null, true, "Profile");
// g→k / g→j Prev/Next topic – panel to the right of the topic title (topic pages only)
HINTS.push({
keys: null, theme: "nav", mode: "abs", beforeSel: null, seq: true,
hintClass: "dso-hint-topic-nav",
targetFn: () => {
if (!$(".timeline-container")) { return null; }
return $("#topic-title, .topic-title") || null;
},
customBuild: buildTopicNavHints,
state: { el: null, wrap: null },
});
// ── Header buttons ────────────────────────────────────────────────────────────
// = → toggle sidebar / hamburger menu
hint(["="], "action",
() => $(".header-sidebar-toggle"),
"abs-br", null, false, "Sidebar");
// p → user profile menu
hint(["p"], "action",
() => $("#current-user"),
"abs-br", null, false, "Profile");
// ── Topic actions (topic view) ────────────────────────────────────────────────
// f → bookmark topic (opens bookmark menu in the footer)
hint(["f"], "post",
() => {
if (!$(".timeline-container")) { return null; }
return $('[data-identifier="bookmark-menu"]') || null;
},
"inline", null, false, "Bookmark topic");
// ⇧ S → share topic (footer share button)
hint(["⇧ S"], "post",
() => $("#topic-footer-buttons button.share-and-invite"),
"inline", null, false, "Share topic");
// ⇧ P → pin/unpin topic for current user
hint(["⇧ P"], "nav",
() => $(".pinned-button button"),
"inline", null, false, "Pin/Unpin");
// ⇧ U → defer topic (mark as unread)
hint(["⇧ U"], "nav",
() => {
if (!$(".timeline-container")) { return null; }
return $("#topic-footer-buttons button.defer-topic") || null;
},
"inline", null, false, "Mark unread");
// ⇧ A → open topic admin actions menu
hint(["⇧ A"], "action",
() => $(".toggle-admin-menu"),
"abs-br", null, false, "Admin actions");
// a → archive personal message / move to inbox
hint(["a"], "action",
() => {
if (!$(".timeline-container")) { return null; }
return $("#topic-footer-buttons button.archive-topic") || null;
},
"inline", null, false, "Archive");
// m→w / m→t / m→r / m→m Notification level panel – appended in .timeline-footer-controls
HINTS.push({
keys: null, theme: "nav", mode: "inline", beforeSel: null, seq: true,
hintClass: "dso-hint-tracking",
targetFn: () => {
if (!$(".timeline-container")) { return null; }
return $(".timeline-footer-controls") || null;
},
customBuild: buildTopicTrackingPanel,
state: { el: null, wrap: null },
});
// ── Bulk select ───────────────────────────────────────────────────────────────
// ⇧ B → toggle bulk select mode (button only present when nav controls show it)
hint(["⇧ B"], "action",
() => $("button.bulk-select"),
"abs-br", null, false, "Bulk select");
// ⇧ D → dismiss read/new topics
hint(["⇧ D"], "action",
() => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
"inline", null, false, "Dismiss");
// X → toggle selection of focused row (only visible in bulk select mode)
hint(["x"], "nav",
() => {
const sel = $(".topic-list-item.selected td.bulk-select");
return sel || $(".topic-list-item td.bulk-select");
},
"abs-br", null, false, "Select row");
// ── Logout ───────────────────────────────────────────────────────────────────
// ⇧Z ⇧Z → log out (sequential shift-z twice)
hint(["⇧ Z", "⇧ Z"], "action",
() => $("li.logout button"),
"inline", null, true, "Log out");
// ── Chat ─────────────────────────────────────────────────────────────────────
// - → toggle chat drawer (chat icon button in header)
hint(["-"], "action",
() => $(".chat-header-icon"),
"abs-br", null, false, "Toggle chat");
// Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K → panel at end of .chat-drawer-content
HINTS.push({
keys: null, theme: "action", mode: "inline", beforeSel: null, seq: false,
hintClass: "dso-hint-chat-composer",
targetFn: () => $(".chat-drawer-content"),
customBuild: buildChatComposerHints,
state: { el: null, wrap: null },
});
// Esc → close chat drawer (abs-br on the close-drawer button)
hint(["Esc"], "action",
() => $(".c-navbar__close-drawer-button"),
"after", null, false, "Close chat");
// ── Toggle button ─────────────────────────────────────────────────────────────
const toggleBtn = document.createElement("button");
toggleBtn.id = "dso-toggle-btn";
toggleBtn.title = "Toggle shortcut overlay";
toggleBtn.textContent = "⌨";
toggleBtn.classList.add("--off");
document.body.appendChild(toggleBtn);
toggleBtn.addEventListener("click", () => { window.__dso__.toggle(); });
// ── MutationObserver ─────────────────────────────────────────────────────────
// Re-checks hints whenever Ember re-renders the DOM.
// Ignores mutations caused by our own injections to avoid re-trigger loops.
let rafPending = false;
function scheduleRefresh() {
if (!rafPending) {
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
HINTS.forEach(inject);
});
}
}
const observer = new MutationObserver((mutations) => {
const onlyOurs = mutations.every((m) => {
// Attribute mutations (e.g. Discourse adding "selected" to a row) are
// never caused by us — always treat them as external.
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,
// Watch class changes so we react when Discourse adds/removes "selected"
attributes: true,
attributeFilter: ["class"],
});
// Refresh on focus changes so / ↔ Ctrl+F swap reacts to input focus
document.addEventListener("focusin", scheduleRefresh, { passive: true });
document.addEventListener("focusout", scheduleRefresh, { passive: true });
// Initial injection pass (hidden by default via body class)
document.body.classList.add("dso-hidden");
HINTS.forEach(inject);
// ── Public API ───────────────────────────────────────────────────────────────
let visible = false;
window.__dso__ = {
stop() {
observer.disconnect();
document.removeEventListener("focusin", scheduleRefresh);
document.removeEventListener("focusout", scheduleRefresh);
HINTS.forEach(({ state }) => {
if (state.wrap) { state.wrap.remove(); }
if (state.el && 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;
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.


