diff --git a/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor b/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor
new file mode 100644
index 000000000..a82b4049b
--- /dev/null
+++ b/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor
@@ -0,0 +1,23 @@
+@code {
+
+ public static RenderFragment PlanetContent =>
+ @
+ Planet
+
;
+
+ public static RenderFragment CategoryContent =>
+ @
+ Category
+
;
+
+ public static RenderFragment ChatChannelContent =>
+ @
+ Chat Channel
+
;
+
+ public static RenderFragment VoiceChannelContent =>
+ @
+ Voice Channel
+
;
+
+}
\ No newline at end of file
diff --git a/Valour/Client/Components/PlanetsList/PlanetListItems.cs b/Valour/Client/Components/PlanetsList/PlanetListItems.cs
new file mode 100644
index 000000000..1612ee6ae
--- /dev/null
+++ b/Valour/Client/Components/PlanetsList/PlanetListItems.cs
@@ -0,0 +1,31 @@
+using Valour.Client.Components.Utility.DragList;
+using Valour.Sdk.Models;
+using Valour.Shared.Models;
+
+namespace Valour.Client.Components.PlanetsList;
+
+public class PlanetListItem : DragListItem
+{
+ public Planet Planet { get; set; }
+
+ public override uint Depth => 0u;
+ public override uint Position => Planet.Name.FirstOrDefault();
+
+ public PlanetListItem(Planet planet)
+ {
+ Planet = planet;
+ }
+}
+
+public class ChannelListItem : DragListItem
+{
+ public Channel Channel { get; set; }
+
+ public override uint Depth => Channel.Position.Depth;
+ public override uint Position => Channel.RawPosition;
+
+ public ChannelListItem(Channel channel)
+ {
+ Channel = channel;
+ }
+}
\ No newline at end of file
diff --git a/Valour/Client/Components/Utility/DragList/DragListComponent.razor b/Valour/Client/Components/Utility/DragList/DragListComponent.razor
new file mode 100644
index 000000000..4d094c82b
--- /dev/null
+++ b/Valour/Client/Components/Utility/DragList/DragListComponent.razor
@@ -0,0 +1,63 @@
+@inherits ControlledRenderComponentBase
+
+@{
+ if (_items is null)
+ {
+ return;
+ }
+
+ foreach (var item in _items)
+ {
+
+
+ @item.Content
+
+
+
+ }
+}
+
+@code {
+
+ [Parameter]
+ public float NestingMargin { get; set; } = 8f;
+
+ [Parameter]
+ public float ItemHeight { get; set; } = 24f;
+
+ // all items, sorted for render
+ private List _items = new();
+
+ ///
+ /// Sets the items of this drag list. Note that changes to the list will
+ /// pass down (stored by reference) but you must manually trigger rebuilds.
+ ///
+ public void SetTopLevelItems(List items)
+ {
+ _items = items;
+ }
+
+ ///
+ /// Orders,
+ ///
+ public void Rebuild(bool render = true)
+ {
+ OrderItems();
+
+ if (render)
+ {
+ ReRender();
+ }
+ }
+
+ // Orders the items for display
+ private void OrderItems()
+ {
+ if (_items is null || _items.Count == 0)
+ {
+ return;
+ }
+
+ _items.Sort(DragListItem.Compare);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Client/Components/Utility/DragList/DragListItem.cs b/Valour/Client/Components/Utility/DragList/DragListItem.cs
new file mode 100644
index 000000000..3acdd7f64
--- /dev/null
+++ b/Valour/Client/Components/Utility/DragList/DragListItem.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Valour.Client.Components.Utility.DragList;
+
+public abstract class DragListItem
+{
+ ///
+ /// The drag list this item belongs to
+ ///
+ public DragListComponent DragList { get; set; }
+
+ ///
+ /// True if this item can contain other items
+ ///
+ public bool Container { get; set; }
+
+ ///
+ /// True if this item is opened. Only applies to containers.
+ ///
+ public bool Open { get; set; }
+
+ ///
+ /// The amount of margin to apply to children. Only applies to containers.
+ ///
+ public int ChildMargin { get; set; }
+
+ public abstract uint Depth { get; }
+
+ public abstract uint Position { get; }
+
+ public virtual Task OnClick()
+ {
+ if (Container)
+ {
+ // Switch the open state
+ Open = !Open;
+
+ // Re-render drag list
+ DragList.ReRender();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// The content to render for this item
+ ///
+ public RenderFragment Content { get; set; }
+
+ public static int Compare(DragListItem a, DragListItem b)
+ {
+ // Compare position
+ return a.Position.CompareTo(b.Position);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Client/Utility/ModelQueryEngineExtensions.cs b/Valour/Client/Utility/ModelQueryEngineExtensions.cs
new file mode 100644
index 000000000..9ec6ca88f
--- /dev/null
+++ b/Valour/Client/Utility/ModelQueryEngineExtensions.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Components.Web.Virtualization;
+using Valour.Sdk.ModelLogic;
+
+namespace Valour.Client.Utility;
+
+public static class ModelQueryEngineExtensions
+{
+ public static async ValueTask> GetVirtualizedItemsAsync(this ModelQueryEngine engine, ItemsProviderRequest request)
+ where TModel : ClientModel
+ {
+ var queryData = await engine.GetItemsAsync(request.StartIndex, request.Count);
+ return new ItemsProviderResult(queryData.Items, queryData.TotalCount);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Client/wwwroot/js/dayjs.min.js b/Valour/Client/wwwroot/js/dayjs.min.js
new file mode 100644
index 000000000..61916d882
--- /dev/null
+++ b/Valour/Client/wwwroot/js/dayjs.min.js
@@ -0,0 +1 @@
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs=e()}(this,(function(){"use strict";var t=1e3,e=6e4,n=36e5,r="millisecond",i="second",s="minute",u="hour",a="day",o="week",c="month",f="quarter",h="year",d="date",l="Invalid Date",$=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,M={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:function(t){var e=["th","st","nd","rd"],n=t%100;return"["+t+(e[(n-20)%10]||e[n]||e[0])+"]"}},m=function(t,e,n){var r=String(t);return!r||r.length>=e?t:""+Array(e+1-r.length).join(n)+t},v={s:m,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+m(r,2,"0")+":"+m(i,2,"0")},m:function t(e,n){if(e.date()1)return t(u[0])}else{var a=e.name;D[a]=e,i=a}return!r&&i&&(g=i),i||!r&&g},O=function(t,e){if(S(t))return t.clone();var n="object"==typeof e?e:{};return n.date=t,n.args=arguments,new _(n)},b=v;b.l=w,b.i=S,b.w=function(t,e){return O(t,{locale:e.$L,utc:e.$u,x:e.$x,$offset:e.$offset})};var _=function(){function M(t){this.$L=w(t.locale,null,!0),this.parse(t),this.$x=this.$x||t.x||{},this[p]=!0}var m=M.prototype;return m.parse=function(t){this.$d=function(t){var e=t.date,n=t.utc;if(null===e)return new Date(NaN);if(b.u(e))return new Date;if(e instanceof Date)return new Date(e);if("string"==typeof e&&!/Z$/i.test(e)){var r=e.match($);if(r){var i=r[2]-1||0,s=(r[7]||"0").substring(0,3);return n?new Date(Date.UTC(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)):new Date(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)}}return new Date(e)}(t),this.init()},m.init=function(){var t=this.$d;this.$y=t.getFullYear(),this.$M=t.getMonth(),this.$D=t.getDate(),this.$W=t.getDay(),this.$H=t.getHours(),this.$m=t.getMinutes(),this.$s=t.getSeconds(),this.$ms=t.getMilliseconds()},m.$utils=function(){return b},m.isValid=function(){return!(this.$d.toString()===l)},m.isSame=function(t,e){var n=O(t);return this.startOf(e)<=n&&n<=this.endOf(e)},m.isAfter=function(t,e){return O(t)=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,h=r[0]+"-"+r[1]+"-"+r[2]+" "+l+":"+r[4]+":"+r[5]+":000",v=+e;return(o.utc(h).valueOf()-(v-=v%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n,i=this.utcOffset(),a=this.toDate(),u=a.toLocaleString("en-US",{timeZone:t}),f=Math.round((a-new Date(u))/1e3/60),s=15*-Math.round(a.getTimezoneOffset()/15)-f;if(!Number(s))n=this.utcOffset(0,e);else if(n=o(u,{locale:this.$L}).$set("millisecond",this.$ms).utcOffset(s,!0),e){var m=n.utcOffset();n=n.add(i-m,"minute")}return n.$x.$timezone=t,n},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format("YYYY-MM-DD HH:mm:ss:SSS"),{locale:this.$L});return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if("string"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}}));
\ No newline at end of file
diff --git a/Valour/Client/wwwroot/js/jquery-3.7.1.min.js b/Valour/Client/wwwroot/js/jquery-3.7.1.min.js
new file mode 100644
index 000000000..8197e838e
--- /dev/null
+++ b/Valour/Client/wwwroot/js/jquery-3.7.1.min.js
@@ -0,0 +1,2 @@
+/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */
+!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,""],col:[2,""],tr:[2,""],td:[3,""],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0{"%%"!==e&&(o++,"%c"===e&&(s=o))}),r.splice(s,0,n)},r.save=function e(t){try{t?r.storage.setItem("debug",t):r.storage.removeItem("debug")}catch(n){}},r.load=function e(){let t;try{t=r.storage.getItem("debug")}catch(o){}return!t&&void 0!==n&&"env"in n&&(t=n.env.DEBUG),t},r.useColors=function e(){return"undefined"!=typeof window&&!!window.process&&("renderer"===window.process.type||!!window.process.__nwjs)||!("undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/))&&("undefined"!=typeof document&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||"undefined"!=typeof window&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/)&&parseInt(RegExp.$1,10)>=31||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/))},r.storage=function e(){try{return localStorage}catch(t){}}();let o;r.destroy=(o=!1,()=>{o||(o=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}),r.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"],r.log=console.debug||console.log||(()=>{}),t.exports=e("./common")(r);let{formatters:s}=t.exports;s.j=function(e){try{return JSON.stringify(e)}catch(t){return"[UnexpectedJSONParseError]: "+t.message}}}).call(this)}).call(this,e("_process"))},{"./common":2,_process:21}],2:[function(e,t,r){t.exports=function t(r){function n(e){let t,r=null,s,i;function a(...e){if(!a.enabled)return;let r=a,o=Number(new Date),s=o-(t||o);r.diff=s,r.prev=t,r.curr=o,t=o,e[0]=n.coerce(e[0]),"string"!=typeof e[0]&&e.unshift("%O");let i=0;e[0]=e[0].replace(/%([a-zA-Z%])/g,(t,o)=>{if("%%"===t)return"%";i++;let s=n.formatters[o];if("function"==typeof s){let a=e[i];t=s.call(r,a),e.splice(i,1),i--}return t}),n.formatArgs.call(r,e);let c=r.log||n.log;c.apply(r,e)}return a.namespace=e,a.useColors=n.useColors(),a.color=n.selectColor(e),a.extend=o,a.destroy=n.destroy,Object.defineProperty(a,"enabled",{enumerable:!0,configurable:!1,get:()=>null!==r?r:(s!==n.namespaces&&(s=n.namespaces,i=n.enabled(e)),i),set(e){r=e}}),"function"==typeof n.init&&n.init(a),a}function o(e,t){let r=n(this.namespace+(void 0===t?":":t)+e);return r.log=this.log,r}function s(e){return e.toString().substring(2,e.toString().length-2).replace(/\.\*\?$/,"*")}return n.debug=n,n.default=n,n.coerce=function e(t){return t instanceof Error?t.stack||t.message:t},n.disable=function e(){let t=[...n.names.map(s),...n.skips.map(s).map(e=>"-"+e)].join(",");return n.enable(""),t},n.enable=function e(t){n.save(t),n.namespaces=t,n.names=[],n.skips=[];let r,o=("string"==typeof t?t:"").split(/[\s,]+/),s=o.length;for(r=0;r{n[e]=r[e]}),n.names=[],n.skips=[],n.formatters={},n.selectColor=function e(t){let r=0;for(let o=0;o=1.5*r?"s":"")}t.exports=function(e,t){t=t||{};var r,c,u,l,f=typeof e;if("string"===f&&e.length>0)return function e(t){if(!((t=String(t)).length>100)){var r=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(t);if(r){var a=parseFloat(r[1]),c=(r[2]||"ms").toLowerCase();switch(c){case"years":case"year":case"yrs":case"yr":case"y":return a*i;case"weeks":case"week":case"w":return a*s;case"days":case"day":case"d":return a*o;case"hours":case"hour":case"hrs":case"hr":case"h":return a*n;case"minutes":case"minute":case"mins":case"min":case"m":return 6e4*a;case"seconds":case"second":case"secs":case"sec":case"s":return 1e3*a;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return a;default:return}}}}(e);if("number"===f&&isFinite(e)){return t.long?(r=e,c=Math.abs(r),c>=o?a(r,c,o,"day"):c>=n?a(r,c,n,"hour"):c>=6e4?a(r,c,6e4,"minute"):c>=1e3?a(r,c,1e3,"second"):r+" ms"):(u=e,l=Math.abs(u),l>=o?Math.round(u/o)+"d":l>=n?Math.round(u/n)+"h":l>=6e4?Math.round(u/6e4)+"m":l>=1e3?Math.round(u/1e3)+"s":u+"ms")}throw Error("val is not a non-empty string or a valid number. val="+JSON.stringify(e))}},{}],5:[function(e,t,r){let{EventEmitter:n}=e("events"),o=e("./Logger");t.exports=class e extends n{constructor(e){super(),this.setMaxListeners(1/0),this._logger=e||new o("EnhancedEventEmitter")}safeEmit(e,...t){try{this.emit(e,...t)}catch(r){this._logger.error("safeEmit() | event listener threw an error [event:%s]:%o",e,r)}}async safeEmitAsPromise(e,...t){return new Promise((r,n)=>{this.safeEmit(e,...t,r,n)})}}},{"./Logger":6,events:20}],6:[function(e,t,r){let n=e("debug"),o="protoo-client";t.exports=class e{constructor(e){e?(this._debug=n(`${o}:${e}`),this._warn=n(`${o}:WARN:${e}`),this._error=n(`${o}:ERROR:${e}`)):(this._debug=n(o),this._warn=n(`${o}:WARN`),this._error=n(`${o}:ERROR`)),this._debug.log=console.info.bind(console),this._warn.log=console.warn.bind(console),this._error.log=console.error.bind(console)}get debug(){return this._debug}get warn(){return this._warn}get error(){return this._error}}},{debug:1}],7:[function(e,t,r){let n=e("./Logger"),{generateRandomNumber:o}=e("./utils"),s=new n("Message");t.exports=class e{static parse(e){let t,r={};try{t=JSON.parse(e)}catch(n){s.error("parse() | invalid JSON: %s",n);return}if("object"!=typeof t||Array.isArray(t)){s.error("parse() | not an object");return}if(t.request){if(r.request=!0,"string"!=typeof t.method){s.error("parse() | missing/invalid method field");return}if("number"!=typeof t.id){s.error("parse() | missing/invalid id field");return}r.id=t.id,r.method=t.method,r.data=t.data||{}}else if(t.response){if(r.response=!0,"number"!=typeof t.id){s.error("parse() | missing/invalid id field");return}r.id=t.id,t.ok?(r.ok=!0,r.data=t.data||{}):(r.ok=!1,r.errorCode=t.errorCode,r.errorReason=t.errorReason)}else if(t.notification){if(r.notification=!0,"string"!=typeof t.method){s.error("parse() | missing/invalid method field");return}r.method=t.method,r.data=t.data||{}}else{s.error("parse() | missing request/response field");return}return r}static createRequest(e,t){let r={request:!0,id:o(),method:e,data:t||{}};return r}static createSuccessResponse(e,t){let r={response:!0,id:e.id,ok:!0,data:t||{}};return r}static createErrorResponse(e,t,r){let n={response:!0,id:e.id,ok:!1,errorCode:t,errorReason:r};return n}static createNotification(e,t){return{notification:!0,method:e,data:t||{}}}}},{"./Logger":6,"./utils":11}],8:[function(e,t,r){let n=e("./Logger"),o=e("./EnhancedEventEmitter"),s=e("./Message"),i=new n("Peer");t.exports=class e extends o{constructor(e){super(i),i.debug("constructor()"),this._closed=!1,this._transport=e,this._connected=!1,this._data={},this._sents=new Map,this._handleTransport()}get closed(){return this._closed}get connected(){return this._connected}get data(){return this._data}set data(e){throw Error("cannot override data object")}close(){if(!this._closed){for(let e of(i.debug("close()"),this._closed=!0,this._connected=!1,this._transport.close(),this._sents.values()))e.close();this.safeEmit("close")}}async request(e,t){let r=s.createRequest(e,t);return this._logger.debug("request() [method:%s, id:%s]",e,r.id),await this._transport.send(r),new Promise((e,t)=>{let n=1500*(15+.1*this._sents.size),o={id:r.id,method:r.method,resolve:t=>{this._sents.delete(r.id)&&(clearTimeout(o.timer),e(t))},reject:e=>{this._sents.delete(r.id)&&(clearTimeout(o.timer),t(e))},timer:setTimeout(()=>{this._sents.delete(r.id)&&t(Error("request timeout"))},n),close(){clearTimeout(o.timer),t(Error("peer closed"))}};this._sents.set(r.id,o)})}async notify(e,t){let r=s.createNotification(e,t);this._logger.debug("notify() [method:%s]",e),await this._transport.send(r)}_handleTransport(){if(this._transport.closed){this._closed=!0,setTimeout(()=>{this._closed||(this._connected=!1,this.safeEmit("close"))});return}this._transport.on("open",()=>{this._closed||(i.debug('emit "open"'),this._connected=!0,this.safeEmit("open"))}),this._transport.on("disconnected",()=>{this._closed||(i.debug('emit "disconnected"'),this._connected=!1,this.safeEmit("disconnected"))}),this._transport.on("failed",e=>{this._closed||(i.debug('emit "failed" [currentAttempt:%s]',e),this._connected=!1,this.safeEmit("failed",e))}),this._transport.on("close",()=>{this._closed||(this._closed=!0,i.debug('emit "close"'),this._connected=!1,this.safeEmit("close"))}),this._transport.on("message",e=>{e.request?this._handleRequest(e):e.response?this._handleResponse(e):e.notification&&this._handleNotification(e)})}_handleRequest(e){try{this.emit("request",e,t=>{let r=s.createSuccessResponse(e,t);this._transport.send(r).catch(()=>{})},(t,r)=>{t instanceof Error?(r=t.message,t=500):"number"==typeof t&&r instanceof Error&&(r=r.message);let n=s.createErrorResponse(e,t,r);this._transport.send(n).catch(()=>{})})}catch(t){let r=s.createErrorResponse(e,500,String(t));this._transport.send(r).catch(()=>{})}}_handleResponse(e){let t=this._sents.get(e.id);if(!t){i.error("received response does not match any sent request [id:%s]",e.id);return}if(e.ok)t.resolve(e.data);else{let r=Error(e.errorReason);r.code=e.errorCode,t.reject(r)}}_handleNotification(e){this.safeEmit("notification",e)}}},{"./EnhancedEventEmitter":5,"./Logger":6,"./Message":7}],9:[function(e,t,r){let{version:n}=e("../package.json"),o=e("./Peer"),s=e("./transports/WebSocketTransport");r.version=n,r.Peer=o,r.WebSocketTransport=s},{"../package.json":12,"./Peer":8,"./transports/WebSocketTransport":10}],10:[function(e,t,r){let n=e("websocket").w3cwebsocket,o=e("retry"),s=e("../Logger"),i=e("../EnhancedEventEmitter"),a=e("../Message"),c={retries:10,factor:2,minTimeout:1e3,maxTimeout:8e3},u=new s("WebSocketTransport");t.exports=class e extends i{constructor(e,t){super(u),u.debug("constructor() [url:%s, options:%o]",e,t),this._closed=!1,this._url=e,this._options=t||{},this._ws=null,this._runWebSocket()}get closed(){return this._closed}close(){if(!this._closed){u.debug("close()"),this._closed=!0,this.safeEmit("close");try{this._ws.onopen=null,this._ws.onclose=null,this._ws.onerror=null,this._ws.onmessage=null,this._ws.close()}catch(e){u.error("close() | error closing the WebSocket: %o",e)}}}async send(e){if(this._closed)throw Error("transport closed");try{this._ws.send(JSON.stringify(e))}catch(t){throw u.warn("send() failed:%o",t),t}}_runWebSocket(){let e=o.operation(this._options.retry||c),t=!1;e.attempt(r=>{if(this._closed){e.stop();return}u.debug("_runWebSocket() [currentAttempt:%s]",r),this._ws=new n(this._url,"protoo",this._options.origin,this._options.headers,this._options.requestOptions,this._options.clientConfig),this._ws.onopen=()=>{this._closed||(t=!0,this.safeEmit("open"))},this._ws.onclose=n=>{if(!this._closed){if(u.warn('WebSocket "close" event [wasClean:%s, code:%s, reason:"%s"]',n.wasClean,n.code,n.reason),4e3!==n.code){if(t){if(e.stop(),this.safeEmit("disconnected"),this._closed)return;this._runWebSocket();return}if(this.safeEmit("failed",r),this._closed||e.retry(!0))return}this._closed=!0,this.safeEmit("close")}},this._ws.onerror=()=>{this._closed||u.error('WebSocket "error" event')},this._ws.onmessage=e=>{if(this._closed)return;let t=a.parse(e.data);if(t){if(0===this.listenerCount("message")){u.error('no listeners for WebSocket "message" event, ignoring received message');return}this.safeEmit("message",t)}}})}}},{"../EnhancedEventEmitter":5,"../Logger":6,"../Message":7,retry:13,websocket:16}],11:[function(e,t,r){r.generateRandomNumber=function(){return Math.round(1e7*Math.random())}},{}],12:[function(e,t,r){t.exports={name:"protoo-client",version:"4.0.6",description:"protoo JavaScript client module",author:"I\xf1aki Baz Castillo ",homepage:"https://protoo.versatica.com",license:"MIT",repository:{type:"git",url:"https://github.com/ibc/protoo.git"},main:"lib/index.js",keywords:["nodejs","browser","websocket"],engines:{node:">=8.0.0"},scripts:{lint:"eslint -c .eslintrc.js lib"},dependencies:{debug:"^4.3.1",events:"^3.2.0",retry:"^0.12.0"},devDependencies:{eslint:"^5.16.0"},optionalDependencies:{websocket:"^1.0.33"}}},{}],13:[function(e,t,r){t.exports=e("./lib/retry")},{"./lib/retry":14}],14:[function(e,t,r){var n=e("./retry_operation");r.operation=function(e){var t=r.timeouts(e);return new n(t,{forever:e&&e.forever,unref:e&&e.unref,maxRetryTime:e&&e.maxRetryTime})},r.timeouts=function(e){if(e instanceof Array)return[].concat(e);var t={retries:10,factor:2,minTimeout:1e3,maxTimeout:1/0,randomize:!1};for(var r in e)t[r]=e[r];if(t.minTimeout>t.maxTimeout)throw Error("minTimeout is greater than maxTimeout");for(var n=[],o=0;o=this._maxRetryTime)return this._errors.unshift(Error("RetryOperation timeout occurred")),!1;this._errors.push(e);var r=this._timeouts.shift();if(void 0===r){if(!this._cachedTimeouts)return!1;this._errors.splice(this._errors.length-1,this._errors.length),this._timeouts=this._cachedTimeouts.slice(0),r=this._timeouts.shift()}var n=this,o=setTimeout(function(){n._attempts++,n._operationTimeoutCb&&(n._timeout=setTimeout(function(){n._operationTimeoutCb(n._attempts)},n._operationTimeout),n._options.unref&&n._timeout.unref()),n._fn(n._attempts)},r);return this._options.unref&&o.unref(),!0},n.prototype.attempt=function(e,t){this._fn=e,t&&(t.timeout&&(this._operationTimeout=t.timeout),t.cb&&(this._operationTimeoutCb=t.cb));var r=this;this._operationTimeoutCb&&(this._timeout=setTimeout(function(){r._operationTimeoutCb()},r._operationTimeout)),this._operationStart=new Date().getTime(),this._fn(this._attempts)},n.prototype.try=function(e){console.log("Using RetryOperation.try() is deprecated"),this.attempt(e)},n.prototype.start=function(e){console.log("Using RetryOperation.start() is deprecated"),this.attempt(e)},n.prototype.start=n.prototype.try,n.prototype.errors=function(){return this._errors},n.prototype.attempts=function(){return this._attempts},n.prototype.mainError=function(){if(0===this._errors.length)return null;for(var e={},t=null,r=0,n=0;n=r&&(t=o,r=i)}return t}},{}],16:[function(e,t,r){if("object"==typeof globalThis)o=globalThis;else try{o=e("es5-ext/global")}catch(n){}finally{if(o||"undefined"==typeof window||(o=window),!o)throw Error("Could not determine global this")}var o,s=o.WebSocket||o.MozWebSocket,i=e("./version");function a(e,t){var r;return t?new s(e,t):new s(e)}s&&["CONNECTING","OPEN","CLOSING","CLOSED"].forEach(function(e){Object.defineProperty(a,e,{get:function(){return s[e]}})}),t.exports={w3cwebsocket:s?a:null,version:i}},{"./version":17,"es5-ext/global":3}],17:[function(e,t,r){t.exports=e("../package.json").version},{"../package.json":18}],18:[function(e,t,r){t.exports={name:"websocket",description:"Websocket Client & Server Library implementing the WebSocket protocol as specified in RFC 6455.",keywords:["websocket","websockets","socket","networking","comet","push","RFC-6455","realtime","server","client"],author:"Brian McKelvey (https://github.com/theturtle32)",contributors:["I\xf1aki Baz Castillo (http://dev.sipdoc.net)"],version:"1.0.34",repository:{type:"git",url:"https://github.com/theturtle32/WebSocket-Node.git"},homepage:"https://github.com/theturtle32/WebSocket-Node",engines:{node:">=4.0.0"},dependencies:{bufferutil:"^4.0.1",debug:"^2.2.0","es5-ext":"^0.10.50","typedarray-to-buffer":"^3.1.5","utf-8-validate":"^5.0.2",yaeti:"^0.0.6"},devDependencies:{"buffer-equal":"^1.0.0",gulp:"^4.0.2","gulp-jshint":"^2.0.4","jshint-stylish":"^2.2.1",jshint:"^2.0.0",tape:"^4.9.1"},config:{verbose:!1},scripts:{test:"tape test/unit/*.js",gulp:"gulp"},main:"index",directories:{lib:"./lib"},browser:"lib/browser.js",license:"Apache-2.0"}},{}],19:[function(e,t,r){window.protooClient=e("protoo-client")},{"protoo-client":9}],20:[function(e,t,r){"use strict";var n,o="object"==typeof Reflect?Reflect:null,s=o&&"function"==typeof o.apply?o.apply:function e(t,r,n){return Function.prototype.apply.call(t,r,n)};n=o&&"function"==typeof o.ownKeys?o.ownKeys:Object.getOwnPropertySymbols?function e(t){return Object.getOwnPropertyNames(t).concat(Object.getOwnPropertySymbols(t))}:function e(t){return Object.getOwnPropertyNames(t)};var i=Number.isNaN||function e(t){return t!=t};function a(){a.init.call(this)}t.exports=a,t.exports.once=function e(t,r){return new Promise(function(e,n){var o,s,i;function a(e){t.removeListener(r,c),n(e)}function c(){"function"==typeof t.removeListener&&t.removeListener("error",a),e([].slice.call(arguments))}g(t,r,c,{once:!0}),"error"!==r&&(o=t,s=a,i={once:!0},"function"==typeof o.on&&g(o,"error",s,i))})},a.EventEmitter=a,a.prototype._events=void 0,a.prototype._eventsCount=0,a.prototype._maxListeners=void 0;var c=10;function u(e){if("function"!=typeof e)throw TypeError('The "listener" argument must be of type Function. Received type '+typeof e)}function l(e){return void 0===e._maxListeners?a.defaultMaxListeners:e._maxListeners}function f(e,t,r,n){if(u(r),void 0===(s=e._events)?(s=e._events=Object.create(null),e._eventsCount=0):(void 0!==s.newListener&&(e.emit("newListener",t,r.listener?r.listener:r),s=e._events),i=s[t]),void 0===i)i=s[t]=r,++e._eventsCount;else if("function"==typeof i?i=s[t]=n?[r,i]:[i,r]:n?i.unshift(r):i.push(r),(o=l(e))>0&&i.length>o&&!i.warned){i.warned=!0;var o,s,i,a,c=Error("Possible EventEmitter memory leak detected. "+i.length+" "+String(t)+" listeners added. Use emitter.setMaxListeners() to increase limit");c.name="MaxListenersExceededWarning",c.emitter=e,c.type=t,c.count=i.length,a=c,console&&console.warn&&console.warn(a)}return e}function h(){if(!this.fired)return(this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length)?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function p(e,t,r){var n={fired:!1,wrapFn:void 0,target:e,type:t,listener:r},o=h.bind(n);return o.listener=r,n.wrapFn=o,o}function d(e,t,r){var n=e._events;if(void 0===n)return[];var o=n[t];return void 0===o?[]:"function"==typeof o?r?[o.listener||o]:[o]:r?function e(t){for(var r=Array(t.length),n=0;n0&&(a=r[0]),a instanceof Error)throw a;var a,c=Error("Unhandled error."+(a?" ("+a.message+")":""));throw c.context=a,c}var u=i[t];if(void 0===u)return!1;if("function"==typeof u)s(u,this,r);else for(var l=u.length,f=m(u,l),n=0;n=0;i--)if(n[i]===r||n[i].listener===r){a=n[i].listener,s=i;break}if(s<0)return this;0===s?n.shift():function e(t,r){for(;r+1=0;o--)this.removeListener(t,r[o]);return this},a.prototype.listeners=function e(t){return d(this,t,!0)},a.prototype.rawListeners=function e(t){return d(this,t,!1)},a.listenerCount=function(e,t){return"function"==typeof e.listenerCount?e.listenerCount(t):$.call(e,t)},a.prototype.listenerCount=$,a.prototype.eventNames=function e(){return this._eventsCount>0?n(this._events):[]}},{}],21:[function(e,t,r){var n,o,s,i=t.exports={};function a(){throw Error("setTimeout has not been defined")}function c(){throw Error("clearTimeout has not been defined")}function u(e){if(n===setTimeout)return setTimeout(e,0);if((n===a||!n)&&setTimeout)return n=setTimeout,setTimeout(e,0);try{return n(e,0)}catch(t){try{return n.call(null,e,0)}catch(r){return n.call(this,e,0)}}}!function(){try{n="function"==typeof setTimeout?setTimeout:a}catch(e){n=a}try{o="function"==typeof clearTimeout?clearTimeout:c}catch(t){o=c}}();var l=[],f=!1,h=-1;function p(){f&&s&&(f=!1,s.length?l=s.concat(l):h=-1,l.length&&d())}function d(){if(!f){var e=u(p);f=!0;for(var t=l.length;t;){for(s=l,l=[];++h1)for(var r=1;r":">","'":"'",'"':"""},re=/(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g,UFE0Fg=/\uFE0F/g,U200D=String.fromCharCode(8205),rescaper=/[&<>'"]/g,shouldntBeParsed=/^(?:iframe|noframes|noscript|script|select|style|textarea)$/,fromCharCode=String.fromCharCode;return twemoji;function createText(text,clean){return document.createTextNode(clean?text.replace(UFE0Fg,""):text)}function escapeHTML(s){return s.replace(rescaper,replacer)}function defaultImageSrcGenerator(icon,options){return"".concat(options.base,options.size,"/",icon,options.ext)}function grabAllTextNodes(node,allText){var childNodes=node.childNodes,length=childNodes.length,subnode,nodeType;while(length--){subnode=childNodes[length];nodeType=subnode.nodeType;if(nodeType===3){allText.push(subnode)}else if(nodeType===1&&!("ownerSVGElement"in subnode)&&!shouldntBeParsed.test(subnode.nodeName.toLowerCase())){grabAllTextNodes(subnode,allText)}}return allText}function grabTheRightIcon(rawText){return toCodePoint(rawText.indexOf(U200D)<0?rawText.replace(UFE0Fg,""):rawText)}function parseNode(node,options){var allText=grabAllTextNodes(node,[]),length=allText.length,attrib,attrname,modified,fragment,subnode,text,match,i,index,img,rawText,iconId,src;while(length--){modified=false;fragment=document.createDocumentFragment();subnode=allText[length];text=subnode.nodeValue;i=0;while(match=re.exec(text)){index=match.index;if(index!==i){fragment.appendChild(createText(text.slice(i,index),true))}rawText=match[0];iconId=grabTheRightIcon(rawText);i=index+rawText.length;src=options.callback(iconId,options);if(iconId&&src){img=new Image;img.onerror=options.onerror;img.setAttribute("draggable","false");attrib=options.attributes(rawText,iconId);for(attrname in attrib){if(attrib.hasOwnProperty(attrname)&&attrname.indexOf("on")!==0&&!img.hasAttribute(attrname)){img.setAttribute(attrname,attrib[attrname])}}img.className=options.className;img.alt=rawText;img.src=src;modified=true;fragment.appendChild(img)}if(!img)fragment.appendChild(createText(rawText,false));img=null}if(modified){if(i")}return ret})}function replacer(m){return escaper[m]}function returnNull(){return null}function toSizeSquaredAsset(value){return typeof value==="number"?value+"x"+value:value}function fromCodePoint(codepoint){var code=typeof codepoint==="string"?parseInt(codepoint,16):codepoint;if(code<65536){return fromCharCode(code)}code-=65536;return fromCharCode(55296+(code>>10),56320+(code&1023))}function parse(what,how){if(!how||typeof how==="function"){how={callback:how}}return(typeof what==="string"?parseString:parseNode)(what,{callback:how.callback||defaultImageSrcGenerator,attributes:typeof how.attributes==="function"?how.attributes:returnNull,base:typeof how.base==="string"?how.base:twemoji.base,ext:how.ext||twemoji.ext,size:how.folder||toSizeSquaredAsset(how.size||twemoji.size),className:how.className||twemoji.className,onerror:how.onerror||twemoji.onerror})}function replace(text,callback){return String(text).replace(re,callback)}function test(text){re.lastIndex=0;var result=re.test(text);re.lastIndex=0;return result}function toCodePoint(unicodeSurrogates,sep){var r=[],c=0,p=0,i=0;while(i InPlanet(this IQueryable channels, long? planetId)
+ {
+ return channels.Where(x => x.PlanetId == planetId);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static IQueryable InParent(this IQueryable channels, long? parentId)
+ {
+ return channels.Where(x => x.ParentId == parentId);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static IQueryable DirectChildrenOf(this IQueryable channels, ISharedChannel channel)
+ {
+ return DirectChildrenOf(channels, channel.PlanetId, channel.RawPosition);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static IQueryable DirectChildrenOf(this IQueryable channels, long? planetId, uint position)
+ {
+ // In this case we return nothing
+ if (planetId is null)
+ return channels.Where(x => false);
+
+ var depth = ChannelPosition.GetDepth(position);
+ var bounds = ChannelPosition.GetDescendentBounds(position, depth);
+ var directChildMask = ChannelPosition.GetDirectChildMaskByDepth(depth);
+
+ return channels.Where(x =>
+ x.PlanetId == planetId &&
+ x.RawPosition >= bounds.lower &&
+ x.RawPosition < bounds.upper &&
+ (x.RawPosition & directChildMask) == position);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static IQueryable DescendantsOf(this IQueryable channels, ISharedChannel channel)
+ {
+ return DescendantsOf(channels, channel.PlanetId, channel.RawPosition);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static IQueryable DescendantsOf(this IQueryable channels, long? planetId, uint position)
+ {
+ // In this case we return nothing
+ if (planetId is null)
+ return channels.Where(x => false);
+
+ var bounds = ChannelPosition.GetDescendentBounds(position);
+ return channels.Where(x =>
+ x.PlanetId == planetId &&
+ x.RawPosition >= bounds.lower &&
+ x.RawPosition < bounds.upper);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Client/ModelCache.cs b/Valour/Sdk/Client/ModelCache.cs
new file mode 100644
index 000000000..1ef397fe2
--- /dev/null
+++ b/Valour/Sdk/Client/ModelCache.cs
@@ -0,0 +1,83 @@
+using System.Collections.Concurrent;
+using Valour.Sdk.ModelLogic;
+using Valour.Sdk.Services;
+
+namespace Valour.Sdk.Client;
+
+/* Valour (TM) - A free and secure chat client
+* Copyright (C) 2024 Valour Software LLC
+* This program is subject to the GNU Affero General Public license
+* A copy of the license should be included - if not, see
+*/
+
+public class ModelCache
+ where TModel : ClientModel
+ where TId : IEquatable
+{
+ private readonly ConcurrentDictionary _innerCache = new();
+
+ ///
+ /// Places an item into the cache. Returns if the item already exists and should be updated.
+ ///
+ public TModel Put(TId id, TModel model)
+ {
+ // Empty object is ignored
+ if (model is null)
+ return null;
+
+ if (!_innerCache.TryAdd(id, model)) // Fails if already exists
+ {
+ return _innerCache[id];
+ }
+
+ return null;
+ }
+
+ ///
+ /// Places an item into the cache, and replaces the item if it already exists
+ ///
+ public void PutReplace(TId id, TModel model)
+ {
+ _innerCache[id] = model;
+ }
+
+ ///
+ /// Returns true if the cache contains the item
+ ///
+ public bool Contains(TId id)
+ {
+ return _innerCache.ContainsKey(id);
+ }
+
+ ///
+ /// Returns all the items of the given type. You can use Linq functions like .Where on this function.
+ ///
+ public IEnumerable GetAll()
+ {
+ return _innerCache.Values;
+ }
+
+
+ ///
+ /// Returns the item for the given id, or null if it does not exist
+ ///
+ public bool TryGet(TId id, out TModel model)
+ {
+ return _innerCache.TryGetValue(id, out model);
+ }
+
+ ///
+ /// Removes an item if present in the cache
+ ///
+ public void Remove(TId id)
+ {
+ _innerCache.TryRemove(id, out var _);
+ }
+
+ public TModel TakeAndRemove(TId id)
+ {
+ _innerCache.TryRemove(id, out var model);
+ return model;
+ }
+}
+
diff --git a/Valour/Sdk/ModelLogic/ClientModel.cs b/Valour/Sdk/ModelLogic/ClientModel.cs
new file mode 100644
index 000000000..355775d9b
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ClientModel.cs
@@ -0,0 +1,138 @@
+using System.Text.Json.Serialization;
+using Valour.Sdk.Client;
+using Valour.Sdk.Nodes;
+using Valour.Shared;
+using Valour.Shared.Models;
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.ModelLogic;
+
+public abstract class ClientModel
+{
+ [JsonIgnore]
+ public virtual string BaseRoute => $"api/{GetType().Name}";
+
+ ///
+ /// Ran when this item is deleted
+ ///
+ public HybridEvent Deleted;
+
+ ///
+ /// Custom logic on model deletion
+ ///
+ protected virtual void OnDeleted() { }
+
+ ///
+ /// The Valour Client this model belongs to
+ ///
+ public ValourClient Client { get; private set; }
+
+ ///
+ /// The node this model belongs to
+ ///
+ public virtual Node Node => Client?.PrimaryNode;
+
+ ///
+ /// Safely invokes the deleted event
+ ///
+ public void InvokeDeletedEvent()
+ {
+ OnDeleted();
+ Deleted?.Invoke();
+ }
+
+ ///
+ /// Sets the client which owns this model
+ ///
+ public void SetClient(ValourClient client)
+ {
+ Client = client;
+ }
+}
+
+public abstract class ClientModel : ClientModel
+ where TSelf : ClientModel
+{
+
+ ///
+ /// Ran when this item is updated
+ ///
+ public HybridEvent> Updated;
+
+ ///
+ /// Custom logic on model update
+ ///
+ protected virtual void OnUpdated(ModelUpdateEvent eventData) { }
+
+ ///
+ /// Adds this item to the cache. If a copy already exists, it is returned to be updated.
+ ///
+ public abstract TSelf AddToCacheOrReturnExisting();
+
+ ///
+ /// Returns and removes this item from the cache.
+ ///
+ public abstract TSelf TakeAndRemoveFromCache();
+
+ public virtual void SyncSubModels(bool skipEvent = false, int flags = 0) { }
+
+ ///
+ /// Safely invokes the updated event
+ ///
+ public void InvokeUpdatedEvent(ModelUpdateEvent eventData)
+ {
+ OnUpdated(eventData);
+ Updated?.Invoke(eventData);
+ }
+}
+
+///
+/// A live model is a model that is updated in real time
+///
+public abstract class ClientModel : ClientModel, ISharedModel
+ where TSelf : ClientModel // curiously recurring template pattern
+ where TId : IEquatable
+{
+ public TId Id { get; set; }
+
+ [JsonIgnore]
+ public virtual string IdRoute => $"{BaseRoute}/{Id}";
+
+ ///
+ /// Attempts to create this item on the server
+ ///
+ /// The result, with the created item (if successful)
+ public virtual Task> CreateAsync()
+ {
+ if (!Id.Equals(default))
+ throw new Exception("Trying to create a model with an ID already assigned. Has it already been created?");
+
+ return Node.PostAsyncWithResponse(BaseRoute, this);
+ }
+
+
+ ///
+ /// Attempts to update this item on the server
+ ///
+ /// The result, with the updated item (if successful)
+ public virtual Task> UpdateAsync()
+ {
+ if (Id.Equals(default))
+ throw new Exception("Trying to update a model with no ID assigned. Has it been created?");
+
+ return Node.PutAsyncWithResponse(IdRoute, this);
+ }
+
+ ///
+ /// Attempts to delete this item on the server
+ ///
+ /// The result
+ public virtual Task DeleteAsync()
+ {
+ if (Id.Equals(default))
+ throw new Exception("Trying to delete a model with no ID assigned. Does it exist?");
+
+ return Node.DeleteAsync(IdRoute);
+ }
+}
+
diff --git a/Valour/Sdk/ModelLogic/ClientPlanetModel.cs b/Valour/Sdk/ModelLogic/ClientPlanetModel.cs
new file mode 100644
index 000000000..c4f24692b
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ClientPlanetModel.cs
@@ -0,0 +1,53 @@
+using Valour.Sdk.ModelLogic.Exceptions;
+using Valour.Sdk.Nodes;
+
+namespace Valour.Sdk.ModelLogic;
+
+public abstract class ClientPlanetModel : ClientModel
+ where TSelf : ClientPlanetModel
+ where TId : IEquatable
+{
+ private Planet _planet;
+ protected abstract long? GetPlanetId();
+
+ ///
+ /// The Planet this model belongs to.
+ /// The Planet should always be in cache before Planet Models are grabbed.
+ /// If for some reason planet is null, it will be fetched from the cache.
+ /// If it is not in cache, you should have loaded the planet first.
+ ///
+ public Planet Planet
+ {
+ get
+ {
+ if (_planet is not null) // If the planet is already stored, return it
+ return _planet;
+
+ var planetId = GetPlanetId(); // Get the planet id from the model
+ if (planetId is null || planetId == -1) // If the id is null or -1, the model is not associated with a planet
+ return null;
+
+ Client.Cache.Planets.TryGet(planetId.Value, out _planet); // Try to assign from cache
+ if (_planet is null) // If it wasn't in cache, throw an exception. Should have loaded the planet first.
+ throw new PlanetNotLoadedException(planetId.Value, this);
+
+ // Return the planet
+ return _planet;
+ }
+ }
+
+ public override Node Node
+ {
+ get
+ {
+ var planetId = GetPlanetId();
+
+ if (planetId is null || planetId == -1)
+ return Client.PrimaryNode;
+
+ return Client.NodeService.GetKnownByPlanet(planetId.Value);
+ }
+ }
+}
+
+
diff --git a/Valour/Sdk/ModelLogic/Exceptions/PlanetNotLoadedException.cs b/Valour/Sdk/ModelLogic/Exceptions/PlanetNotLoadedException.cs
new file mode 100644
index 000000000..29727d208
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/Exceptions/PlanetNotLoadedException.cs
@@ -0,0 +1,11 @@
+using Valour.Shared.Models;
+
+namespace Valour.Sdk.ModelLogic.Exceptions;
+
+public class PlanetNotLoadedException : Exception
+{
+ public PlanetNotLoadedException(long planetId, ISharedModel requestedBy)
+ : base($"Tried to access planet {planetId} but it was not loaded. Requested by {requestedBy.GetType()}")
+ {
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ISortableModel.cs b/Valour/Sdk/ModelLogic/ISortableModel.cs
new file mode 100644
index 000000000..2a679f1f8
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ISortableModel.cs
@@ -0,0 +1,14 @@
+using Valour.Shared.Models;
+
+namespace Valour.Sdk.ModelLogic;
+
+public interface ISortableModel : ISortable
+ where TModel : ClientModel
+{
+}
+
+public interface ISortableModel : ISortable
+ where TModel : ClientModel
+ where TId : IEquatable
+{
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/IVoiceChannel.cs b/Valour/Sdk/ModelLogic/IVoiceChannel.cs
new file mode 100644
index 000000000..70fb9c08c
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/IVoiceChannel.cs
@@ -0,0 +1,5 @@
+namespace Valour.Sdk.ModelLogic;
+
+public interface IVoiceChannel
+{
+}
diff --git a/Valour/Sdk/ModelLogic/ModelExtensions.cs b/Valour/Sdk/ModelLogic/ModelExtensions.cs
new file mode 100644
index 000000000..603c411e8
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelExtensions.cs
@@ -0,0 +1,24 @@
+using Valour.Sdk.Services;
+
+namespace Valour.Sdk.ModelLogic;
+
+public static class ModelExtensions
+{
+ ///
+ /// Syncs all items in the list to the cache and updates
+ /// references in the list.
+ ///
+ public static List SyncAll(this List list, CacheService cache)
+ where T : ClientModel
+ {
+ if (list is null || list.Count == 0)
+ return null;
+
+ for (int i = 0; i < list.Count; i++)
+ {
+ list[i] = cache.Sync(list[i]);
+ }
+
+ return list;
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ModelList.cs b/Valour/Sdk/ModelLogic/ModelList.cs
new file mode 100644
index 000000000..36815d379
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelList.cs
@@ -0,0 +1,250 @@
+using System.Collections;
+using System.Runtime.CompilerServices;
+using Valour.Shared.Models;
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.ModelLogic;
+
+public enum ListChangeType
+{
+ Added,
+ Updated,
+ Removed,
+ Set,
+ Cleared,
+ Reordered
+}
+
+public readonly struct ModelListChangeEvent
+ where T : class
+{
+ public static readonly ModelListChangeEvent Set = new(ListChangeType.Set, default);
+ public static readonly ModelListChangeEvent Clear = new(ListChangeType.Cleared, default);
+ public static readonly ModelListChangeEvent Reordered = new(ListChangeType.Reordered, default);
+
+ public readonly ListChangeType ChangeType;
+ public readonly T Model;
+
+ public ModelListChangeEvent(ListChangeType changeType, T model)
+ {
+ ChangeType = changeType;
+ Model = model;
+ }
+}
+
+///
+/// Reactive lists are lists that can be observed for changes.
+///
+public class ModelList : IEnumerable, IDisposable
+ where TModel : ClientModel
+ where TId : IEquatable
+{
+ public HybridEvent> Changed; // We don't assign because += and -= will do it
+
+ protected readonly List List;
+ protected Dictionary IdMap;
+ public IReadOnlyList Values;
+
+ public int Count => List.Count;
+
+ public ModelList(List startingList = null)
+ {
+ List = startingList ?? new List();
+ IdMap = List.ToDictionary(x => x.Id);
+ Values = List;
+ }
+
+ // Make iterable
+ public TModel this[int index] => List[index];
+ public IEnumerator GetEnumerator() => List.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator();
+
+ public virtual void Upsert(TModel item, bool skipEvent = false)
+ {
+ ListChangeType changeType;
+
+ if (!List.Contains(item))
+ {
+ List.Add(item);
+ changeType = ListChangeType.Added;
+ }
+ else
+ {
+ changeType = ListChangeType.Updated;
+ }
+
+ IdMap[item.Id] = item;
+
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(new ModelListChangeEvent(changeType, item));
+ }
+
+ public virtual void Remove(TModel item, bool skipEvent = false)
+ {
+ if (!IdMap.ContainsKey(item.Id))
+ return;
+
+ List.Remove(item);
+ IdMap.Remove(item.Id);
+
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(new ModelListChangeEvent(ListChangeType.Removed, item));
+ }
+
+ public virtual void Set(List items, bool skipEvent = false)
+ {
+ // We clear rather than replace the list to ensure that the reference is maintained
+ // Because the reference may be used across the application.
+ List.Clear();
+ IdMap.Clear();
+
+ List.AddRange(items);
+ IdMap = List.ToDictionary(x => x.Id);
+
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(ModelListChangeEvent.Set);
+ }
+
+ public virtual void Clear(bool skipEvent = false)
+ {
+ List.Clear();
+ IdMap.Clear();
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(ModelListChangeEvent.Clear);
+ }
+
+ public bool TryGet(TId id, out TModel item)
+ {
+ return IdMap.TryGetValue(id, out item);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool Contains(TModel item)
+ {
+ return IdMap.ContainsKey(item.Id); // This is faster than List.Contains
+ // return List.Contains(item);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool ContainsId(TId id)
+ {
+ return IdMap.ContainsKey(id);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Sort(Comparison comparison)
+ {
+ List.Sort(comparison);
+ }
+
+ ///
+ /// Exists so that external full list changes can be notified.
+ ///
+ public void NotifySet()
+ {
+ Changed?.Invoke(ModelListChangeEvent.Set);
+ }
+
+ public void Dispose()
+ {
+ Changed?.Dispose();
+
+ Changed = null;
+
+ List.Clear();
+ IdMap.Clear();
+ }
+}
+
+///
+/// Reactive lists are lists that can be observed for changes.
+/// This version of the list is ordered, and will automatically sort the list when items are added or updated.
+///
+public class SortedModelList : ModelList
+ where TModel : ClientModel, ISortable
+ where TId : IEquatable
+{
+ public SortedModelList(List startingList = null) : base(startingList)
+ {
+ }
+
+ public void Upsert(ModelUpdateEvent updateEvent, bool skipEvent = false)
+ {
+ Upsert(updateEvent.Model, skipEvent, updateEvent.PositionChange);
+ }
+
+ public override void Upsert(TModel item, bool skipEvent = false)
+ {
+ Upsert(item, skipEvent, null);
+ }
+
+ ///
+ /// Updates if the item is in the list. If the item is not in the list, it is ignored.
+ ///
+ public void Update(ModelUpdateEvent updateEvent, bool skipEvent = false)
+ {
+ if (!Contains(updateEvent.Model))
+ return;
+
+ Upsert(updateEvent.Model, skipEvent, updateEvent.PositionChange);
+ }
+
+ public void Upsert(TModel item, bool skipEvent = false, PositionChange? positionChange = null)
+ {
+ ListChangeType changeType;
+
+ var index = List.BinarySearch(item, ISortable.Comparer);
+ if (index < 0)
+ {
+ // Insert new item at the correct position
+ index = ~index;
+ List.Insert(index, item);
+ IdMap[item.Id] = item;
+ changeType = ListChangeType.Added;
+ }
+ else
+ {
+ // If positionChange is specified, resort the item
+ if (positionChange is not null)
+ {
+ List.RemoveAt(index);
+ var newIndex = List.BinarySearch(item, ISortable.Comparer);
+ if (newIndex < 0) newIndex = ~newIndex;
+ List.Insert(newIndex, item);
+ }
+
+ changeType = ListChangeType.Updated;
+ }
+
+ if (!skipEvent && Changed is not null)
+ Changed?.Invoke(new ModelListChangeEvent(changeType, item));
+ }
+
+
+ public void UpsertNoSort(TModel item, bool skipEvent = false)
+ {
+ base.Upsert(item, skipEvent);
+ }
+
+ public override void Set(List items, bool skipEvent = false)
+ {
+ List.Clear();
+ IdMap.Clear();
+
+ List.AddRange(items);
+ IdMap = List.ToDictionary(x => x.Id);
+
+ Sort(false);
+
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(ModelListChangeEvent.Set);
+ }
+
+ public void Sort(bool skipEvent = false)
+ {
+ List.Sort(ISortable.Compare);
+
+ if (!skipEvent && Changed is not null)
+ Changed.Invoke(ModelListChangeEvent.Reordered);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ModelObserver.cs b/Valour/Sdk/ModelLogic/ModelObserver.cs
new file mode 100644
index 000000000..e653368b6
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelObserver.cs
@@ -0,0 +1,32 @@
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.ModelLogic;
+
+///
+/// The ModelObserver class allows global events to be hooked for entire item types
+///
+public static class ModelObserver where T : ClientModel
+{
+ ///
+ /// Run when any of this item type is updated
+ ///
+ public static HybridEvent> AnyUpdated;
+
+ ///
+ /// Run when any of this item type is deleted
+ ///
+ public static HybridEvent AnyDeleted;
+
+ public static void InvokeAnyUpdated(ModelUpdateEvent eventData)
+ {
+ if (AnyUpdated is not null)
+ AnyUpdated.Invoke(eventData);
+ }
+
+ public static void InvokeAnyDeleted(T deleted)
+ {
+ if (AnyDeleted is not null)
+ AnyDeleted.Invoke(deleted);
+ }
+}
+
diff --git a/Valour/Sdk/ModelLogic/ModelQueryEngine.cs b/Valour/Sdk/ModelLogic/ModelQueryEngine.cs
new file mode 100644
index 000000000..303f2d7d4
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelQueryEngine.cs
@@ -0,0 +1,190 @@
+using Valour.Sdk.Nodes;
+
+namespace Valour.Sdk.ModelLogic;
+
+///
+/// The model query engine allows for efficient querying of models
+/// with automatic global caching and a local sliding cache.
+///
+public class ModelQueryEngine
+ where TModel : ClientModel
+{
+ private readonly string _route;
+ private readonly Node _node;
+ private readonly int _cacheSize;
+ private int? _totalCount; // Updated when fetching missing data, null if unknown
+
+ // Fixed-size array cache
+ private readonly TModel[] _slidingCache;
+ private int _cacheStartIndex; // The index in the dataset corresponding to _cache[0]
+
+ public ModelQueryEngine(Node node, string route, int cacheSize = 200)
+ {
+ _route = route;
+ _node = node;
+ _cacheSize = cacheSize;
+ _slidingCache = new TModel[_cacheSize];
+ _cacheStartIndex = 0;
+ }
+
+ public async Task> GetItemsAsync(int skip, int take)
+ {
+ var items = new List(take);
+
+ // Check if requested range is within the current cache window
+ if (IsWithinCache(skip, take))
+ {
+ // Retrieve items from cache
+ var offset = skip - _cacheStartIndex;
+ for (var i = 0; i < take; i++)
+ {
+ items.Add(_slidingCache[offset + i]);
+ }
+ }
+ else
+ {
+ // Adjust the cache window to include the requested range
+ await AdjustCacheWindow(skip, take);
+
+ // After adjusting, retrieve items from cache
+ var offset = skip - _cacheStartIndex;
+ for (var i = 0; i < take; i++)
+ {
+ items.Add(_slidingCache[offset + i]);
+ }
+ }
+
+ return new ModelQueryResponse
+ {
+ Items = items,
+ TotalCount = _totalCount ?? items.Count
+ };
+ }
+
+ private bool IsWithinCache(int skip, int take)
+ {
+ return skip >= _cacheStartIndex && (skip + take) <= (_cacheStartIndex + _cacheSize);
+ }
+
+ private async Task AdjustCacheWindow(int skip, int take)
+ {
+ int newStartIndex;
+ if (take >= _cacheSize)
+ {
+ // The requested range is larger than or equal to the cache size
+ newStartIndex = skip;
+ }
+ else if (skip < _cacheStartIndex)
+ {
+ // Scrolling backward
+ newStartIndex = Math.Max(skip - (_cacheSize - take), 0);
+ }
+ else
+ {
+ // Scrolling forward
+ newStartIndex = skip;
+ }
+
+ // Calculate how much to shift existing data
+ int shift = _cacheStartIndex - newStartIndex;
+ if (shift != 0)
+ {
+ ShiftCache(shift);
+ }
+
+ _cacheStartIndex = newStartIndex;
+
+ // Fetch missing data
+ await FetchMissingData();
+ }
+
+ private void ShiftCache(int shift)
+ {
+ if (shift > 0)
+ {
+ // Scrolling backward: Shift right
+ for (var i = _cacheSize - 1; i >= shift; i--)
+ {
+ _slidingCache[i] = _slidingCache[i - shift];
+ }
+
+ // Clear the vacated positions
+ for (var i = 0; i < shift; i++)
+ {
+ _slidingCache[i] = default;
+ }
+ }
+ else if (shift < 0)
+ {
+ // Scrolling forward: Shift left
+ shift = -shift;
+ for (var i = 0; i < _cacheSize - shift; i++)
+ {
+ _slidingCache[i] = _slidingCache[i + shift];
+ }
+
+ // Clear the vacated positions
+ for (var i = _cacheSize - shift; i < _cacheSize; i++)
+ {
+ _slidingCache[i] = default;
+ }
+ }
+ }
+
+ // Fetches missing data and places it into the cache
+ // Returns false if there was an error
+ private async Task FetchMissingData()
+ {
+ var min = int.MaxValue;
+ var max = -1; // -1 can't happen naturally, so we can use it to see if empty
+
+ for (var i = 0; i < _cacheSize; i++)
+ {
+ if (_slidingCache[i] == null)
+ {
+ var index = _cacheStartIndex + i;
+ if (index < min)
+ min = index;
+
+ if (index > max)
+ max = index;
+ }
+ }
+
+ // Nothing to fetch
+ if (max == -1)
+ return true;
+
+
+ var skip = min;
+ var take = max - skip + 1;
+
+ var result = await _node.GetJsonAsync>($"{_route}?skip={skip}&take={take}");
+
+ if (!result.Success || result.Data.Items is null)
+ {
+ // There will probably be a gap in the cache, but we can't do anything about it
+ // We'll return false so it can be handled.
+ return false;
+ }
+
+ // Update total count
+ _totalCount = result.Data.TotalCount;
+
+ // Sync to client cache and update references in list
+ result.Data.Sync();
+
+ // Place fetched items into local sliding cache
+ for (var i = 0; i < result.Data.Items.Count; i++)
+ {
+ var index = skip + i;
+ var cachePosition = index - _cacheStartIndex;
+ if (cachePosition >= 0 && cachePosition < _cacheSize)
+ {
+ _slidingCache[cachePosition] = result.Data.Items[i];
+ }
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ModelQueryResponse.cs b/Valour/Sdk/ModelLogic/ModelQueryResponse.cs
new file mode 100644
index 000000000..f33abc234
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelQueryResponse.cs
@@ -0,0 +1,15 @@
+using Valour.Shared.Models;
+
+namespace Valour.Sdk.ModelLogic;
+
+public class ModelQueryResponse : QueryResponse
+ where T : ClientModel
+{
+ public void Sync()
+ {
+ for (int i = 0; i < Items.Count; i++)
+ {
+ Items[i] = Items[i].Node.Client.Cache.Sync(Items[i]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ModelUpdateEvent.cs b/Valour/Sdk/ModelLogic/ModelUpdateEvent.cs
new file mode 100644
index 000000000..bd114d98e
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelUpdateEvent.cs
@@ -0,0 +1,67 @@
+namespace Valour.Sdk.ModelLogic;
+
+public struct PositionChange
+{
+ public uint OldPosition { get; set; }
+ public uint NewPosition { get; set; }
+}
+
+public class ModelUpdateEvent : IDisposable
+ where TModel : ClientModel
+{
+ private bool _disposed = false;
+
+ ///
+ /// The new or updated item
+ ///
+ public TModel Model { get; set; }
+
+ ///
+ /// The fields that changed on the item
+ ///
+ public HashSet PropsChanged { get; set; }
+
+ ///
+ /// If not null, the position change of the item
+ ///
+ public PositionChange PositionChange { get; set; }
+
+ ///
+ /// True if the item is new to the client
+ ///
+ public bool NewToClient { get; set; }
+
+ ///
+ /// Additional data for the event
+ ///
+ public int? Flags { get; set; }
+
+ // Cleanup: Return PropsChanged to pool
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ return;
+
+ if (disposing)
+ {
+ if (PropsChanged != null)
+ {
+ ModelUpdater.ReturnPropsChanged(PropsChanged);
+ PropsChanged = null;
+ }
+ }
+
+ _disposed = true;
+ }
+
+ ~ModelUpdateEvent()
+ {
+ Dispose(false);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/ModelLogic/ModelUpdater.cs b/Valour/Sdk/ModelLogic/ModelUpdater.cs
new file mode 100644
index 000000000..b56a7e1b2
--- /dev/null
+++ b/Valour/Sdk/ModelLogic/ModelUpdater.cs
@@ -0,0 +1,158 @@
+using System.Reflection;
+using Microsoft.Extensions.ObjectPool;
+using Valour.Shared.Extensions;
+using Valour.Shared.Models;
+
+namespace Valour.Sdk.ModelLogic;
+
+///
+/// Responsible for pushing model updates across the client
+///
+public static class ModelUpdater
+{
+
+ // Cache for type properties
+ // The properties of models are cached to avoid reflection overhead
+ // It's not like the properties will change during runtime
+ private static readonly Dictionary ModelPropertyCache = new ();
+
+ // Same for fields
+ private static readonly Dictionary ModelFieldCache = new ();
+
+ // Pool for PropsChanged hashsets
+ private static readonly ObjectPool> HashSetPool =
+ new DefaultObjectPoolProvider().Create(new HashSetPooledObjectPolicy());
+
+ static ModelUpdater()
+ {
+ // Cache all properties of all models
+ foreach (var modelType in typeof(ClientModel).Assembly.GetTypes())
+ {
+ if (modelType.IsSubclassOf(typeof(ClientModel)))
+ {
+ ModelPropertyCache[modelType] = modelType.GetProperties();
+ ModelFieldCache[modelType] = modelType.GetFields();
+ }
+ }
+ }
+
+ public static void ReturnPropsChanged(HashSet propsChanged)
+ {
+ HashSetPool.Return(propsChanged);
+ }
+
+ public static void AddPositionChange(ISortableModel oldModel, ISortableModel newModel, ModelUpdateEvent eventData)
+ where TModel : ClientModel
+ {
+ if (oldModel.GetSortPosition() != newModel.GetSortPosition())
+ {
+ eventData.PositionChange = new PositionChange()
+ {
+ OldPosition = oldModel.GetSortPosition(),
+ NewPosition = newModel.GetSortPosition()
+ };
+ }
+ }
+
+ public static void AddPositionChange(TModel oldModel, TModel newModel, ModelUpdateEvent eventData)
+ where TModel : ClientModel
+ {
+ return;
+ }
+
+ ///
+ /// Updates a model's properties and returns the global instance
+ ///
+ public static TModel UpdateItem(TModel updated, TModel cached, int flags, bool skipEvent = false)
+ where TModel : ClientModel
+ {
+ // Create object for event data
+ var eventData = new ModelUpdateEvent()
+ {
+ Flags = flags,
+ PropsChanged = HashSetPool.Get(),
+ Model = cached,
+ NewToClient = cached is null
+ };
+
+ if (!eventData.NewToClient)
+ {
+ // Find changed properties
+ var pInfo = ModelPropertyCache[typeof(TModel)];
+
+ foreach (var prop in pInfo)
+ {
+ if (prop.GetValue(cached) != prop.GetValue(updated))
+ eventData.PropsChanged.Add(prop.Name);
+ }
+
+ // Check position change
+ AddPositionChange(cached, updated, eventData);
+
+ // Update local copy
+ // This uses the cached property info to avoid expensive reflection
+ updated.CopyAllTo(cached, pInfo, null);
+ }
+
+ if (!skipEvent)
+ {
+ // Update
+ if (cached is not null)
+ {
+ eventData.Model = cached;
+ // Fire off local event on item
+ cached.InvokeUpdatedEvent(eventData);
+ }
+ // New
+ else
+ {
+ eventData.Model = updated;
+
+ // Add to cache
+ updated.AddToCacheOrReturnExisting();
+ // Fire off local event on item
+ updated.InvokeUpdatedEvent(eventData);
+ }
+
+ // Fire off global events
+ ModelObserver.InvokeAnyUpdated(eventData);
+
+ // printing to console is SLOW, only turn on for debugging reasons
+ //Console.WriteLine("Invoked update events for " + updated.Id);
+ }
+
+ return cached ?? updated;
+ }
+
+ ///
+ /// Updates an item's properties
+ ///
+ public static void DeleteItem(TModel model)
+ where TModel : ClientModel
+ {
+ var cached = model.TakeAndRemoveFromCache();
+ if (cached is null)
+ {
+ // Invoke static "any" delete
+ model.InvokeDeletedEvent();
+ ModelObserver.InvokeAnyDeleted(model);
+ }
+ else
+ {
+ // Invoke static "any" delete
+ cached.InvokeDeletedEvent();
+ ModelObserver.InvokeAnyDeleted(cached);
+ }
+ }
+}
+
+public class HashSetPooledObjectPolicy : PooledObjectPolicy>
+{
+ public override HashSet Create() => new HashSet();
+
+ public override bool Return(HashSet obj)
+ {
+ obj.Clear();
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Models/ConnectionLockResult.cs b/Valour/Sdk/Models/ConnectionLockResult.cs
new file mode 100644
index 000000000..26cad8554
--- /dev/null
+++ b/Valour/Sdk/Models/ConnectionLockResult.cs
@@ -0,0 +1,8 @@
+namespace Valour.Sdk.Models;
+
+public enum ConnectionLockResult
+{
+ NotFound,
+ Locked,
+ Unlocked,
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Models/InitialPlanetData.cs b/Valour/Sdk/Models/InitialPlanetData.cs
new file mode 100644
index 000000000..835e28ae3
--- /dev/null
+++ b/Valour/Sdk/Models/InitialPlanetData.cs
@@ -0,0 +1,34 @@
+namespace Valour.Sdk.Models;
+
+///
+/// Rather than using multiple API calls to get the initial data for a planet,
+/// we can use this class to store all the data we need in one go.
+///
+public class InitialPlanetData
+{
+ ///
+ /// The planet this data was requested for
+ ///
+ public long PlanetId { get; set; }
+
+ ///
+ /// The roles within the planet
+ ///
+ public List Roles { get; set; }
+
+ ///
+ /// The channels within the planet that the user has access to
+ ///
+ public List Channels { get; set; }
+
+ ///
+ /// Initial member data. Will include most recently active members, but may not
+ /// include all members
+ ///
+ public List MemberData { get; set; }
+
+ ///
+ /// The
+ ///
+ public List Permissions { get; set; }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/AuthService.cs b/Valour/Sdk/Services/AuthService.cs
new file mode 100644
index 000000000..6bfbfdf7c
--- /dev/null
+++ b/Valour/Sdk/Services/AuthService.cs
@@ -0,0 +1,135 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Valour.Sdk.Client;
+using Valour.Shared;
+using Valour.Shared.Models;
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.Services;
+
+///
+/// Handles tokens and authentication
+///
+public class AuthService : ServiceBase
+{
+ ///
+ /// Run when the user logs in
+ ///
+ public HybridEvent LoggedIn;
+
+ ///
+ /// The token for this client instance
+ ///
+ public string Token => _token;
+
+ ///
+ /// The internal token for this client
+ ///
+ private string _token;
+
+ private static readonly LogOptions LogOptions = new(
+ "AuthService",
+ "#0083ab",
+ "#ab0055",
+ "#ab8900"
+ );
+
+ private readonly ValourClient _client;
+
+ public AuthService(ValourClient client)
+ {
+ _client = client;
+ SetupLogging(client.Logger, LogOptions);
+ }
+
+ ///
+ /// Gets the Token for the client
+ ///
+ public async Task> FetchToken(string email, string password)
+ {
+ TokenRequest request = new()
+ {
+ Email = email,
+ Password = password
+ };
+
+ var httpContent = JsonContent.Create(request);
+ var response = await _client.Http.PostAsync($"api/users/token", httpContent);
+
+
+ if (response.IsSuccessStatusCode)
+ {
+ var token = await response.Content.ReadFromJsonAsync();
+ _token = token.Id;
+
+ return TaskResult.FromData(_token);
+ }
+
+ return new TaskResult()
+ {
+ Success = false,
+ Message = await response.Content.ReadAsStringAsync(),
+ Code = (int) response.StatusCode
+ };
+ }
+
+ public void SetToken(string token)
+ {
+ _token = token;
+ }
+
+ public async Task LoginAsync(string email, string password)
+ {
+ var tokenResult = await FetchToken(email, password);
+ if (!tokenResult.Success)
+ return tokenResult.WithoutData();
+
+ return await LoginAsync();
+ }
+
+ public async Task LoginAsync()
+ {
+ // Ensure any existing auth headers are removed
+ if (_client.Http.DefaultRequestHeaders.Contains("authorization"))
+ {
+ _client.Http.DefaultRequestHeaders.Remove("authorization");
+ }
+
+ // Add auth header to main http client so we never have to do that again
+ _client.Http.DefaultRequestHeaders.Add("authorization", Token);
+
+ if (_client.PrimaryNode is null)
+ await _client.NodeService.SetupPrimaryNodeAsync();
+
+ var response = await _client.PrimaryNode!.GetJsonAsync($"api/users/me");
+
+ if (!response.Success)
+ return response.WithoutData();
+
+ _client.Me = _client.Cache.Sync(response.Data);
+
+ LoggedIn?.Invoke(_client.Me);
+
+ return new TaskResult(true, "Success");
+ }
+
+ public Task RegisterAsync(RegisterUserRequest request)
+ {
+ return _client.PrimaryNode.PostAsync("api/users/register", request);
+ }
+
+ ///
+ /// Sets the compliance data for the current user
+ ///
+ public async ValueTask SetComplianceDataAsync(DateTime birthDate, Locality locality)
+ {
+ var result = await _client.PrimaryNode.PostAsync($"api/users/me/compliance/{birthDate.ToString("s")}/{locality}", null);
+ var taskResult = new TaskResult()
+ {
+ Success = result.Success,
+ Message = result.Message
+ };
+
+ return taskResult;
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/BotService.cs b/Valour/Sdk/Services/BotService.cs
new file mode 100644
index 000000000..a0d4e2953
--- /dev/null
+++ b/Valour/Sdk/Services/BotService.cs
@@ -0,0 +1,77 @@
+using Valour.Sdk.Client;
+using Valour.Shared;
+
+namespace Valour.Sdk.Services;
+
+///
+/// Provides functionality for running Valour headless bots
+///
+public class BotService : ServiceBase
+{
+ private readonly ValourClient _client;
+
+ public BotService(ValourClient client)
+ {
+ _client = client;
+ SetupLogging(client.Logger);
+ }
+
+ ///
+ /// Logs in and prepares the bot's client for use
+ ///
+ public async Task InitializeBot(string email, string password)
+ {
+ // Ensure client HTTP client is set
+ _client.SetupHttpClient();
+
+ // Login to account
+ var userResult = await _client.AuthService.LoginAsync(email, password);
+ if (!userResult.Success)
+ return userResult;
+
+ // Now that we have our user, we can set up our primary node
+ await _client.NodeService.SetupPrimaryNodeAsync();
+
+ Console.WriteLine($"Initialized bot {_client.Me.Name} ({_client.Me.Id})");
+
+ await JoinAllChannelsAsync();
+
+ return new TaskResult(true, "Success");
+ }
+
+ ///
+ /// Should only be run during initialization of bots!
+ ///
+ public async Task JoinAllChannelsAsync()
+ {
+ // Get all joined planets
+ var planets = (await _client.PrimaryNode.GetJsonAsync>("api/users/me/planets")).Data;
+
+ var planetTasks = new List();
+
+ // Add to cache
+ foreach (var planet in planets)
+ {
+ var cached = _client.Cache.Sync(planet);
+ await planet.FetchChannelsAsync();
+
+ planetTasks.Add(Task.Run(async () =>
+ {
+ await _client.PlanetService.TryOpenPlanetConnection(planet, "bot-init");
+
+ var channelTasks = new List();
+
+ foreach (var channel in planet.Channels)
+ {
+ channelTasks.Add(_client.ChannelService.TryOpenPlanetChannelConnection(channel, "bot-init"));
+ }
+
+ await Task.WhenAll(channelTasks);
+ }));
+ }
+
+ await Task.WhenAll(planetTasks);
+
+ _client.PlanetService.SetJoinedPlanets(planets);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/CacheService.cs b/Valour/Sdk/Services/CacheService.cs
new file mode 100644
index 000000000..7b810d0b3
--- /dev/null
+++ b/Valour/Sdk/Services/CacheService.cs
@@ -0,0 +1,90 @@
+using Valour.Sdk.Client;
+using Valour.Sdk.ModelLogic;
+using Valour.Sdk.Models.Economy;
+using Valour.Sdk.Models.Themes;
+
+namespace Valour.Sdk.Services;
+
+///
+/// The CacheService provides caching for the Valour client.
+///
+public class CacheService
+{
+ /////////////
+ // Lookups //
+ /////////////
+
+ public readonly Dictionary DmChannelKeyToId = new();
+ public readonly Dictionary PermNodeKeyToId = new();
+ public readonly Dictionary MemberKeyToId = new();
+
+ //////////////////
+ // Model Caches //
+ //////////////////
+
+ public readonly ModelCache Users = new();
+ public readonly ModelCache EcoAccounts = new();
+ public readonly ModelCache Currencies = new();
+ public readonly ModelCache Channels = new();
+ public readonly ModelCache UserFriends = new();
+ public readonly ModelCache UserProfiles = new();
+ public readonly ModelCache ChannelMembers = new();
+ public readonly ModelCache Themes = new();
+ public readonly ModelCache OauthApps = new();
+
+ public readonly ModelCache Messages = new();
+
+ public readonly ModelCache Planets = new();
+ public readonly ModelCache PlanetMembers = new();
+ public readonly ModelCache PlanetRoles = new();
+ public readonly ModelCache PlanetBans = new();
+ public readonly ModelCache PermissionsNodes = new();
+ public readonly ModelCache PlanetInvites = new();
+
+ private readonly ValourClient _client;
+
+ public CacheService(ValourClient client)
+ {
+ _client = client;
+ }
+
+ ///
+ /// Pushes this version of this model to cache and optionally
+ /// fires off event for the update. Flags can be added for additional data.
+ /// Returns the global cached instance of the model.
+ ///
+ public TModel Sync(TModel model, bool skipEvent = false, int flags = 0)
+ where TModel : ClientModel
+ {
+ if (model is null)
+ return null;
+
+ // Let the model know what client it is associated with
+ model.SetClient(_client);
+
+ model.SyncSubModels(skipEvent, flags);
+
+ // Add to cache or get the existing cached instance
+ var existing = model.AddToCacheOrReturnExisting();
+
+ // Update the existing model with the new data, or broadcast the new item
+ return ModelUpdater.UpdateItem(model, existing, flags, skipEvent); // Update if already exists
+ }
+
+ ///
+ /// Removes the model from cache and optionally fires off event for the deletion.
+ ///
+ public void Delete(TModel model, bool skipEvent = false)
+ where TModel : ClientModel
+ {
+ if (model is null)
+ return;
+
+ // Remove from cache
+ model.TakeAndRemoveFromCache();
+
+ // Broadcast the deletion
+ if (!skipEvent)
+ ModelUpdater.DeleteItem(model);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/ChannelService.cs b/Valour/Sdk/Services/ChannelService.cs
new file mode 100644
index 000000000..dc52f75fb
--- /dev/null
+++ b/Valour/Sdk/Services/ChannelService.cs
@@ -0,0 +1,376 @@
+using Microsoft.AspNetCore.SignalR.Client;
+using Valour.Sdk.Client;
+using Valour.Sdk.Nodes;
+using Valour.Sdk.Requests;
+using Valour.Shared;
+using Valour.Shared.Channels;
+using Valour.Shared.Models;
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.Services;
+
+public class ChannelService : ServiceBase
+{
+ ///
+ /// Run when SignalR opens a channel
+ ///
+ public HybridEvent ChannelConnected;
+
+ ///
+ /// Run when SignalR closes a channel
+ ///
+ public HybridEvent ChannelDisconnected;
+
+ ///
+ /// Run when a category is reordered
+ ///
+ public HybridEvent CategoryReordered;
+
+ ///
+ /// Currently opened channels
+ ///
+ public readonly IReadOnlyList ConnectedPlanetChannels;
+ private readonly List _connectedPlanetChannels = new();
+
+ ///
+ /// Connected channels lookup
+ ///
+ public readonly IReadOnlyDictionary ConnectedPlanetChannelsLookup;
+ private readonly Dictionary _connectedPlanetChannelsLookup = new();
+
+ ///
+ /// A set of locks used to prevent channel connections from closing automatically
+ ///
+ public readonly IReadOnlyDictionary ChannelLocks;
+ private readonly Dictionary _channelLocks = new();
+
+ ///
+ /// The direct chat channels (dms) of this user
+ ///
+ public readonly IReadOnlyList DirectChatChannels;
+ private readonly List _directChatChannels = new();
+
+ ///
+ /// Lookup for direct chat channels
+ ///
+ public readonly IReadOnlyDictionary DirectChatChannelsLookup;
+ private readonly Dictionary _directChatChannelsLookup = new();
+
+ private readonly LogOptions _logOptions = new(
+ "ChannelService",
+ "#3381a3",
+ "#a3333e",
+ "#a39433"
+ );
+
+ private readonly ValourClient _client;
+ private readonly CacheService _cache;
+
+ public ChannelService(ValourClient client)
+ {
+ _client = client;
+ _cache = client.Cache;
+
+ ConnectedPlanetChannels = _connectedPlanetChannels;
+ ChannelLocks = _channelLocks;
+ ConnectedPlanetChannelsLookup = _connectedPlanetChannelsLookup;
+
+ DirectChatChannels = _directChatChannels;
+ DirectChatChannelsLookup = _directChatChannelsLookup;
+
+ SetupLogging(client.Logger, _logOptions);
+
+ // Reconnect channels on node reconnect
+ client.NodeService.NodeReconnected += OnNodeReconnect;
+ client.NodeService.NodeAdded += HookHubEvents;
+ }
+
+ ///
+ /// Given a channel id, returns the channel. Planet channels should be fetched via the Planet.
+ ///
+ public async ValueTask FetchChannelAsync(long id, bool skipCache = false)
+ {
+ if (!skipCache && _cache.Channels.TryGet(id, out var cached))
+ return cached;
+
+ var channel = (await _client.PrimaryNode.GetJsonAsync(ISharedChannel.GetIdRoute(id))).Data;
+
+ return _cache.Sync(channel);
+ }
+
+ public async ValueTask FetchChannelAsync(long id, long planetId, bool skipCache = false)
+ {
+ var planet = await _client.PlanetService.FetchPlanetAsync(planetId, skipCache);
+ return await FetchChannelAsync(id, planet, skipCache);
+ }
+
+ public async ValueTask FetchChannelAsync(long id, Planet planet, bool skipCache = false)
+ {
+ if (!skipCache && _cache.Channels.TryGet(id, out var cached))
+ return cached;
+
+ var channel = (await planet.Node.GetJsonAsync(ISharedChannel.GetIdRoute(id))).Data;
+
+ return _cache.Sync(channel);
+ }
+
+ public Task> CreatePlanetChannelAsync(Planet planet, CreateChannelRequest request)
+ {
+ request.Channel.PlanetId = planet.Id;
+ return planet.Node.PostAsyncWithResponse(request.Channel.BaseRoute, request);
+ }
+
+ ///
+ /// Given a user id, returns the direct channel between them and the requester.
+ /// If create is true, this will create the channel if it is not found.
+ ///
+ public async ValueTask FetchDmChannelAsync(long otherUserId, bool create = false, bool skipCache = false)
+ {
+ var key = new DirectChannelKey(_client.Me.Id, otherUserId);
+
+ if (!skipCache &&
+ _cache.DmChannelKeyToId.TryGetValue(key, out var id) &&
+ _cache.Channels.TryGet(id, out var cached))
+ return cached;
+
+ var dmChannel = (await _client.PrimaryNode.GetJsonAsync(
+ $"{ISharedChannel.BaseRoute}/direct/{otherUserId}?create={create}")).Data;
+
+ return _cache.Sync(dmChannel);
+ }
+
+ public async Task LoadDmChannelsAsync()
+ {
+ var response = await _client.PrimaryNode.GetJsonAsync>("api/channels/direct/self");
+ if (!response.Success)
+ {
+ LogError("Failed to load direct chat channels!");
+ LogError(response.Message);
+
+ return;
+ }
+
+ // Clear existing
+ _directChatChannels.Clear();
+ _directChatChannelsLookup.Clear();
+
+ foreach (var channel in response.Data)
+ {
+ // Custom cache insert behavior
+ if (channel.Members is not null && channel.Members.Count > 0)
+ {
+ var id0 = channel.Members[0].Id;
+
+ // Self channel
+ if (channel.Members.Count == 1)
+ {
+ var key = new DirectChannelKey(id0, id0);
+ _cache.DmChannelKeyToId.Add(key, channel.Id);
+ }
+ // Other channel
+ else if (channel.Members.Count == 2)
+ {
+ var id1 = channel.Members[1].Id;
+ var key = new DirectChannelKey(id0, id1);
+ _cache.DmChannelKeyToId.Add(key, channel.Id);
+ }
+ }
+
+ var cached = _cache.Sync(channel);
+ _directChatChannels.Add(cached);
+ _directChatChannelsLookup.Add(cached.Id, cached);
+ }
+
+ Log($"Loaded {DirectChatChannels.Count} direct chat channels...");
+ }
+
+ ///
+ /// Opens a SignalR connection to a channel if it does not already have one,
+ /// and stores a key to prevent it from being closed
+ ///
+ public async Task TryOpenPlanetChannelConnection(Channel channel, string key)
+ {
+ if (channel.ChannelType != ChannelTypeEnum.PlanetChat)
+ return TaskResult.FromFailure("Channel is not a planet chat channel");
+
+ if (_channelLocks.ContainsKey(key))
+ {
+ _channelLocks[key] = channel.Id;
+ }
+ else
+ {
+ // Add lock
+ AddChannelLock(key, channel.Id);
+ }
+
+ // Already opened
+ if (_connectedPlanetChannels.Contains(channel))
+ return TaskResult.SuccessResult;
+
+ var planet = channel.Planet;
+
+ // Ensure planet is opened
+ var planetResult = await _client.PlanetService.TryOpenPlanetConnection(planet, key);
+ if (!planetResult.Success)
+ return planetResult;
+
+ // Join channel SignalR group
+ var result = await channel.Node.HubConnection.InvokeAsync("JoinChannel", channel.Id);
+
+ if (!result.Success)
+ {
+ LogError(result.Message);
+ return result;
+ }
+
+ Log(result.Message);
+
+ // Add to open set
+ _connectedPlanetChannels.Add(channel);
+ _connectedPlanetChannelsLookup[channel.Id] = channel;
+
+ Log($"Joined SignalR group for channel {channel.Name} ({channel.Id})");
+
+ ChannelConnected?.Invoke(channel);
+
+ return TaskResult.SuccessResult;
+ }
+
+ ///
+ /// Closes a SignalR connection to a channel
+ ///
+ public async Task TryClosePlanetChannelConnection(Channel channel, string key, bool force = false)
+ {
+ if (channel.ChannelType != ChannelTypeEnum.PlanetChat)
+ return TaskResult.FromFailure("Channel is not a planet chat channel");
+
+ if (!force)
+ {
+ // Remove key from locks
+ var lockResult = RemoveChannelLock(key);
+
+ // If there are still any locks, don't close
+ if (lockResult == ConnectionLockResult.Locked)
+ {
+ return TaskResult.FromFailure("Channel is locked by other keys.");
+ }
+ // If for some reason our key isn't actually there
+ // (shouldn't happen, but just in case)
+ else if (lockResult == ConnectionLockResult.NotFound)
+ {
+ if (_channelLocks.Values.Any(x => x == channel.Id))
+ {
+ return TaskResult.FromFailure("Channel is locked by other keys.");
+ }
+ }
+ }
+
+ // Not opened
+ if (!_connectedPlanetChannels.Contains(channel))
+ return TaskResult.FromFailure("Channel is not open.");
+
+ // Leaves channel SignalR group
+ await channel.Node.HubConnection.SendAsync("LeaveChannel", channel.Id);
+
+ // Remove from open set
+ _connectedPlanetChannels.Remove(channel);
+ _connectedPlanetChannelsLookup.Remove(channel.Id);
+
+ Log($"Left SignalR group for channel {channel.Name} ({channel.Id})");
+
+ ChannelDisconnected?.Invoke(channel);
+
+ // Close planet connection if no other channels are open
+ await _client.PlanetService.TryClosePlanetConnection(channel.Planet, key);
+
+ return TaskResult.SuccessResult;
+ }
+
+ ///
+ /// Prevents a channel from closing connections automatically.
+ /// Key is used to allow multiple locks per channel.
+ ///
+ private void AddChannelLock(string key, long planetId)
+ {
+ _channelLocks[key] = planetId;
+ }
+
+ ///
+ /// Removes the lock for a channel.
+ /// Returns the result of if there are any locks left for the channel.
+ ///
+ private ConnectionLockResult RemoveChannelLock(string key)
+ {
+ if (_channelLocks.TryGetValue(key, out var channelId))
+ {
+ Log($"Channel lock {key} removed.");
+ _channelLocks.Remove(key);
+ return _channelLocks.Any(x => x.Value == channelId)
+ ? ConnectionLockResult.Locked
+ : ConnectionLockResult.Unlocked;
+ }
+
+ return ConnectionLockResult.NotFound;
+ }
+
+ ///
+ /// Returns if the channel is open
+ ///
+ public bool IsChannelConnected(long channelId) =>
+ _connectedPlanetChannelsLookup.ContainsKey(channelId);
+
+
+ // TODO: change
+ public void OnCategoryOrderUpdate(CategoryOrderEvent eventData)
+ {
+ // Update channels in cache
+ uint pos = 0;
+ foreach (var data in eventData.Order)
+ {
+ if (_client.Cache.Channels.TryGet(data.Id, out var channel))
+ {
+ // The parent can be changed in this event
+ channel.ParentId = eventData.CategoryId;
+
+ // Position can be changed in this event
+ channel.RawPosition = pos;
+ }
+
+ pos++;
+ }
+
+ CategoryReordered?.Invoke(eventData);
+ }
+
+ public void OnWatchingUpdate(ChannelWatchingUpdate update)
+ {
+ if (!_cache.Channels.TryGet(update.ChannelId, out var channel))
+ return;
+
+ channel.WatchingUpdated?.Invoke(update);
+ }
+
+ public void OnTypingUpdate(ChannelTypingUpdate update)
+ {
+ if (!_cache.Channels.TryGet(update.ChannelId, out var channel))
+ return;
+
+ channel.TypingUpdated?.Invoke(update);
+ }
+
+ private void HookHubEvents(Node node)
+ {
+ node.HubConnection.On("CategoryOrder-Update", OnCategoryOrderUpdate);
+ node.HubConnection.On("Channel-Watching-Update", OnWatchingUpdate);
+ node.HubConnection.On("Channel-CurrentlyTyping-Update", OnTypingUpdate);
+ }
+
+ private async Task OnNodeReconnect(Node node)
+ {
+ foreach (var channel in _connectedPlanetChannels.Where(x => x.Node?.Name == node.Name))
+ {
+ await node.HubConnection.SendAsync("JoinChannel", channel.Id);
+ Log($"Rejoined SignalR group for channel {channel.Id}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/ChannelStateService.cs b/Valour/Sdk/Services/ChannelStateService.cs
new file mode 100644
index 000000000..c061f9ffb
--- /dev/null
+++ b/Valour/Sdk/Services/ChannelStateService.cs
@@ -0,0 +1,154 @@
+using Microsoft.AspNetCore.SignalR.Client;
+using Valour.Sdk.Client;
+using Valour.Sdk.Nodes;
+using Valour.Shared.Models;
+using Valour.Shared.Utilities;
+
+namespace Valour.Sdk.Services;
+
+public class ChannelStateService : ServiceBase
+{
+ ///
+ /// Run when a UserChannelState is updated
+ ///
+ public HybridEvent UserChannelStateUpdated;
+
+ ///
+ /// Run when a channel state updates
+ ///
+ public HybridEvent ChannelStateUpdated;
+
+ ///
+ /// The state of channels this user has access to
+ ///
+ public readonly IReadOnlyDictionary ChannelsLastViewedState;
+ private readonly Dictionary _channelsLastViewedState = new();
+
+ ///
+ /// The last update times of channels this user has access to
+ ///
+ public readonly IReadOnlyDictionary CurrentChannelStates;
+ private readonly Dictionary _currentChannelStates = new();
+
+ private static readonly LogOptions LogOptions = new(
+ "ChannelStateService",
+ "#5c33a3",
+ "#a33340",
+ "#a39433"
+ );
+
+ private readonly ValourClient _client;
+
+ public ChannelStateService(ValourClient client)
+ {
+ _client = client;
+
+ ChannelsLastViewedState = _channelsLastViewedState;
+ CurrentChannelStates = _currentChannelStates;
+
+ SetupLogging(client.Logger, LogOptions);
+
+ _client.NodeService.NodeAdded += HookHubEvents;
+ }
+
+ public async Task LoadChannelStatesAsync()
+ {
+ var response = await _client.PrimaryNode.GetJsonAsync>($"api/users/me/statedata");
+ if (!response.Success)
+ {
+ Console.WriteLine("** Failed to load channel states **");
+ Console.WriteLine(response.Message);
+
+ return;
+ }
+
+ foreach (var state in response.Data)
+ {
+ if (state.ChannelState is not null)
+ _currentChannelStates[state.ChannelId] = state.ChannelState;
+
+ if (state.LastViewedTime is not null)
+ _channelsLastViewedState[state.ChannelId] = state.LastViewedTime;
+ }
+
+ Console.WriteLine("Loaded " + ChannelsLastViewedState.Count + " channel states.");
+ // Console.WriteLine(JsonSerializer.Serialize(response.Data));
+ }
+
+ public void SetChannelLastViewedState(long channelId, DateTime lastViewed)
+ {
+ _channelsLastViewedState[channelId] = lastViewed;
+ }
+
+ public bool GetPlanetUnreadState(long planetId)
+ {
+ var channelStates =
+ CurrentChannelStates.Where(x => x.Value.PlanetId == planetId);
+
+ foreach (var state in channelStates)
+ {
+ if (GetChannelUnreadState(state.Key))
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool GetChannelUnreadState(long channelId)
+ {
+ // TODO: this will act weird with multiple tabs
+ if (_client.ChannelService.IsChannelConnected(channelId))
+ return false;
+
+ if (!_channelsLastViewedState.TryGetValue(channelId, out var lastRead))
+ {
+ return true;
+ }
+
+ if (!_currentChannelStates.TryGetValue(channelId, out var lastUpdate))
+ {
+ return false;
+ }
+
+ return lastRead < lastUpdate.LastUpdateTime;
+ }
+
+ public void OnChannelStateUpdated(ChannelStateUpdate update)
+ {
+ // Right now only planet chat channels have state updates
+ if (!_client.Cache.Channels.TryGet(update.ChannelId, out var channel))
+ {
+ return;
+ }
+
+ if (!_currentChannelStates.TryGetValue(channel.Id, out var state))
+ {
+ state = new ChannelState()
+ {
+ ChannelId = update.ChannelId,
+ PlanetId = update.PlanetId,
+ LastUpdateTime = update.Time
+ };
+
+ _currentChannelStates[channel.Id] = state;
+ }
+ else
+ {
+ _currentChannelStates[channel.Id].LastUpdateTime = update.Time;
+ }
+
+ ChannelStateUpdated?.Invoke(update);
+ }
+
+ public void OnUserChannelStateUpdated(UserChannelState channelState)
+ {
+ _channelsLastViewedState[channelState.ChannelId] = channelState.LastViewedTime;
+ UserChannelStateUpdated?.Invoke(channelState);
+ }
+
+ private void HookHubEvents(Node node)
+ {
+ node.HubConnection.On("Channel-State", OnChannelStateUpdated);
+ node.HubConnection.On("UserChannelState-Update", OnUserChannelStateUpdated);
+ }
+}
\ No newline at end of file
diff --git a/Valour/Sdk/Services/EcoService.cs b/Valour/Sdk/Services/EcoService.cs
new file mode 100644
index 000000000..efe99238a
--- /dev/null
+++ b/Valour/Sdk/Services/EcoService.cs
@@ -0,0 +1,206 @@
+using System.Web;
+using Valour.Sdk.Client;
+using Valour.Sdk.ModelLogic;
+using Valour.Sdk.Models.Economy;
+using Valour.Shared;
+using Valour.Shared.Models;
+using Valour.Shared.Models.Economy;
+
+namespace Valour.Sdk.Services;
+
+public class EcoService : ServiceBase
+{
+ private static readonly LogOptions LogOptions = new(
+ "EcoService",
+ "#0083ab",
+ "#ab0055",
+ "#ab8900"
+ );
+
+ private readonly ValourClient _client;
+ private readonly CacheService _cache;
+
+ public EcoService(ValourClient client)
+ {
+ _client = client;
+ _cache = client.Cache;
+ SetupLogging(client.Logger, LogOptions);
+ }
+
+ ///
+ /// Returns a list of global accounts that match the given name. Global accounts are the Valour Credit accounts.
+ ///
+ public async Task SearchGlobalAccountsAsync(string name)
+ {
+ var node = await _client.NodeService.GetNodeForPlanetAsync(ISharedPlanet.ValourCentralId);
+ return (await node.GetJsonAsync($"api/eco/accounts/byname/{HttpUtility.UrlEncode(name)}", true)).Data;
+ }
+
+ ///
+ /// Returns all eco accounts that the client user has access to.
+ ///
+ ///
+ public async Task>> FetchSelfEcoAccountsAsync()
+ {
+ return await _client.PrimaryNode.GetJsonAsync>("api/eco/accounts/self");
+ }
+
+ public async Task GetSelfGlobalAccountAsync()
+ {
+ var planet = await _client.PlanetService.FetchPlanetAsync(ISharedPlanet.ValourCentralId);
+ var account = (await planet.Node.GetJsonAsync($"api/eco/accounts/self/global")).Data;
+
+ return _client.Cache.Sync(account);
+ }
+
+ ///
+ /// Returns the eco account with the given id.
+ /// The planet will be fetched.
+ ///
+ public async ValueTask FetchEcoAccountAsync(long id, long planetId, bool skipCache = false)
+ {
+ var planet = await _client.PlanetService.FetchPlanetAsync(planetId, skipCache);
+ return await FetchEcoAccountAsync(id, planet, skipCache);
+ }
+
+ ///
+ /// Returns the eco account with the given id.
+ /// The planet must be provided.
+ ///
+ public async ValueTask FetchEcoAccountAsync(long id, Planet planet, bool skipCache = false)
+ {
+ if (!skipCache && _cache.EcoAccounts.TryGet(id, out var cached))
+ return cached;
+
+ var item = (await planet.Node.GetJsonAsync($"api/eco/accounts/{id}")).Data;
+
+ return _cache.Sync(item);
+ }
+
+ ///
+ /// Returns the transaction with the given id.
+ ///
+ public async ValueTask FetchTransactionAsync(string id)
+ {
+ var item = (await _client.PrimaryNode.GetJsonAsync($"api/eco/transactions/{id}")).Data;
+ return item;
+ }
+
+ ///
+ /// Sends the given transaction to be processed.
+ /// Will fetch the planet.
+ ///
+ public async ValueTask> SendTransactionAsync(Transaction trans)
+ {
+ var planet = await _client.PlanetService.FetchPlanetAsync(trans.PlanetId);
+ return await SendTransactionAsync(trans, planet);
+ }
+
+ ///
+ /// Sends the given transaction to be processed.
+ /// Planet must be provided.
+ ///
+ public async ValueTask> SendTransactionAsync(Transaction trans, Planet planet)
+ {
+ return await planet.Node.PostAsyncWithResponse("api/eco/transactions", trans);
+ }
+
+ ///
+ /// Returns the receipt for the given transaction id.
+ ///
+ public async ValueTask FetchReceiptAsync(string id)
+ {
+ var item = (await _client.PrimaryNode.GetJsonAsync($"api/eco/transactions/{id}/receipt")).Data;
+ return item;
+ }
+
+ ///
+ /// Returns an engine for querying shared accounts on the given planet.
+ ///
+ public ModelQueryEngine GetSharedAccountQueryEngine(Planet planet) =>
+ new ModelQueryEngine(planet.Node, $"api/eco/accounts/planet/{planet.Id}/planet");
+
+ ///
+ /// Returns an engine for querying user accounts on the given planet.
+ ///
+ public ModelQueryEngine GetUserAccountQueryEngine(Planet planet) =>
+ new ModelQueryEngine(planet.Node, $"api/eco/accounts/planet/{planet.Id}/member");
+
+ ///