mirror of
https://github.com/bigskysoftware/htmx.git
synced 2025-10-02 15:25:26 +00:00
Improvements in WebSocket extension
* Implement proper `hx-trigger` support * Expose trigger handling API to extensions * Implement safe message sending with sending queue * Fix `ws-send` attributes connecting in new elements * Fix OOB swapping of multiple elements in response
This commit is contained in:
parent
bcd68ad437
commit
8bb0399052
118
src/ext/ws.js
118
src/ext/ws.js
@ -58,6 +58,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
|
|||||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
|
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function(child) {
|
||||||
ensureWebSocket(child)
|
ensureWebSocket(child)
|
||||||
});
|
});
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||||
|
ensureWebSocketSend(child)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -125,8 +128,11 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
|
|||||||
/** @type {WebSocket} */
|
/** @type {WebSocket} */
|
||||||
var socket = htmx.createWebSocket(wssSource);
|
var socket = htmx.createWebSocket(wssSource);
|
||||||
|
|
||||||
|
var messageQueue = [];
|
||||||
|
|
||||||
socket.onopen = function (e) {
|
socket.onopen = function (e) {
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
|
handleQueuedMessages(messageQueue, socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onclose = function (e) {
|
socket.onclose = function (e) {
|
||||||
@ -158,25 +164,42 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
|
|||||||
var fragment = api.makeFragment(response);
|
var fragment = api.makeFragment(response);
|
||||||
|
|
||||||
if (fragment.children.length) {
|
if (fragment.children.length) {
|
||||||
for (var i = 0; i < fragment.children.length; i++) {
|
var children = Array.from(fragment.children);
|
||||||
api.oobSwap(api.getAttributeValue(fragment.children[i], "hx-swap-oob") || "true", fragment.children[i], settleInfo);
|
for (var i = 0; i < children.length; i++) {
|
||||||
|
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.settleImmediately(settleInfo.tasks);
|
api.settleImmediately(settleInfo.tasks);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-connect any ws-send commands as well.
|
|
||||||
forEach(queryAttributeOnThisOrChildren(elt, "ws-send"), function(child) {
|
|
||||||
var legacyAttribute = api.getAttributeValue(child, "hx-ws");
|
|
||||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processWebSocketSend(elt, child);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Put the WebSocket into the HTML Element's custom data.
|
// Put the WebSocket into the HTML Element's custom data.
|
||||||
api.getInternalData(elt).webSocket = socket;
|
api.getInternalData(elt).webSocket = socket;
|
||||||
|
api.getInternalData(elt).webSocketMessageQueue = messageQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocketSend attaches trigger handles to elements with
|
||||||
|
* "ws-send" attribute
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function ensureWebSocketSend(elt) {
|
||||||
|
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||||
|
processWebSocketSend(webSocketParent, elt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hasWebSocket function checks if a node has webSocket instance attached
|
||||||
|
* @param {HTMLElement} node
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasWebSocket(node) {
|
||||||
|
return api.getInternalData(node).webSocket != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -186,27 +209,63 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
|
|||||||
* @param {HTMLElement} child
|
* @param {HTMLElement} child
|
||||||
*/
|
*/
|
||||||
function processWebSocketSend(parent, child) {
|
function processWebSocketSend(parent, child) {
|
||||||
child.addEventListener(api.getTriggerSpecs(child)[0].trigger, function (evt) {
|
var nodeData = api.getInternalData(child);
|
||||||
var webSocket = api.getInternalData(parent).webSocket;
|
let triggerSpecs = api.getTriggerSpecs(child);
|
||||||
var headers = api.getHeaders(child, parent);
|
triggerSpecs.forEach(function(ts) {
|
||||||
var results = api.getInputValues(child, 'post');
|
api.addTriggerHandler(child, ts, nodeData, function (evt) {
|
||||||
var errors = results.errors;
|
var webSocket = api.getInternalData(parent).webSocket;
|
||||||
var rawParameters = results.values;
|
var messageQueue = api.getInternalData(parent).webSocketMessageQueue;
|
||||||
var expressionVars = api.getExpressionVars(child);
|
var headers = api.getHeaders(child, parent);
|
||||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
var results = api.getInputValues(child, 'post');
|
||||||
var filteredParameters = api.filterValues(allParameters, child);
|
var errors = results.errors;
|
||||||
filteredParameters['HEADERS'] = headers;
|
var rawParameters = results.values;
|
||||||
if (errors && errors.length > 0) {
|
var expressionVars = api.getExpressionVars(child);
|
||||||
api.triggerEvent(child, 'htmx:validation:halted', errors);
|
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||||
return;
|
var filteredParameters = api.filterValues(allParameters, child);
|
||||||
}
|
filteredParameters['HEADERS'] = headers;
|
||||||
webSocket.send(JSON.stringify(filteredParameters));
|
if (errors && errors.length > 0) {
|
||||||
if(api.shouldCancel(evt, child)){
|
api.triggerEvent(child, 'htmx:validation:halted', errors);
|
||||||
evt.preventDefault();
|
return;
|
||||||
}
|
}
|
||||||
|
webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue);
|
||||||
|
if(api.shouldCancel(evt, child)){
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webSocketSend provides a safe way to send messages through a WebSocket.
|
||||||
|
* It checks that the socket is in OPEN state and, otherwise, awaits for it.
|
||||||
|
* @param {WebSocket} socket
|
||||||
|
* @param {string} message
|
||||||
|
* @param {string[]} messageQueue
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
function webSocketSend(socket, message, messageQueue) {
|
||||||
|
if (socket.readyState != socket.OPEN) {
|
||||||
|
messageQueue.push(message);
|
||||||
|
} else {
|
||||||
|
socket.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handleQueuedMessages sends messages awaiting in the message queue
|
||||||
|
*/
|
||||||
|
function handleQueuedMessages(messageQueue, socket) {
|
||||||
|
while (messageQueue.length > 0) {
|
||||||
|
var message = messageQueue[0]
|
||||||
|
if (socket.readyState == socket.OPEN) {
|
||||||
|
socket.send(message);
|
||||||
|
messageQueue.shift()
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||||
* @param {number} retryCount // The number of retries that have already taken place
|
* @param {number} retryCount // The number of retries that have already taken place
|
||||||
@ -293,3 +352,4 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
|
|||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
127
src/htmx.js
127
src/htmx.js
@ -76,6 +76,7 @@ return (function () {
|
|||||||
|
|
||||||
/** @type {import("./htmx").HtmxInternalApi} */
|
/** @type {import("./htmx").HtmxInternalApi} */
|
||||||
var internalAPI = {
|
var internalAPI = {
|
||||||
|
addTriggerHandler: addTriggerHandler,
|
||||||
bodyContains: bodyContains,
|
bodyContains: bodyContains,
|
||||||
canAccessLocalStorage: canAccessLocalStorage,
|
canAccessLocalStorage: canAccessLocalStorage,
|
||||||
filterValues: filterValues,
|
filterValues: filterValues,
|
||||||
@ -172,13 +173,11 @@ return (function () {
|
|||||||
* @returns {HTMLElement | null}
|
* @returns {HTMLElement | null}
|
||||||
*/
|
*/
|
||||||
function getClosestMatch(elt, condition) {
|
function getClosestMatch(elt, condition) {
|
||||||
if (condition(elt)) {
|
while (elt && !condition(elt)) {
|
||||||
return elt;
|
elt = parentElt(elt);
|
||||||
} else if (parentElt(elt)) {
|
|
||||||
return getClosestMatch(parentElt(elt), condition);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return elt ? elt : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){
|
function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){
|
||||||
@ -1188,14 +1187,14 @@ return (function () {
|
|||||||
getInternalData(elt).cancelled = true;
|
getInternalData(elt).cancelled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processPolling(elt, verb, path, spec) {
|
function processPolling(elt, handler, spec) {
|
||||||
var nodeData = getInternalData(elt);
|
var nodeData = getInternalData(elt);
|
||||||
nodeData.timeout = setTimeout(function () {
|
nodeData.timeout = setTimeout(function () {
|
||||||
if (bodyContains(elt) && nodeData.cancelled !== true) {
|
if (bodyContains(elt) && nodeData.cancelled !== true) {
|
||||||
if (!maybeFilterEvent(spec, makeEvent('hx:poll:trigger', {triggerSpec:spec, target:elt}))) {
|
if (!maybeFilterEvent(spec, makeEvent('hx:poll:trigger', {triggerSpec:spec, target:elt}))) {
|
||||||
issueAjaxRequest(verb, path, elt);
|
handler(elt);
|
||||||
}
|
}
|
||||||
processPolling(elt, verb, getAttributeValue(elt, "hx-" + verb), spec);
|
processPolling(elt, handler, spec);
|
||||||
}
|
}
|
||||||
}, spec.pollInterval);
|
}, spec.pollInterval);
|
||||||
}
|
}
|
||||||
@ -1223,7 +1222,9 @@ return (function () {
|
|||||||
path = getRawAttribute(elt, 'action');
|
path = getRawAttribute(elt, 'action');
|
||||||
}
|
}
|
||||||
triggerSpecs.forEach(function(triggerSpec) {
|
triggerSpecs.forEach(function(triggerSpec) {
|
||||||
addEventListener(elt, verb, path, nodeData, triggerSpec, true);
|
addEventListener(elt, function(evt) {
|
||||||
|
issueAjaxRequest(verb, path, elt, evt)
|
||||||
|
}, nodeData, triggerSpec, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1267,7 +1268,7 @@ return (function () {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEventListener(elt, verb, path, nodeData, triggerSpec, explicitCancel) {
|
function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) {
|
||||||
var eltsToListenOn;
|
var eltsToListenOn;
|
||||||
if (triggerSpec.from) {
|
if (triggerSpec.from) {
|
||||||
eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from);
|
eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from);
|
||||||
@ -1328,17 +1329,15 @@ return (function () {
|
|||||||
|
|
||||||
if (triggerSpec.throttle) {
|
if (triggerSpec.throttle) {
|
||||||
if (!elementData.throttle) {
|
if (!elementData.throttle) {
|
||||||
issueAjaxRequest(verb, path, elt, evt);
|
handler(elt, evt);
|
||||||
elementData.throttle = setTimeout(function () {
|
elementData.throttle = setTimeout(function () {
|
||||||
elementData.throttle = null;
|
elementData.throttle = null;
|
||||||
}, triggerSpec.throttle);
|
}, triggerSpec.throttle);
|
||||||
}
|
}
|
||||||
} else if (triggerSpec.delay) {
|
} else if (triggerSpec.delay) {
|
||||||
elementData.delayed = setTimeout(function () {
|
elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay);
|
||||||
issueAjaxRequest(verb, path, elt, evt);
|
|
||||||
}, triggerSpec.delay);
|
|
||||||
} else {
|
} else {
|
||||||
issueAjaxRequest(verb, path, elt, evt);
|
handler(elt, evt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1356,7 +1355,7 @@ return (function () {
|
|||||||
|
|
||||||
var windowIsScrolling = false // used by initScrollHandler
|
var windowIsScrolling = false // used by initScrollHandler
|
||||||
var scrollHandler = null;
|
var scrollHandler = null;
|
||||||
function initScrollHandler() {
|
function initScrollHandler(handler) {
|
||||||
if (!scrollHandler) {
|
if (!scrollHandler) {
|
||||||
scrollHandler = function() {
|
scrollHandler = function() {
|
||||||
windowIsScrolling = true
|
windowIsScrolling = true
|
||||||
@ -1366,25 +1365,22 @@ return (function () {
|
|||||||
if (windowIsScrolling) {
|
if (windowIsScrolling) {
|
||||||
windowIsScrolling = false;
|
windowIsScrolling = false;
|
||||||
forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) {
|
forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) {
|
||||||
maybeReveal(elt);
|
maybeReveal(elt, handler);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeReveal(elt) {
|
function maybeReveal(elt, handler) {
|
||||||
if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) {
|
if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) {
|
||||||
elt.setAttribute('data-hx-revealed', 'true');
|
elt.setAttribute('data-hx-revealed', 'true');
|
||||||
var nodeData = getInternalData(elt);
|
var nodeData = getInternalData(elt);
|
||||||
if (nodeData.initialized) {
|
if (nodeData.initialized) {
|
||||||
issueAjaxRequest(nodeData.verb, nodeData.path, elt);
|
handler(elt);
|
||||||
} else {
|
} else {
|
||||||
// if the node isn't initialized, wait for it before triggering the request
|
// if the node isn't initialized, wait for it before triggering the request
|
||||||
elt.addEventListener("htmx:afterProcessNode",
|
elt.addEventListener("htmx:afterProcessNode", function(evt) { handler(elt) }, {once: true});
|
||||||
function () {
|
|
||||||
issueAjaxRequest(nodeData.verb, nodeData.path, elt);
|
|
||||||
}, {once: true});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1571,14 +1567,14 @@ return (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processSSETrigger(elt, verb, path, sseEventName) {
|
function processSSETrigger(elt, handler, sseEventName) {
|
||||||
var sseSourceElt = getClosestMatch(elt, hasEventSource);
|
var sseSourceElt = getClosestMatch(elt, hasEventSource);
|
||||||
if (sseSourceElt) {
|
if (sseSourceElt) {
|
||||||
var sseEventSource = getInternalData(sseSourceElt).sseEventSource;
|
var sseEventSource = getInternalData(sseSourceElt).sseEventSource;
|
||||||
var sseListener = function () {
|
var sseListener = function () {
|
||||||
if (!maybeCloseSSESource(sseSourceElt)) {
|
if (!maybeCloseSSESource(sseSourceElt)) {
|
||||||
if (bodyContains(elt)) {
|
if (bodyContains(elt)) {
|
||||||
issueAjaxRequest(verb, path, elt);
|
handler(elt);
|
||||||
} else {
|
} else {
|
||||||
sseEventSource.removeEventListener(sseEventName, sseListener);
|
sseEventSource.removeEventListener(sseEventName, sseListener);
|
||||||
}
|
}
|
||||||
@ -1604,11 +1600,11 @@ return (function () {
|
|||||||
|
|
||||||
//====================================================================
|
//====================================================================
|
||||||
|
|
||||||
function loadImmediately(elt, verb, path, nodeData, delay) {
|
function loadImmediately(elt, handler, nodeData, delay) {
|
||||||
var load = function(){
|
var load = function(){
|
||||||
if (!nodeData.loaded) {
|
if (!nodeData.loaded) {
|
||||||
nodeData.loaded = true;
|
nodeData.loaded = true;
|
||||||
issueAjaxRequest(verb, path, elt);
|
handler(elt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (delay) {
|
if (delay) {
|
||||||
@ -1627,46 +1623,52 @@ return (function () {
|
|||||||
nodeData.path = path;
|
nodeData.path = path;
|
||||||
nodeData.verb = verb;
|
nodeData.verb = verb;
|
||||||
triggerSpecs.forEach(function(triggerSpec) {
|
triggerSpecs.forEach(function(triggerSpec) {
|
||||||
if (triggerSpec.sseEvent) {
|
addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) {
|
||||||
processSSETrigger(elt, verb, path, triggerSpec.sseEvent);
|
issueAjaxRequest(verb, path, elt, evt)
|
||||||
} else if (triggerSpec.trigger === "revealed") {
|
})
|
||||||
initScrollHandler();
|
|
||||||
maybeReveal(elt);
|
|
||||||
} else if (triggerSpec.trigger === "intersect") {
|
|
||||||
var observerOptions = {};
|
|
||||||
if (triggerSpec.root) {
|
|
||||||
observerOptions.root = querySelectorExt(elt, triggerSpec.root)
|
|
||||||
}
|
|
||||||
if (triggerSpec.threshold) {
|
|
||||||
observerOptions.threshold = parseFloat(triggerSpec.threshold);
|
|
||||||
}
|
|
||||||
var observer = new IntersectionObserver(function (entries) {
|
|
||||||
for (var i = 0; i < entries.length; i++) {
|
|
||||||
var entry = entries[i];
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
triggerEvent(elt, "intersect");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, observerOptions);
|
|
||||||
observer.observe(elt);
|
|
||||||
addEventListener(elt, verb, path, nodeData, triggerSpec);
|
|
||||||
} else if (triggerSpec.trigger === "load") {
|
|
||||||
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
|
|
||||||
loadImmediately(elt, verb, path, nodeData, triggerSpec.delay);
|
|
||||||
}
|
|
||||||
} else if (triggerSpec.pollInterval) {
|
|
||||||
nodeData.polling = true;
|
|
||||||
processPolling(elt, verb, path, triggerSpec);
|
|
||||||
} else {
|
|
||||||
addEventListener(elt, verb, path, nodeData, triggerSpec);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return explicitAction;
|
return explicitAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTriggerHandler(elt, triggerSpec, nodeData, handler) {
|
||||||
|
if (triggerSpec.sseEvent) {
|
||||||
|
processSSETrigger(elt, handler, triggerSpec.sseEvent);
|
||||||
|
} else if (triggerSpec.trigger === "revealed") {
|
||||||
|
initScrollHandler(handler);
|
||||||
|
maybeReveal(elt, handler);
|
||||||
|
} else if (triggerSpec.trigger === "intersect") {
|
||||||
|
var observerOptions = {};
|
||||||
|
if (triggerSpec.root) {
|
||||||
|
observerOptions.root = querySelectorExt(elt, triggerSpec.root)
|
||||||
|
}
|
||||||
|
if (triggerSpec.threshold) {
|
||||||
|
observerOptions.threshold = parseFloat(triggerSpec.threshold);
|
||||||
|
}
|
||||||
|
var observer = new IntersectionObserver(function (entries) {
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var entry = entries[i];
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
triggerEvent(elt, "intersect");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, observerOptions);
|
||||||
|
observer.observe(elt);
|
||||||
|
addEventListener(elt, handler, nodeData, triggerSpec);
|
||||||
|
} else if (triggerSpec.trigger === "load") {
|
||||||
|
if (!maybeFilterEvent(triggerSpec, makeEvent("load", {elt:elt}))) {
|
||||||
|
loadImmediately(elt, handler, nodeData, triggerSpec.delay);
|
||||||
|
}
|
||||||
|
} else if (triggerSpec.pollInterval) {
|
||||||
|
nodeData.polling = true;
|
||||||
|
processPolling(elt, handler, triggerSpec);
|
||||||
|
} else {
|
||||||
|
addEventListener(elt, handler, nodeData, triggerSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function evalScript(script) {
|
function evalScript(script) {
|
||||||
if (script.type === "text/javascript" || script.type === "module" || script.type === "") {
|
if (script.type === "text/javascript" || script.type === "module" || script.type === "") {
|
||||||
var newScript = getDocument().createElement("script");
|
var newScript = getDocument().createElement("script");
|
||||||
@ -3177,3 +3179,4 @@ return (function () {
|
|||||||
}
|
}
|
||||||
)()
|
)()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user