wayBetter for X
This commit is contained in:
commit
2f1e35ba20
720 changed files with 147465 additions and 0 deletions
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @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);
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue