diff --git a/assets/reader-relative-time.js b/assets/reader-relative-time.js new file mode 100644 index 0000000..45a6ce2 --- /dev/null +++ b/assets/reader-relative-time.js @@ -0,0 +1,87 @@ +/** + * Relative timestamps — Alpine.js directive that converts absolute + * datetime attributes to human-friendly relative strings. + * + * Usage: + * + * The server-rendered absolute time stays as fallback for no-JS clients. + * Alpine enhances it to relative on hydration, updates every 60s for + * recent posts, and shows the absolute time on hover via title attribute. + * + * Format rules (matching Mastodon/Elk conventions): + * < 1 minute: "just now" + * < 60 minutes: "Xm" (e.g. "5m") + * < 24 hours: "Xh" (e.g. "3h") + * < 7 days: "Xd" (e.g. "2d") + * same year: "Mar 3" + * older: "Mar 3, 2025" + */ + +document.addEventListener("alpine:init", () => { + // eslint-disable-next-line no-undef + Alpine.directive("relative-time", (el) => { + const iso = el.getAttribute("datetime"); + if (!iso) return; + + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return; + + // Store the original formatted text as the title (hover tooltip) + const original = el.textContent.trim(); + if (original && !el.getAttribute("title")) { + el.setAttribute("title", original); + } + + function update() { + el.textContent = formatRelative(date); + } + + update(); + + // Only set up interval for recent posts (< 24h old) + const ageMs = Date.now() - date.getTime(); + if (ageMs < 86_400_000) { + const interval = setInterval(() => { + update(); + // Stop updating once older than 24h + if (Date.now() - date.getTime() >= 86_400_000) { + clearInterval(interval); + } + }, 60_000); + } + }); +}); + +/** + * Format a Date as a relative time string. + * @param {Date} date + * @returns {string} + */ +function formatRelative(date) { + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return "just now"; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m`; + + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h`; + + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 7) return `${diffDay}d`; + + // Older than 7 days — use formatted date + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const month = months[date.getMonth()]; + const day = date.getDate(); + + if (date.getFullYear() === new Date().getFullYear()) { + return `${month} ${day}`; + } + + return `${month} ${day}, ${date.getFullYear()}`; +} diff --git a/package.json b/package.json index 3dec390..cc32142 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.5.1", + "version": "2.5.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/layouts/ap-reader.njk b/views/layouts/ap-reader.njk index 96c897c..1b57d4c 100644 --- a/views/layouts/ap-reader.njk +++ b/views/layouts/ap-reader.njk @@ -7,6 +7,8 @@ {# Tab components — apExploreTabs #} + {# Relative timestamps — converts absolute dates to "5m", "3h", "2d" etc. #} + {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index c551cac..b31b578 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -60,7 +60,7 @@ {% if item.published %} - diff --git a/views/partials/ap-notification-card.njk b/views/partials/ap-notification-card.njk index 8f729d2..487833f 100644 --- a/views/partials/ap-notification-card.njk +++ b/views/partials/ap-notification-card.njk @@ -68,7 +68,7 @@ {# Timestamp #} {% if item.published %} -