diff --git a/package-lock.json b/package-lock.json index 6ec8ce6..32e7e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.8.0", + "@cloudrac3r/discord-markdown": "^2.9.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -55,6 +55,19 @@ "node": ">=22" } }, + "../discord-markdown": { + "name": "@cloudrac3r/discord-markdown", + "version": "2.9.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "simple-markdown": "^0.7.3" + }, + "devDependencies": { + "@cloudrac3r/tap-dot": "^2.0.2", + "supertape": "^13.6.2" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -131,9 +144,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.8.0.tgz", - "integrity": "sha512-ciLxS6iaMxuEkEln7Y9rTpUBFf+gij8djNKS1QGrTfKoHcKABmDvcXQlPntdTx7odHjtMqV2RfvR/rnmQ25YPQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.0.tgz", + "integrity": "sha512-0S22mNlT9rPhYnxP+qzKWayVe+hFWcEqxVCviF/pyzWAb9nLaBZkIVk0jHTImoA1MY0mqx1uMKjIcxVHkEZe3A==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -1049,9 +1062,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" diff --git a/package.json b/package.json index 7c9ac57..72004cf 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.8.0", + "@cloudrac3r/discord-markdown": "^2.9.0", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index b3e3116..f192aff 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -18,6 +18,8 @@ const lottie = sync.require("../actions/lottie") const mxUtils = sync.require("../../matrix/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") +/** @type {import("../../discord/time")} */ +const discordTime = sync.require("../../discord/time") /** @type {import("./find-mentions")} */ const findMentions = sync.require("./find-mentions") /** @type {import("../../discord/interactions/poll-responses")} */ @@ -72,6 +74,17 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { return `:${node.name}:` } }, + /** @param {{timestamp: number, style: string | null}} node */ + timestamp: node => { + const text = discordTime.convertDiscordTime(node.timestamp, node.style) + if (useHTML) { + let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}` + if (node.style) url += `/${node.style}` + return tag`${text}` + } else { + return text + } + }, role: node => { const role = guild.roles.find(r => r.id === node.id) if (!role) { diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 1ef83c3..47e27b5 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -44,7 +44,7 @@ test("message2event components: pk question mark output", async t => { + "\n| Same hat" + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + "\n" - + "\n-# Original Message ID: 1466556003645657118 · ", + + "\n-# Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)", format: "org.matrix.custom.html", formatted_body: "
" + "

Lillith (INX)

" @@ -73,7 +73,7 @@ test("message2event components: pk question mark output", async t => { + "
" + "

Same hat

" + `🖼️ Image: image.png
` - + "

Original Message ID: 1466556003645657118 · <t:1769724599:f>

", + + "

Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)

", "m.mentions": {}, msgtype: "m.text", }]) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 41f40ba..397be39 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -202,6 +202,28 @@ test("message2event: simple game mentions", async t => { }]) }) +test("message2event: timestamps", async t => { + const events = await messageToEvent(data.message.timestamps, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "4 Oct 2026, 9:33:33 am (UTC)" + + "\n4 October 2026 at 9:33 am (UTC)" + + "\nSunday, 4 October 2026 at 9:34 am (UTC)" + + "\n9:34:08 am (UTC)" + + "\n3 October 2026 at 9:30 am (UTC)" + + "\n24 December 2026 at 11:00 pm (UTC)", + format: "org.matrix.custom.html", + formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` + + `
4 October 2026 at 9:33 am (UTC)` + + `
Sunday, 4 October 2026 at 9:34 am (UTC)` + + `
9:34:08 am (UTC)` + + `
3 October 2026 at 9:30 am (UTC)` + + `
24 December 2026 at 11:00 pm (UTC)`, + "m.mentions": {} + }]) +}) + test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { diff --git a/src/discord/time.js b/src/discord/time.js new file mode 100644 index 0000000..d0f7a04 --- /dev/null +++ b/src/discord/time.js @@ -0,0 +1,51 @@ +// @ts-check + +const assert = require("assert").strict + +/* + https://docs.discord.com/developers/reference#message-formatting + + Trying to make these formats fairly culture independent since the Matrix text will show up the same for everyone. + That means no ambiguous short dates like 03/04/26. The shortest allowable date is "medium", which spells the month. + It also means no 24-hour time, since this is ambiguous to 12-hour time users. Times must have am/pm. + Relative time is not supported for text conversion and will fall back to the default, "f". + + See the test file for sample outputs. +*/ + +/** @type {Map} discord style letter abbreviation -> Intl.DateTimeFormat constructor options */ +const styles = new Map([ + ["t", {timeStyle: "short", timeZone: "UTC"}], + ["T", {timeStyle: "medium", timeZone: "UTC"}], + ["d", {dateStyle: "medium", timeZone: "UTC"}], + ["D", {dateStyle: "long", timeZone: "UTC"}], + ["f", {dateStyle: "long", timeStyle: "short", timeZone: "UTC"}], + ["F", {dateStyle: "full", timeStyle: "short", timeZone: "UTC"}], + ["s", {dateStyle: "medium", timeStyle: "short", timeZone: "UTC"}], + ["S", {dateStyle: "medium", timeStyle: "medium", timeZone: "UTC"}] +]) +const defaultStyle = "f" + +/** + * @param {string | null | undefined} [style] + * @returns {Intl.DateTimeFormatOptions} */ +function getFormatOptions(style) { + if (!style || !styles.has(style)) style = defaultStyle + const formatOptions = styles.get(style) + assert(formatOptions) + return formatOptions +} + +/** + * @param {number} timestamp unix time in seconds + * @param {string | null | undefined} [style] discord style letter abbreviation, if present + * @returns {string} + */ +function convertDiscordTime(timestamp, style) { + const formatter = new Intl.DateTimeFormat("en-NZ", getFormatOptions(style)) + const d = new Date(timestamp * 1000) + return formatter.format(d) + " (UTC)" +} + +module.exports.getFormatOptions = getFormatOptions +module.exports.convertDiscordTime = convertDiscordTime diff --git a/src/discord/time.test.js b/src/discord/time.test.js new file mode 100644 index 0000000..b889682 --- /dev/null +++ b/src/discord/time.test.js @@ -0,0 +1,48 @@ +// @ts-check + +const time = require("./time") +const {test} = require("supertape") + +const d = new Date("2026-02-14T14:23:06.000Z") +const seconds = d.getTime() / 1000 + +test("time: default style", t => { + t.equal(time.convertDiscordTime(seconds), "14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: t style", t => { + t.equal(time.convertDiscordTime(seconds, "t"), "2:23 pm (UTC)") +}) + +test("time: T style", t => { + t.equal(time.convertDiscordTime(seconds, "T"), "2:23:06 pm (UTC)") +}) + +test("time: d style", t => { + t.equal(time.convertDiscordTime(seconds, "d"), "14 Feb 2026 (UTC)") +}) + +test("time: D style", t => { + t.equal(time.convertDiscordTime(seconds, "D"), "14 February 2026 (UTC)") +}) + +test("time: f style", t => { + t.equal(time.convertDiscordTime(seconds, "f"), "14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: F style", t => { + t.equal(time.convertDiscordTime(seconds, "F"), "Saturday, 14 February 2026 at 2:23 pm (UTC)") +}) + +test("time: s style", t => { + t.equal(time.convertDiscordTime(seconds, "s"), "14 Feb 2026, 2:23 pm (UTC)") +}) + +test("time: S style", t => { + t.equal(time.convertDiscordTime(seconds, "S"), "14 Feb 2026, 2:23:06 pm (UTC)") +}) + +test("time: R style", t => { + // Discord would display this as relative time, but I don't want to do that in a static message. + t.equal(time.convertDiscordTime(seconds, "R"), "14 February 2026 at 2:23 pm (UTC)") +}) diff --git a/src/web/pug/time.pug b/src/web/pug/time.pug new file mode 100644 index 0000000..1d71c22 --- /dev/null +++ b/src/web/pug/time.pug @@ -0,0 +1,143 @@ +//- requires timestamp, text, style + +- const WIDTH = 600 + +doctype html +html(lang="en") + head + + + title= `Converting time ${text}...` + style. + body { + font-family: "Segoe UI", sans-serif; + margin: 0 auto; + padding: 4px; + font-size: 18px; + background-color: #f0f2f5; /* element compound tertiary off-white */ + max-width: !{WIDTH}px; + } + .only-script { + display: none; + } + .yes-script .only-script { + display: revert; + } + .local { + line-height: 1; + width: auto; + height: auto; + max-width: 100%; + } + .original { + color: #004c84; + } + .remaining { + font-weight: bold; + } + .primary-remaining { + font-size: 25px; + } + @media (prefers-color-scheme: dark) { + body { + color: #e3e3e3; + background-color: #131416; /* element compound bg but desaturated */ + } + .original { + color: #99c9ec; + } + .local { + fill: #fff; + } + .remaining { + color: #fff; + } + } + body + noscript + | You must enable JavaScript to convert to your local time. + .only-script + p In your local time, #[span.original= text] is... + if style === "R" + p.primary-remaining #[span#remaining.remaining]#[span#direction] #[span#tick.tick] + svg.local(viewBox=`0 -130 ${WIDTH} 253`) + text(style="font-size: 160px; font-weight: bold;")#localTime + text(y=70 style="font-size: 45px; font-weight: bold;")#localDate + text(y=115 style="font-size: 22px;")#localTz + if style !== "R" + p #[span#remaining.remaining]#[span#direction] #[span#tick.tick] + script. + document.body.classList.add("yes-script") + const WIDTH = !{WIDTH} + const timestamp = !{timestamp} + const style = "!{style}" + const formatOptions = !{JSON.stringify(formatOptions)} + const d = new Date(timestamp * 1000) + const UNITS = [ + ["week", 7*24*60*60*1000], + ["day", 24*60*60*1000], + ["hour", 60*60*1000], + ["minute", 60*1000], + ] + const showSeconds = formatOptions.timeStyle === "medium" || style === "R" + if (showSeconds) UNITS.push(["second", 1000]) + + if (formatOptions.timeStyle) { + localTime.textContent = new Intl.DateTimeFormat(undefined, {timeStyle: formatOptions.timeStyle}).format(d) + } else { + localTime.textContent = "Any time on" + localTime.style.fill = "#888" + } + if (formatOptions.dateStyle) { + localDate.textContent = new Intl.DateTimeFormat(undefined, {dateStyle: formatOptions.dateStyle}).format(d) + } else { + localDate.textContent = "On any date" + localDate.style.fill = "#888" + } + const localTzText = new Intl.DateTimeFormat(undefined, {timeZoneName: "long"}).formatToParts(d).find(p => p.type === "timeZoneName").value + const localTitleText = new Intl.DateTimeFormat(undefined, {...formatOptions, timeZone: undefined}).format(d) + document.title = localTitleText + localTz.textContent = localTzText + for (const st of [localTime, localDate, localTz]) { + const l = st.getComputedTextLength() + if (l > WIDTH) { + st.style.fontSize = Math.floor(parseInt(st.style.fontSize) * WIDTH / l) + "px"; + } + } + + let frame = 0 + function updateTimeRemaining() { + const now = new Date() + const timeForRemaining = new Date(d) + if (!formatOptions.dateStyle) { + timeForRemaining.setFullYear(now.getFullYear()) + timeForRemaining.setMonth(now.getMonth()) + timeForRemaining.setDate(now.getDate()) + } + let difference = Math.abs(timeForRemaining.getTime() - now.getTime()) + + let foundSignificantField = false + let remainingText = UNITS.map(([unit, duration], index) => { + const multiplier = Math.floor(difference / duration) + difference -= multiplier * duration + if (multiplier > 0 || foundSignificantField) { + foundSignificantField = true + return multiplier + "\u00a0" + unit + (multiplier !== 1 ? "s" : "") + } + }).filter(s => s).join(", ") + if (remainingText) { + remaining.textContent = remainingText + direction.textContent = + ( timeForRemaining.getTime() > now.getTime() + ? "\u00a0from\u00a0now." + : "\u00a0ago.") + } else { + remaining.textContent = "Right now!" + direction.textContent = "" + } + if (!showSeconds) { + tick.textContent = String.fromCodePoint(11028 + ++frame % 2) + } + } + updateTimeRemaining() + setInterval(updateTimeRemaining, 1000) diff --git a/src/web/routes/time.js b/src/web/routes/time.js new file mode 100644 index 0000000..ad89b58 --- /dev/null +++ b/src/web/routes/time.js @@ -0,0 +1,31 @@ +// @ts-check + +const {defineEventHandler, getValidatedRouterParams, H3Event, setResponseHeader} = require("h3") +const {as, db, sync} = require("../../passthrough") +const {z} = require("zod") + +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../../discord/time")} */ +const discordTime = sync.require("../../discord/time") + +const schema = { + time: z.object({ + timestamp: z.coerce.number(), + style: z.string().optional() + }) +} + +as.router.get("/time/:timestamp", defineEventHandler(async event => { + const {timestamp} = await getValidatedRouterParams(event, schema.time.parse) + const formatOptions = discordTime.getFormatOptions() + const text = discordTime.convertDiscordTime(timestamp) + return pugSync.render(event, "time.pug", {text, timestamp, formatOptions}) +})) + +as.router.get("/time/:timestamp/:style", defineEventHandler(async event => { + const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse) + const formatOptions = discordTime.getFormatOptions(style) + const text = discordTime.convertDiscordTime(timestamp, style) + return pugSync.render(event, "time.pug", {text, timestamp, style, formatOptions}) +})) diff --git a/src/web/server.js b/src/web/server.js index 3c06ede..83aa588 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -141,3 +141,4 @@ sync.require("./routes/oauth") sync.require("./routes/opt-out") sync.require("./routes/password") sync.require("./routes/stats") +sync.require("./routes/time") diff --git a/test/data.js b/test/data.js index f441778..bcc9fdb 100644 --- a/test/data.js +++ b/test/data.js @@ -1732,6 +1732,56 @@ module.exports = { } ] }, + timestamps: { + type: 0, + content: "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-10-04T09:34:27.909000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1556238063774990499", + channel_id: "1160894080998461480", + author: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + vad_colors: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + }, + pinned: false, + mention_everyone: false, + tts: false + }, unknown_role: { id: "1162374402785153106", type: 0, diff --git a/test/test.js b/test/test.js index 5079ed3..74d99b1 100644 --- a/test/test.js +++ b/test/test.js @@ -132,6 +132,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/db/orm.test") require("../src/js/errors.test") require("../src/web/server.test") + require("../src/discord/time.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") require("../src/matrix/api.test")