diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md index 1e5e21c..9940ed0 100644 --- a/docs/foreign-keys.md +++ b/docs/foreign-keys.md @@ -45,7 +45,7 @@ Here are some tables that could potentially have foreign keys added between them * The storage cost of the additional index on `sim` would not be worth the benefits. * `channel_room` <--(**C** room_id PK)-- `sim_member` * If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline. - * Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here. + * Either way, the sims should remain in the room even after it's been unlinked. So no referential integrity is desirable here. * `sim` <--(PK user_id PK)-- `sim_proxy` * OOYE left joins on this. In normal operation, this relationship might not exist. * `channel_room` <--(PK channel_id PK)-- `webhook` ✅ diff --git a/scripts/start-server.js b/scripts/start-server.js index 6c15037..f09c458 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -38,6 +38,4 @@ passthrough.select = orm.select await discord.cloud.connect() console.log("Discord gateway started") sync.require("../src/web/server") - - require("../src/stdin") })() diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 690ca2a..e0d019f 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -6,7 +6,7 @@ const Ty = require("../../types") const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("../../matrix/api")} */ @@ -14,9 +14,7 @@ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") /** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../m2d/converters/utils")} */ -const mUtils = sync.require("../../m2d/converters/utils") +const utils = sync.require("../../discord/utils") /** @type {import("./create-space")} */ const createSpace = sync.require("./create-space") @@ -116,8 +114,8 @@ async function channelToKState(channel, guild, di) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) - const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) @@ -394,7 +392,7 @@ function syncRoom(channelID) { return _syncRoom(channelID, true) } -async function unbridgeChannel(channelID) { +async function _unbridgeRoom(channelID) { /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) @@ -409,8 +407,12 @@ async function unbridgeChannel(channelID) { async function unbridgeDeletedChannel(channel, guildID) { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) - const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() - assert.ok(row) + const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() + assert.ok(spaceID) + + // remove room from being a space member + await api.sendState(roomID, "m.space.parent", spaceID, {}) + await api.sendState(spaceID, "m.space.child", roomID, {}) // remove declaration that the room is bridged await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) @@ -419,6 +421,15 @@ async function unbridgeDeletedChannel(channel, guildID) { await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) } + // send a notification in the room + await api.sendEvent(roomID, "m.room.message", { + msgtype: "m.notice", + body: "⚠️ This room was removed from the bridge." + }) + + // leave room + await api.leaveRoom(roomID) + // delete webhook on discord const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { @@ -428,48 +439,7 @@ async function unbridgeDeletedChannel(channel, guildID) { // delete room from database db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) - db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages - - // demote admins in room - /** @type {Ty.Event.M_Power_Levels} */ - const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - powerLevelContent.users ??= {} - const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` - for (const mxid of Object.keys(powerLevelContent.users)) { - if (mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) { - delete powerLevelContent.users[mxid] - await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) - } - } - - // send a notification in the room - await api.sendEvent(roomID, "m.room.message", { - msgtype: "m.notice", - body: "⚠️ This room was removed from the bridge." - }) - - // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged - // (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later) - if (row.autocreate === 1) { - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", row.space_id, {}) - await api.sendState(row.space_id, "m.space.child", roomID, {}) - - // leave room - await api.leaveRoom(roomID) - } - - // if it is a self-service room, remove sim members - // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) - if (row.autocreate === 0) { - // remove sim members - const members = select("sim_member", "mxid", {room_id: roomID}).pluck().all() - const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?") - for (const mxid of members) { - await api.leaveRoom(roomID, mxid) - preparedDelete.run(roomID, mxid) - } - } + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) } /** @@ -518,7 +488,7 @@ module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic -module.exports.unbridgeChannel = unbridgeChannel +module.exports._unbridgeRoom = _unbridgeRoom module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 4fce9a2..34178bf 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -67,7 +67,7 @@ block body option(value="admin") Admin input(type="hidden" name="guild_id" value=guild_id) .grid--row-start2 - button.s-btn.s-btn__filled Invite + button.s-btn.s-btn__filled.htmx-indicator Invite div != svg @@ -78,13 +78,12 @@ block body h3.mt32.fs-category Linked channels .s-card.bs-sm.p0 - form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") - input(type="hidden" name="guild_id" value=guild_id) + .s-table-container table.s-table.s-table__bx-simple each row in linkedChannelsWithDetails tr td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm td: +matrix(row) else tr @@ -100,16 +99,16 @@ block body - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() input(type="hidden" name="guild_id" value=guild_id) input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) - #autocreate-loading + .is-loading#autocreate-loading h3.mt32.fs-category Privacy level .s-card - form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") input(type="hidden" name="guild_id" value=guild_id) .d-flex.ai-center.mb4 label.s-label.fl-grow1 | How people can join on Matrix - span#privacy-level-loading + span.is-loading#privacy-level-loading .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") @@ -134,23 +133,23 @@ block body p.s-description.m0 Publicly listed in directory, like Discord server discovery h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="this") .fl-grow2.s-btn-group.fd-column.w40 each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) +discord(channel, true, "Announcement") else .s-empty-state.p8 All Discord channels are linked. .fl-grow1.s-btn-group.fd-column.w30 each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) +matrix(room, true) else .s-empty-state.p8 All Matrix rooms are linked. input(type="hidden" name="guild_id" value=guild_id) div - button.s-btn.s-btn__icon.s-btn__filled#link-button + button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator != icons.Icons.IconMerge = ` Link` diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index eaa0ee5..6a0fb76 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,89 +1,71 @@ -mixin guild(guild) - span.s-avatar.s-avatar__32.s-user-card--avatar - if guild.icon - img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) - else - .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] - .s-user-card--info.ai-start - strong= guild.name - ul.s-user-card--awards - li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels - -doctype html -html(lang="en") - head - title Out Of Your Element - - link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) - - meta(name="htmx-config" content='{"requestClass":"is-loading"}') - style. - .themed { - --theme-base-primary-color-h: 266; - --theme-base-primary-color-s: 53%; - --theme-base-primary-color-l: 63%; - --theme-dark-primary-color-h: 266; - --theme-dark-primary-color-s: 53%; - --theme-dark-primary-color-l: 63%; - } - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { - --_ts-multiple-bg: var(--green-400); - --_ts-multiple-fc: var(--white); - } - body.themed.theme-system - header.s-topbar - .s-topbar--skip-link(href="#content") Skip to main content - .s-topbar--container.wmx9 - a.s-topbar--logo(href=rel("/")) - img.s-avatar.s-avatar__32(src=rel("/icon.png")) - nav.s-topbar--navigation - ul.s-topbar--content - li.ps-relative - if !session.data.managedGuilds || session.data.managedGuilds.length === 0 - a.s-btn.s-btn__icon.as-center(href=rel("/oauth")) - != icons.Icons.IconDiscord - = ` Log in` - else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id) - button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds") - +guild(discord.guilds.get(guild_id)) - else if session.data.managedGuilds - button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds") - | Your servers - #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible - .s-popover--arrow.s-popover--arrow__tc - .s-popover--content.overflow-y-auto.overflow-x-hidden - ul.s-menu(role="menu") - each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g) - li(role="menuitem") - a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) - +guild(guild) - //- Body - .mx-auto.w100.wmx9.py24.px8.fs-body1#content - block body - //- Guild list popover - script. - document.querySelectorAll("[popovertarget]").forEach(e => { - e.addEventListener("click", () => { - const rect = e.getBoundingClientRect() - const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` - // console.log(t) - document.styleSheets[0].insertRule(t) - }) - }) - script(src=rel("/static/htmx.js")) - //- Error dialog - aside.s-modal#server-error(aria-hidden="true") - .s-modal--dialog - h1.s-modal--header Server error - pre.overflow-auto#server-error-content - button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm - .s-modal--footer - button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK - script. - function hideError() { - document.getElementById("server-error").setAttribute("aria-hidden", "true") - } - document.body.addEventListener("htmx:responseError", event => { - document.getElementById("server-error").setAttribute("aria-hidden", "false") - document.getElementById("server-error-content").textContent = event.detail.xhr.responseText - }) +mixin guild(guild) + span.s-avatar.s-avatar__32.s-user-card--avatar + if guild.icon + img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] + .s-user-card--info.ai-start + strong= guild.name + ul.s-user-card--awards + li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels + +doctype html +html(lang="en") + head + title Out Of Your Element + + link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) + + meta(name="htmx-config" content='{"indicatorClass":"is-loading"}') + style. + .themed { + --theme-base-primary-color-h: 266; + --theme-base-primary-color-s: 53%; + --theme-base-primary-color-l: 63%; + --theme-dark-primary-color-h: 266; + --theme-dark-primary-color-s: 53%; + --theme-dark-primary-color-l: 63%; + } + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { + --_ts-multiple-bg: var(--green-400); + --_ts-multiple-fc: var(--white); + } + body.themed.theme-system + header.s-topbar + .s-topbar--skip-link(href="#content") Skip to main content + .s-topbar--container.wmx9 + a.s-topbar--logo(href=rel("/")) + img.s-avatar.s-avatar__32(src=rel("/icon.png")) + nav.s-topbar--navigation + ul.s-topbar--content + li.ps-relative + if !session.data.managedGuilds || session.data.managedGuilds.length === 0 + a.s-btn.s-btn__icon.as-center(href=rel("/oauth")) + != icons.Icons.IconDiscord + = ` Log in` + else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id) + button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds") + +guild(discord.guilds.get(guild_id)) + else if session.data.managedGuilds + button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds") + | Your servers + #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + .s-popover--content.overflow-y-auto.overflow-x-hidden + ul.s-menu(role="menu") + each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g) + li(role="menuitem") + a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) + +guild(guild) + .mx-auto.w100.wmx9.py24.px8.fs-body1#content + block body + script. + document.querySelectorAll("[popovertarget]").forEach(e => { + e.addEventListener("click", () => { + const rect = e.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + // console.log(t) + document.styleSheets[0].insertRule(t) + }) + }) + script(src=rel("/static/htmx.min.js")) diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 7c7d4c6..1c33854 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -2,7 +2,7 @@ const assert = require("assert/strict") const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") +const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3") const {as, db, sync} = require("../../passthrough") const {reg} = require("../../matrix/read-registration") diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 193d7a3..e63c01b 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -1,7 +1,7 @@ // @ts-check const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody, setResponseHeader} = require("h3") +const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") const Ty = require("../../types") const {discord, db, as, sync, select, from} = require("../../passthrough") @@ -19,10 +19,6 @@ const schema = { guild_id: z.string(), matrix: z.string(), discord: z.string() - }), - unlink: z.object({ - guild_id: z.string(), - channel_id: z.string() }) } @@ -63,29 +59,5 @@ as.router.post("/api/link", defineEventHandler(async event => { // Sync room data and space child await createRoom.syncRoom(parsedBody.discord) - setResponseHeader(event, "HX-Refresh", "true") - return null // 204 -})) - -as.router.post("/api/unlink", defineEventHandler(async event => { - const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const session = await useSession(event, {password: reg.as_token}) - - // Check guild ID or nonce - if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check channel is part of this guild - const channel = discord.channels.get(channel_id) - if (!channel) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} does not exist`}) - if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) - - // Check channel is currently bridged - const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() - if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) - - // Do it - await createRoom.unbridgeDeletedChannel(channel, guild_id) - - setResponseHeader(event, "HX-Refresh", "true") return null // 204 })) diff --git a/src/web/server.js b/src/web/server.js index 39c0a68..9373947 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -49,12 +49,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({ } })) -as.router.get("/static/htmx.js", defineEventHandler({ +as.router.get("/static/htmx.min.js", defineEventHandler({ onBeforeResponse: compressResponse, handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(join(__dirname, "static", "htmx.js"), "utf-8") + return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") } })) diff --git a/src/web/static/htmx.js b/src/web/static/htmx.js deleted file mode 100644 index 370cc0f..0000000 --- a/src/web/static/htmx.js +++ /dev/null @@ -1,5261 +0,0 @@ -var htmx = (function() { - 'use strict' - - // Public API - const htmx = { - // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine - /* Event processing */ - /** @type {typeof onLoadHelper} */ - onLoad: null, - /** @type {typeof processNode} */ - process: null, - /** @type {typeof addEventListenerImpl} */ - on: null, - /** @type {typeof removeEventListenerImpl} */ - off: null, - /** @type {typeof triggerEvent} */ - trigger: null, - /** @type {typeof ajaxHelper} */ - ajax: null, - /* DOM querying helpers */ - /** @type {typeof find} */ - find: null, - /** @type {typeof findAll} */ - findAll: null, - /** @type {typeof closest} */ - closest: null, - /** - * Returns the input values that would resolve for a given element via the htmx value resolution mechanism - * - * @see https://htmx.org/api/#values - * - * @param {Element} elt the element to resolve values on - * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** - * @returns {Object} - */ - values: function(elt, type) { - const inputValues = getInputValues(elt, type || 'post') - return inputValues.values - }, - /* DOM manipulation helpers */ - /** @type {typeof removeElement} */ - remove: null, - /** @type {typeof addClassToElement} */ - addClass: null, - /** @type {typeof removeClassFromElement} */ - removeClass: null, - /** @type {typeof toggleClassOnElement} */ - toggleClass: null, - /** @type {typeof takeClassForElement} */ - takeClass: null, - /** @type {typeof swap} */ - swap: null, - /* Extension entrypoints */ - /** @type {typeof defineExtension} */ - defineExtension: null, - /** @type {typeof removeExtension} */ - removeExtension: null, - /* Debugging */ - /** @type {typeof logAll} */ - logAll: null, - /** @type {typeof logNone} */ - logNone: null, - /* Debugging */ - /** - * The logger htmx uses to log with - * - * @see https://htmx.org/api/#logger - */ - logger: null, - /** - * A property holding the configuration htmx uses at runtime. - * - * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. - * - * @see https://htmx.org/api/#config - */ - config: { - /** - * Whether to use history. - * @type boolean - * @default true - */ - historyEnabled: true, - /** - * The number of pages to keep in **localStorage** for history support. - * @type number - * @default 10 - */ - historyCacheSize: 10, - /** - * @type boolean - * @default false - */ - refreshOnHistoryMiss: false, - /** - * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. - * @type HtmxSwapStyle - * @default 'innerHTML' - */ - defaultSwapStyle: 'innerHTML', - /** - * The default delay between receiving a response from the server and doing the swap. - * @type number - * @default 0 - */ - defaultSwapDelay: 0, - /** - * The default delay between completing the content swap and settling attributes. - * @type number - * @default 20 - */ - defaultSettleDelay: 20, - /** - * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. - * @type boolean - * @default true - */ - includeIndicatorStyles: true, - /** - * The class to place on indicators when a request is in flight. - * @type string - * @default 'htmx-indicator' - */ - indicatorClass: 'htmx-indicator', - /** - * The class to place on triggering elements when a request is in flight. - * @type string - * @default 'htmx-request' - */ - requestClass: 'htmx-request', - /** - * The class to temporarily place on elements that htmx has added to the DOM. - * @type string - * @default 'htmx-added' - */ - addedClass: 'htmx-added', - /** - * The class to place on target elements when htmx is in the settling phase. - * @type string - * @default 'htmx-settling' - */ - settlingClass: 'htmx-settling', - /** - * The class to place on target elements when htmx is in the swapping phase. - * @type string - * @default 'htmx-swapping' - */ - swappingClass: 'htmx-swapping', - /** - * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. - * @type boolean - * @default true - */ - allowEval: true, - /** - * If set to false, disables the interpretation of script tags. - * @type boolean - * @default true - */ - allowScriptTags: true, - /** - * If set, the nonce will be added to inline scripts. - * @type string - * @default '' - */ - inlineScriptNonce: '', - /** - * If set, the nonce will be added to inline styles. - * @type string - * @default '' - */ - inlineStyleNonce: '', - /** - * The attributes to settle during the settling phase. - * @type string[] - * @default ['class', 'style', 'width', 'height'] - */ - attributesToSettle: ['class', 'style', 'width', 'height'], - /** - * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. - * @type boolean - * @default false - */ - withCredentials: false, - /** - * @type number - * @default 0 - */ - timeout: 0, - /** - * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. - * @type {'full-jitter' | ((retryCount:number) => number)} - * @default "full-jitter" - */ - wsReconnectDelay: 'full-jitter', - /** - * The type of binary data being received over the WebSocket connection - * @type BinaryType - * @default 'blob' - */ - wsBinaryType: 'blob', - /** - * @type string - * @default '[hx-disable], [data-hx-disable]' - */ - disableSelector: '[hx-disable], [data-hx-disable]', - /** - * @type {'auto' | 'instant' | 'smooth'} - * @default 'instant' - */ - scrollBehavior: 'instant', - /** - * If the focused element should be scrolled into view. - * @type boolean - * @default false - */ - defaultFocusScroll: false, - /** - * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser - * @type boolean - * @default false - */ - getCacheBusterParam: false, - /** - * If set to true, htmx will use the View Transition API when swapping in new content. - * @type boolean - * @default false - */ - globalViewTransitions: false, - /** - * htmx will format requests with these methods by encoding their parameters in the URL, not the request body - * @type {(HttpVerb)[]} - * @default ['get', 'delete'] - */ - methodsThatUseUrlParams: ['get', 'delete'], - /** - * If set to true, disables htmx-based requests to non-origin hosts. - * @type boolean - * @default false - */ - selfRequestsOnly: true, - /** - * If set to true htmx will not update the title of the document when a title tag is found in new content - * @type boolean - * @default false - */ - ignoreTitle: false, - /** - * Whether the target of a boosted element is scrolled into the viewport. - * @type boolean - * @default true - */ - scrollIntoViewOnBoost: true, - /** - * The cache to store evaluated trigger specifications into. - * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) - * @type {Object|null} - * @default null - */ - triggerSpecsCache: null, - /** @type boolean */ - disableInheritance: false, - /** @type HtmxResponseHandlingConfig[] */ - responseHandling: [ - { code: '204', swap: false }, - { code: '[23]..', swap: true }, - { code: '[45]..', swap: false, error: true } - ], - /** - * Whether to process OOB swaps on elements that are nested within the main response element. - * @type boolean - * @default true - */ - allowNestedOobSwaps: true - }, - /** @type {typeof parseInterval} */ - parseInterval: null, - /** @type {typeof internalEval} */ - _: null, - version: '2.0.4' - } - // Tsc madness part 2 - htmx.onLoad = onLoadHelper - htmx.process = processNode - htmx.on = addEventListenerImpl - htmx.off = removeEventListenerImpl - htmx.trigger = triggerEvent - htmx.ajax = ajaxHelper - htmx.find = find - htmx.findAll = findAll - htmx.closest = closest - htmx.remove = removeElement - htmx.addClass = addClassToElement - htmx.removeClass = removeClassFromElement - htmx.toggleClass = toggleClassOnElement - htmx.takeClass = takeClassForElement - htmx.swap = swap - htmx.defineExtension = defineExtension - htmx.removeExtension = removeExtension - htmx.logAll = logAll - htmx.logNone = logNone - htmx.parseInterval = parseInterval - htmx._ = internalEval - - const internalAPI = { - addTriggerHandler, - bodyContains, - canAccessLocalStorage, - findThisElement, - filterValues, - swap, - hasAttribute, - getAttributeValue, - getClosestAttributeValue, - getClosestMatch, - getExpressionVars, - getHeaders, - getInputValues, - getInternalData, - getSwapSpecification, - getTriggerSpecs, - getTarget, - makeFragment, - mergeObjects, - makeSettleInfo, - oobSwap, - querySelectorExt, - settleImmediately, - shouldCancel, - triggerEvent, - triggerErrorEvent, - withExtensions - } - - const VERBS = ['get', 'post', 'put', 'delete', 'patch'] - const VERB_SELECTOR = VERBS.map(function(verb) { - return '[hx-' + verb + '], [data-hx-' + verb + ']' - }).join(', ') - - //= =================================================================== - // Utilities - //= =================================================================== - - /** - * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. - * - * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** - * - * @see https://htmx.org/api/#parseInterval - * - * @param {string} str timing string - * @returns {number|undefined} - */ - function parseInterval(str) { - if (str == undefined) { - return undefined - } - - let interval = NaN - if (str.slice(-2) == 'ms') { - interval = parseFloat(str.slice(0, -2)) - } else if (str.slice(-1) == 's') { - interval = parseFloat(str.slice(0, -1)) * 1000 - } else if (str.slice(-1) == 'm') { - interval = parseFloat(str.slice(0, -1)) * 1000 * 60 - } else { - interval = parseFloat(str) - } - return isNaN(interval) ? undefined : interval - } - - /** - * @param {Node} elt - * @param {string} name - * @returns {(string | null)} - */ - function getRawAttribute(elt, name) { - return elt instanceof Element && elt.getAttribute(name) - } - - /** - * @param {Element} elt - * @param {string} qualifiedName - * @returns {boolean} - */ - // resolve with both hx and data-hx prefixes - function hasAttribute(elt, qualifiedName) { - return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || - elt.hasAttribute('data-' + qualifiedName)) - } - - /** - * - * @param {Node} elt - * @param {string} qualifiedName - * @returns {(string | null)} - */ - function getAttributeValue(elt, qualifiedName) { - return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) - } - - /** - * @param {Node} elt - * @returns {Node | null} - */ - function parentElt(elt) { - const parent = elt.parentElement - if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode - return parent - } - - /** - * @returns {Document} - */ - function getDocument() { - return document - } - - /** - * @param {Node} elt - * @param {boolean} global - * @returns {Node|Document} - */ - function getRootNode(elt, global) { - return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() - } - - /** - * @param {Node} elt - * @param {(e:Node) => boolean} condition - * @returns {Node | null} - */ - function getClosestMatch(elt, condition) { - while (elt && !condition(elt)) { - elt = parentElt(elt) - } - - return elt || null - } - - /** - * @param {Element} initialElement - * @param {Element} ancestor - * @param {string} attributeName - * @returns {string|null} - */ - function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { - const attributeValue = getAttributeValue(ancestor, attributeName) - const disinherit = getAttributeValue(ancestor, 'hx-disinherit') - var inherit = getAttributeValue(ancestor, 'hx-inherit') - if (initialElement !== ancestor) { - if (htmx.config.disableInheritance) { - if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { - return attributeValue - } else { - return null - } - } - if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { - return 'unset' - } - } - return attributeValue - } - - /** - * @param {Element} elt - * @param {string} attributeName - * @returns {string | null} - */ - function getClosestAttributeValue(elt, attributeName) { - let closestAttr = null - getClosestMatch(elt, function(e) { - return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) - }) - if (closestAttr !== 'unset') { - return closestAttr - } - } - - /** - * @param {Node} elt - * @param {string} selector - * @returns {boolean} - */ - function matches(elt, selector) { - // @ts-ignore: non-standard properties for browser compatibility - // noinspection JSUnresolvedVariable - const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) - return !!matchesFunction && matchesFunction.call(elt, selector) - } - - /** - * @param {string} str - * @returns {string} - */ - function getStartTag(str) { - const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i - const match = tagMatcher.exec(str) - if (match) { - return match[1].toLowerCase() - } else { - return '' - } - } - - /** - * @param {string} resp - * @returns {Document} - */ - function parseHTML(resp) { - const parser = new DOMParser() - return parser.parseFromString(resp, 'text/html') - } - - /** - * @param {DocumentFragment} fragment - * @param {Node} elt - */ - function takeChildrenFor(fragment, elt) { - while (elt.childNodes.length > 0) { - fragment.append(elt.childNodes[0]) - } - } - - /** - * @param {HTMLScriptElement} script - * @returns {HTMLScriptElement} - */ - function duplicateScript(script) { - const newScript = getDocument().createElement('script') - forEach(script.attributes, function(attr) { - newScript.setAttribute(attr.name, attr.value) - }) - newScript.textContent = script.textContent - newScript.async = false - if (htmx.config.inlineScriptNonce) { - newScript.nonce = htmx.config.inlineScriptNonce - } - return newScript - } - - /** - * @param {HTMLScriptElement} script - * @returns {boolean} - */ - function isJavaScriptScriptNode(script) { - return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') - } - - /** - * we have to make new copies of script tags that we are going to insert because - * SOME browsers (not saying who, but it involves an element and an animal) don't - * execute scripts created in