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.


