/** * @name Exact Timestamps * @description Shows the full local date and time next to relative post times ("5h" becomes "5h · Sep 27, 2026, 14:03") and on hover. When the header is short on room the date gives way first (it shortens, then hides) and never covers X's buttons. * @version 1.2.0 * @author wayBetter * @permissions network:none */ module.exports = class ExactTimestamps { start() { const api = window.wayBetter.api; this.scope = api.scope(); // Dates in the app's language; a language switch redraws every date. const locale = () => (api.i18n ? api.i18n.locale.split('-x-')[0] : undefined); this.format = new Intl.DateTimeFormat(locale(), { dateStyle: 'medium', timeStyle: 'short' }); if (api.i18n) this.scope.defer(api.i18n.onChange(() => { this.format = new Intl.DateTimeFormat(locale(), { dateStyle: 'medium', timeStyle: 'short' }); for (const time of document.querySelectorAll('article time[data-bt-exact]')) this.decorate(time); })); // The date is its own flex item in X's header row (name · @handle · 2h | Grok ··· ). It shrinks // a thousand times faster than X's name block, so a narrow header cuts the date (with an // ellipsis, the full date stays in the tooltip) before it truncates the name, and X's buttons // on the right never move or get covered. this.scope.css('exact-timestamps', ` .bt-exact-time { flex: 0 1000 auto; min-width: 0; margin: 0 auto 0 4px; align-self: flex-start; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: 400 15px/20px var(--wb-font, inherit); color: var(--wb-muted, inherit); opacity: .85; } .bt-exact-time.bt-exact-inline { margin-right: 0; } `); this.scope.observe('article time[datetime]', time => this.decorate(time)); } stop() { this.scope.dispose(); // Per-element changes are marked in the DOM (no per-element closures that keep old nodes alive). for (const span of document.querySelectorAll('.bt-exact-time')) span.remove(); for (const time of document.querySelectorAll('time[data-bt-exact]')) { time.removeAttribute('title'); time.removeAttribute('data-bt-exact'); } } /** * Where the date goes: right after the name block in the post's header row (the row that also * holds X's Grok / ··· buttons), or, where a header has no buttons (quoted posts), right after the * time's own box. Returns the node to insert after and whether it is the header row. */ static placement(time) { const article = time.closest('article'); const link = time.closest('a') || time; const name = link.closest('[data-testid="User-Name"]'); // A quoted post (a role=link card inside the post) has no buttons of its own. const quoted = link.closest('div[role="link"]'); if (name && article && !(quoted && article.contains(quoted))) { // Nearest ancestor of the name block whose parent is a row that also holds X's header buttons. let node = name; for (let depth = 0; depth < 6 && node.parentElement && node.parentElement !== article; depth += 1, node = node.parentElement) { const row = node.parentElement; if (!row.querySelector('[data-testid="caret"], button[aria-label*="Grok" i]')) continue; if (node.querySelector('[data-testid="caret"]') || !getComputedStyle(row).flexDirection.startsWith('row')) break; return { after: node, header: true }; } } // X keeps the time in a box that never shrinks; the date goes after that box (the outermost // wrapper holding only the time), where it can shrink, not inside it. let box = link; while (name && box.parentElement && box.parentElement !== name && box.parentElement.childElementCount === 1 && name.contains(box.parentElement)) box = box.parentElement; return { after: box, header: false }; } decorate(time) { const date = new Date(time.getAttribute('datetime')); if (Number.isNaN(date.getTime())) return; const full = this.format.format(date); // X renders post times without a title; stop() removes the one set here. time.setAttribute('data-bt-exact', ''); time.setAttribute('title', full); // The focused post on a post page already prints the full date ("2:03 PM · Sep 27, 2026"). if ((time.textContent || '').includes('·')) return; const { after, header } = ExactTimestamps.placement(time); if (!after.parentNode) return; const text = `· ${full}`; const next = after.nextElementSibling; if (next?.classList.contains('bt-exact-time')) { if (next.textContent !== text) next.textContent = text; return; } // A header row keeps at most one date (X may re-render the name block next to an old one). if (header) for (const old of after.parentNode.querySelectorAll(':scope > .bt-exact-time')) old.remove(); const span = document.createElement('span'); span.className = header ? 'bt-exact-time' : 'bt-exact-time bt-exact-inline'; span.title = full; span.textContent = text; after.after(span); } };