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"); + + /// + /// Returns a paged reader for querying shared accounts on the given planet. + /// + public PagedModelReader GetSharedAccountPagedReader(Planet planet, int pageSize = 50) => + new PagedModelReader(planet.Node, $"api/eco/accounts/planet/{planet.Id}/planet", pageSize); + + /// + /// Returns the currency with the given id. + /// The planet will be fetched. + /// + public async ValueTask FetchCurrencyAsync(long id, long planetId, bool skipCache = false) + { + var planet = await _client.PlanetService.FetchPlanetAsync(planetId, skipCache); + return await FetchCurrencyAsync(id, planet, skipCache); + } + + /// + /// Returns the currency with the given id. + /// Planet must be provided. + /// + public async ValueTask FetchCurrencyAsync(long id, Planet planet, bool skipCache = false) + { + if (!skipCache && _cache.Currencies.TryGet(id, out var cached)) + return cached; + + var item = (await _client.PrimaryNode.GetJsonAsync($"api/eco/currencies/{id}")).Data; + + return _cache.Sync(item); + } + + public ValueTask FetchGlobalCurrencyAsync() => + FetchCurrencyAsync(ISharedCurrency.ValourCreditsId, ISharedPlanet.ValourCentralId); + + /// + /// Returns the currency for the given planet. + /// The planet will be fetched. + /// + public async ValueTask FetchCurrencyByPlanetAsync(long planetId, bool skipCache = false) + { + var planet = await _client.PlanetService.FetchPlanetAsync(planetId, skipCache); + return await FetchCurrencyByPlanetAsync(planet); + } + + /// + /// Returns the currency for the given planet. + /// Planet must be provided. + /// + public async ValueTask FetchCurrencyByPlanetAsync(Planet planet) + { + var item = (await planet.Node.GetJsonAsync($"api/eco/currencies/byPlanet/{planet.Id}", true)).Data; + + return _cache.Sync(item); + } + + /// + /// Returns all accounts the user can send to for a given planet id + /// + public async Task> SearchSendableAccountsAsync(long planetId, long accountId, string filter = "") + { + var node = await _client.NodeService.GetNodeForPlanetAsync(planetId); + + var request = new EcoPlanetAccountSearchRequest() + { + PlanetId = planetId, + AccountId = accountId, + Filter = filter + }; + + var response = (await node.PostAsyncWithResponse>($"api/eco/accounts/planet/canSend", request)).Data; + + for (int i = 0; i < response.Count; i++) + { + response[i].Account = _cache.Sync(response[i].Account); + } + + return response; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/FriendService.cs b/Valour/Sdk/Services/FriendService.cs new file mode 100644 index 000000000..967024889 --- /dev/null +++ b/Valour/Sdk/Services/FriendService.cs @@ -0,0 +1,280 @@ +using System.Web; +using Valour.Sdk.Client; +using Valour.Shared; +using Valour.Shared.Models; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Services; + +public class FriendService : ServiceBase +{ + /// + /// Run when there is a friend event + /// + public HybridEvent FriendsChanged; + + /// + /// The friends of this client + /// + public readonly List Friends = new(); + + /// + /// The fast lookup set for friends + /// + public readonly Dictionary FriendLookup = new(); + public readonly List FriendRequests = new(); + public readonly List FriendsRequested = new (); + + private static readonly LogOptions LogOptions = new ( + "FriendService", + "#036bfc", + "#fc0356", + "#fc8403" + ); + + private readonly ValourClient _client; + private readonly CacheService _cache; + public FriendService(ValourClient client) + { + _client = client; + _cache = client.Cache; + SetupLogging(client.Logger, LogOptions); + } + + /// + /// Fetches friend data from the server + /// + public async Task FetchesFriendsAsync() + { + var data = await _client.Me.FetchFriendDataAsync(); + if (data is null) + { + LogError("Error loading friends."); + return; + } + + FriendRequests.Clear(); + FriendsRequested.Clear(); + + foreach (var added in data.Added) + FriendRequests.Add(added); + + foreach (var addedBy in data.AddedBy) + FriendsRequested.Add(addedBy); + + Friends.Clear(); + FriendLookup.Clear(); + + foreach (var req in FriendRequests) + { + if (FriendsRequested.Any(x => x.Id == req.Id)) + { + Friends.Add(req); + FriendLookup.Add(req.Id, req); + } + } + + foreach (var friend in Friends) + { + FriendRequests.RemoveAll(x => x.Id == friend.Id); + FriendsRequested.RemoveAll(x => x.Id == friend.Id); + } + + Log($"Loaded {Friends.Count} friends."); + + FriendsChanged?.Invoke(new FriendEventData() + { + User = null, + Type = FriendEventType.FetchedAll + }); + } + + public void OnFriendEventReceived(FriendEventData eventData) + { + if (eventData.Type == FriendEventType.AddedMe) + { + // If we already had a friend request to them, + if (FriendsRequested.Any(x => x.Id == eventData.User.Id)) + { + // Add as a friend + Friends.Add(eventData.User); + } + // otherwise, + else + { + // add to friend requests + FriendRequests.Add(eventData.User); + } + } + else if (eventData.Type == FriendEventType.RemovedMe) + { + // If they were already a friend, + if (Friends.Any(x => x.Id == eventData.User.Id)) + { + // remove them + Friends.RemoveAll(x => x.Id == eventData.User.Id); + } + // otherwise, + else + { + // remove from friend requests + FriendRequests.RemoveAll(x => x.Id == eventData.User.Id); + } + } + + FriendsChanged?.Invoke(eventData); + } + + /// + /// Adds a friend + /// + public async Task> AddFriendAsync(string nameAndTag) + { + var result = await _client.PrimaryNode.PostAsyncWithResponse($"api/userfriends/add/{HttpUtility.UrlEncode(nameAndTag)}"); + + if (!result.Success) + return result; + + var addedUser = await _client.UserService.FetchUserAsync(result.Data.FriendId); + + // If we already had a friend request from them, + // add them to the friends list + var request = FriendRequests.FirstOrDefault(x => x.NameAndTag.Equals(nameAndTag, StringComparison.OrdinalIgnoreCase)); + if (request is not null) + { + FriendRequests.Remove(request); + Friends.Add(addedUser); + FriendLookup.Add(addedUser.Id, addedUser); + } + // Otherwise, add this request to our request list + else + { + FriendsRequested.Add(addedUser); + } + + var eventData = new FriendEventData + { + User = addedUser, + Type = FriendEventType.AddedThem + }; + + FriendsChanged?.Invoke(eventData); + + return result; + } + + /// + /// Declines a friend request + /// + public async Task DeclineFriendAsync(string nameAndTag) + { + var result = await _client.PrimaryNode.PostAsync($"api/userfriends/decline/{HttpUtility.UrlEncode(nameAndTag)}", null); + + if (!result.Success) + return result; + + var declined = FriendRequests.FirstOrDefault(x => x.NameAndTag.ToLower() == nameAndTag.ToLower()); + if (declined is not null) + FriendRequests.Remove(declined); + + var eventData = new FriendEventData + { + User = declined, + Type = FriendEventType.DeclinedThem + }; + + FriendsChanged?.Invoke(eventData); + + return result; + } + + /// + /// Removes a friend :( + /// + public async Task RemoveFriendAsync(string nameAndTag) + { + var result = await _client.PrimaryNode.PostAsync($"api/userfriends/remove/{HttpUtility.UrlEncode(nameAndTag)}", null); + + if (!result.Success) + return result; + + FriendsRequested.RemoveAll(x => x.NameAndTag.ToLower() == nameAndTag.ToLower()); + var friend = Friends.FirstOrDefault(x => x.NameAndTag.ToLower() == nameAndTag.ToLower()); + if (friend is not null) + { + Friends.Remove(friend); + FriendLookup.Remove(friend.Id); + + FriendRequests.Add(friend); + } + + var eventData = new FriendEventData() + { + User = friend, + Type = FriendEventType.RemovedThem + }; + + FriendsChanged?.Invoke(eventData); + + return result; + } + + /// + /// Cancels a friend request + /// + public async Task CancelFriendAsync(string nameAndTag) + { + var result = await _client.PrimaryNode.PostAsync($"api/userfriends/cancel/{HttpUtility.UrlEncode(nameAndTag)}", null); + + if (!result.Success) + return result; + + var canceled = FriendsRequested.FirstOrDefault(x => x.NameAndTag.ToLower() == nameAndTag.ToLower()); + if (canceled is not null) + FriendsRequested.Remove(canceled); + + var eventData = new FriendEventData + { + User = canceled, + Type = FriendEventType.CancelledThem + }; + + FriendsChanged?.Invoke(eventData); + + return result; + } + + public async Task> GetFriendsAsync(long userId) + { + var result = await _client.PrimaryNode.GetJsonAsync>($"{ISharedUser.GetIdRoute(userId)}/friends"); + + for (int i = 0; i < result.Data.Count; i++) + { + var user = result.Data[i]; + result.Data[i] = _cache.Sync(user); + } + + return result.Data; + } + + public async Task FetchFriendDataAsync(long userId) + { + var result = await _client.PrimaryNode.GetJsonAsync($"{ISharedUser.GetIdRoute(userId)}/frienddata"); + if (!result.Success) + return null; + + for (int i = 0; i < result.Data.Added.Count; i++) + { + var user = result.Data.Added[i]; + result.Data.Added[i] = _cache.Sync(user); + } + + for (int i = 0; i < result.Data.AddedBy.Count; i++) + { + var user = result.Data.AddedBy[i]; + result.Data.AddedBy[i] = _cache.Sync(user); + } + + return result.Data; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/LoggingService.cs b/Valour/Sdk/Services/LoggingService.cs new file mode 100644 index 000000000..8dc86d232 --- /dev/null +++ b/Valour/Sdk/Services/LoggingService.cs @@ -0,0 +1,54 @@ +namespace Valour.Sdk.Services; + +public class LoggingService +{ + private List> Loggers { get; set; } = new(); + private List> ColorLoggers { get; set; } = new(); + + public LoggingService(bool addDefaultLogger = true) + { + if (addDefaultLogger) + Loggers.Add(DefaultLog); + } + + private void DefaultLog(string prefix, string message) + { + Console.WriteLine($"[{prefix}] {message}"); + } + + public void AddLogger(Action logger) + { + Loggers.Add(logger); + } + + public void AddColorLogger(Action logger) + { + ColorLoggers.Add(logger); + } + + public void Log(string prefix, string message, string color) + { + foreach (var logger in Loggers) + { + logger(prefix, message); + } + + foreach (var logger in ColorLoggers) + { + logger(prefix, message, color); + } + } + + public void Log(string message, string color) + { + foreach (var logger in Loggers) + { + logger(typeof(T).Name, message); + } + + foreach (var logger in ColorLoggers) + { + logger(typeof(T).Name, message, color); + } + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/MessageService.cs b/Valour/Sdk/Services/MessageService.cs new file mode 100644 index 000000000..630f8bff8 --- /dev/null +++ b/Valour/Sdk/Services/MessageService.cs @@ -0,0 +1,197 @@ +using Microsoft.AspNetCore.SignalR.Client; +using Valour.Sdk.Client; +using Valour.Sdk.Models.Messages.Embeds; +using Valour.Sdk.Nodes; +using Valour.Shared; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Services; + +public class MessageService : ServiceBase +{ + /// + /// Run when a message is received + /// + public HybridEvent MessageReceived; + + /// + /// Run when a message is edited + /// + public HybridEvent MessageEdited; + + /// + /// Run when a planet is deleted + /// + public HybridEvent MessageDeleted; + + /// + /// Run when a personal embed update is received + /// + public HybridEvent PersonalEmbedUpdate; + + /// + /// Run when a channel embed update is received + /// + public HybridEvent ChannelEmbedUpdate; + + private readonly LogOptions _logOptions = new( + "MessageService", + "#3381a3", + "#a3333e", + "#a39433" + ); + + private readonly ValourClient _client; + private readonly CacheService _cache; + + public MessageService(ValourClient client) + { + _client = client; + _cache = client.Cache; + SetupLogging(client.Logger, _logOptions); + + _client.NodeService.NodeAdded += HookHubEvents; + } + + public async ValueTask FetchMessageAsync(long id, bool skipCache = false) + { + if (!skipCache && _client.Cache.Messages.TryGet(id, out var cached)) + return cached; + + var response = await _client.PrimaryNode.GetJsonAsync($"api/message/{id}"); + + return _client.Cache.Sync(response.Data); + } + + public async ValueTask FetchMessageAsync(long id, Planet planet, bool skipCache = false) + { + if (!skipCache && _client.Cache.Messages.TryGet(id, out var cached)) + return cached; + + var response = await (planet?.Node ?? _client.PrimaryNode).GetJsonAsync($"api/message/{id}"); + + return _client.Cache.Sync(response.Data); + } + + /// + /// Sends a message + /// + public Task> SendMessage(Message message) + => message.PostAsync(); + + /// + /// Ran when a message is recieved + /// + private void OnPlanetMessageReceived(Message message) + { + Log($"[{message.Node?.Name}]: Received planet message {message.Id} for channel {message.ChannelId}"); + if (message.ReplyTo is not null) + { + message.ReplyTo = _cache.Sync(message.ReplyTo); + } + + var cached = _cache.Sync(message); + + MessageReceived?.Invoke(cached); + + if (_cache.Channels.TryGet(message.ChannelId, out var channel)) + { + channel.NotifyMessageReceived(message); + } + } + + /// + /// Ran when a message is edited + /// + private void OnPlanetMessageEdited(Message message) + { + Log($"[{message.Node?.Name}]: Received planet message edit {message.Id} for channel {message.ChannelId}"); + if (message.ReplyTo is not null) + { + message.ReplyTo = _cache.Sync(message.ReplyTo); + } + + var cached = _cache.Sync(message); + + MessageEdited?.Invoke(cached); + + if (_cache.Channels.TryGet(message.ChannelId, out var channel)) + { + channel.NotifyMessageEdited(message); + } + } + + /// + /// Ran when a message is recieved + /// + private void OnDirectMessageReceived(Message message) + { + Log($"[{message.Node?.Name}]: Received direct message {message.Id} for channel {message.ChannelId}"); + + if (message.ReplyTo is not null) + { + message.ReplyTo = _cache.Sync(message.ReplyTo); + } + + var cached = _cache.Sync(message); + + MessageReceived?.Invoke(cached); + + if (_cache.Channels.TryGet(message.ChannelId, out var channel)) + { + channel.NotifyMessageReceived(message); + } + } + + /// + /// Ran when a message is edited + /// + private void OnDirectMessageEdited(Message message) + { + Log($"[{message.Node?.Name}]: Received direct message edit {message.Id} for channel {message.ChannelId}"); + if (message.ReplyTo is not null) + { + message.ReplyTo = _cache.Sync(message.ReplyTo); + } + + var cached = _cache.Sync(message); + + MessageEdited?.Invoke(cached); + + if (_cache.Channels.TryGet(message.ChannelId, out var channel)) + { + channel.NotifyMessageEdited(message); + } + } + + private void OnMessageDeleted(Message message) + { + MessageDeleted?.Invoke(message); + + if (_cache.Channels.TryGet(message.ChannelId, out var channel)) + { + channel.NotifyMessageDeleted(message); + } + } + + private void OnPersonalEmbedUpdate(PersonalEmbedUpdate update) + { + PersonalEmbedUpdate?.Invoke(update); + } + + private void OnChannelEmbedUpdate(ChannelEmbedUpdate update) + { + ChannelEmbedUpdate?.Invoke(update); + } + + private void HookHubEvents(Node node) + { + node.HubConnection.On("Relay", OnPlanetMessageReceived); + node.HubConnection.On("RelayEdit", OnPlanetMessageEdited); + node.HubConnection.On("RelayDirect", OnDirectMessageReceived); + node.HubConnection.On("RelayDirectEdit", OnDirectMessageEdited); + node.HubConnection.On("DeleteMessage", _client.MessageService.OnMessageDeleted); + node.HubConnection.On("Personal-Embed-Update", OnPersonalEmbedUpdate); + node.HubConnection.On("Channel-Embed-Update", OnChannelEmbedUpdate); + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/NodeService.cs b/Valour/Sdk/Services/NodeService.cs new file mode 100644 index 000000000..0623b41eb --- /dev/null +++ b/Valour/Sdk/Services/NodeService.cs @@ -0,0 +1,177 @@ +using Valour.Sdk.Client; +using Valour.Sdk.Nodes; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Services; + +public class NodeService : ServiceBase +{ + public HybridEvent NodeAdded; + public HybridEvent NodeRemoved; + public HybridEvent NodeReconnected; + + public readonly IReadOnlyList Nodes; + private readonly List _nodes = new(); + + public readonly IReadOnlyDictionary PlanetToNodeName; + private readonly Dictionary _planetToNodeName = new(); + + public readonly IReadOnlyDictionary NameToNode; + private readonly Dictionary _nameToNode = new(); + + private readonly ValourClient _client; + + private static readonly LogOptions LogOptions = new ( + "NodeService", + "#036bfc", + "#fc0356", + "#fc8403" + ); + + public NodeService(ValourClient client) + { + _client = client; + + Nodes = _nodes; + PlanetToNodeName = _planetToNodeName; + NameToNode = _nameToNode; + + SetupLogging(client.Logger, LogOptions); + } + + public async Task SetupPrimaryNodeAsync() + { + string nodeName = null; + + do + { + // Get primary node identity + var nodeNameResponse = await _client.Http.GetAsync("api/node/name"); + var msg = await nodeNameResponse.Content.ReadAsStringAsync(); + if (!nodeNameResponse.IsSuccessStatusCode) + { + LogError("Failed to get primary node name... trying again in three seconds. Network issues? \n \n" + msg); + await Task.Delay(3000); + } + else + { + nodeName = msg; + } + } while (nodeName is null); + + // Initialize primary node + _client.PrimaryNode = new Node(_client); + await _client.PrimaryNode.InitializeAsync(nodeName, true); + } + + + /// + /// Returns the node that a planet is known to be on, + /// but will not reach out to the server to find new ones + /// + public Node GetKnownByPlanet(long planetId) + { + _planetToNodeName.TryGetValue(planetId, out string name); + if (name is null) + return null; + + // If we know the node name but don't have it set up, we can set it up now + _nameToNode.TryGetValue(name, out Node node); + return node; + } + + /// + /// Returns the node with the given name + /// + public async ValueTask GetByName(string name) + { + // Do we already have the node? + if (_nameToNode.TryGetValue(name, out var node)) + return node; + + // If not, create it and link it + node = new Node(_client); + await node.InitializeAsync(name); + + // TODO: We have a master list of node names so we can probably do a sanity check here + + return node; + } + + /// + /// Returns the node with the given name, and sets the location of a planet + /// Used for healing node locations after bad requests + /// + public async Task GetNodeAndSetPlanetLocation(string name, long? planetId) + { + if (name is null) + return null; + + var node = await GetByName(name); + + if (planetId is not null) + { + _planetToNodeName[planetId.Value] = name; + + if (_client.Cache.Planets.TryGet(planetId.Value, out var planet)) + planet.SetNode(node); + } + + return node; + } + + public void RegisterNode(Node node) + { + _nameToNode[node.Name] = node; + + if (_nodes.All(x => x.Name != node.Name)) + _nodes.Add(node); + } + + public void SetKnownByPlanet(long planetId, string nodeName) + { + _planetToNodeName[planetId] = nodeName; + } + + public async ValueTask GetNodeForPlanetAsync(long planetId) + { + + _planetToNodeName.TryGetValue(planetId, out string name); + + // Do we already have the node? + if (name is null) + { + // If not, ask current node where the planet is located + var response = await _client.PrimaryNode.GetAsync($"api/node/planet/{planetId}"); + + // We failed to find the planet in a node + if (!response.Success) + return null; + + // If we succeeded, wrap the response in a node object + name = response.Data.Trim(); + } + + NameToNode.TryGetValue(name, out var node); + + // If we don't already know about this node, create it and link it + if (node is null) { + + node = new Node(_client); + await node.InitializeAsync(name); + } + + // Set planet to node + _planetToNodeName[planetId] = name; + + return node; + } + + public void CheckConnections() + { + foreach (var node in Nodes) + { + node.CheckConnection(); + } + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/NotificationService.cs b/Valour/Sdk/Services/NotificationService.cs new file mode 100644 index 000000000..1a4c45b52 --- /dev/null +++ b/Valour/Sdk/Services/NotificationService.cs @@ -0,0 +1,132 @@ +using Valour.Sdk.Client; +using Valour.Shared; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Services; + +public class NotificationService +{ + /// + /// Run when a notification is received + /// + public HybridEvent NotificationReceived; + + /// + /// Run when notifications are cleared + /// + public HybridEvent NotificationsCleared; + + /// + /// Pain and suffering for thee + /// + public IReadOnlyList UnreadNotifications { get; private set; } + private List _unreadNotifications = new(); + + // Needs to be here to be used as virtualized, unfortunately + public List GetUnreadInternal() => _unreadNotifications; + + /// + /// A set from the source of notifications to the notification. + /// Used for extremely efficient lookups. + /// + public IReadOnlyDictionary UnreadNotificationsLookupBySource { get; private set; } + private Dictionary _unreadNotificationsLookupBySource = new(); + + private readonly ValourClient _client; + + public NotificationService(ValourClient client) + { + _client = client; + + UnreadNotifications = _unreadNotifications; + UnreadNotificationsLookupBySource = _unreadNotificationsLookupBySource; + } + + public async Task LoadUnreadNotificationsAsync() + { + var response = await _client.PrimaryNode.GetJsonAsync>($"api/notifications/self/unread/all"); + + if (!response.Success) + return; + + var notifications = response.Data; + + _unreadNotifications.Clear(); + _unreadNotificationsLookupBySource.Clear(); + + // Add to cache + foreach (var notification in notifications) + { + var cached = _client.Cache.Sync(notification); + + // Only add if unread + if (notification.TimeRead is not null) + continue; + + _unreadNotifications.Add(cached); + + if (cached.SourceId is not null) + _unreadNotificationsLookupBySource.Add(notification.SourceId!.Value, notification); + } + } + + public async Task MarkNotificationRead(Notification notification, bool value) + { + var result = await _client.PrimaryNode.PostAsync($"api/notifications/self/{notification.Id}/read/{value}", null); + return result; + } + + public async Task ClearNotificationsAsync() + { + var result = await _client.PrimaryNode.PostAsync("api/notifications/self/clear", null); + return result; + } + + public int GetPlanetNotifications(long planetId) + { + return UnreadNotifications.Count(x => x.PlanetId == planetId); + } + + public int GetChannelNotifications(long channelId) + { + return UnreadNotifications.Count(x => x.ChannelId == channelId); + } + + public void OnNotificationReceived(Notification notification) + { + var cached = _client.Cache.Sync(notification); + + if (cached.TimeRead is null) + { + if (!_unreadNotifications.Contains(cached)) + _unreadNotifications.Add(cached); + + if (cached.SourceId is not null) + { + _unreadNotificationsLookupBySource[cached.SourceId.Value] = cached; + } + } + else + { + _unreadNotifications.RemoveAll(x => x.Id == cached.Id); + if (cached.SourceId is not null) + { + _unreadNotificationsLookupBySource.Remove(cached.SourceId.Value); + } + } + + NotificationReceived?.Invoke(cached); + } + + /// + /// Triggered by the server when notifications are cleared, + /// so that cross-device notifications are synced + /// + public void OnNotificationsCleared() + { + _unreadNotifications.Clear(); + _unreadNotificationsLookupBySource.Clear(); + + NotificationsCleared?.Invoke(); + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/OauthService.cs b/Valour/Sdk/Services/OauthService.cs new file mode 100644 index 000000000..58f074fc3 --- /dev/null +++ b/Valour/Sdk/Services/OauthService.cs @@ -0,0 +1,31 @@ +using Valour.Sdk.Client; +using Valour.Shared.Authorization; + +namespace Valour.Sdk.Services; + +public class OauthService : ServiceBase +{ + private readonly LogOptions _logOptions = new( + "OauthService", + "#3381a3", + "#a3333e", + "#a39433" + ); + + private readonly ValourClient _client; + + public OauthService(ValourClient client) + { + _client = client; + SetupLogging(_client.Logger, _logOptions); + } + + public async Task> FetchMyOauthAppAsync() => + (await _client.PrimaryNode.GetJsonAsync>($"{_client.Me.IdRoute}/apps")).Data; + + public async Task FetchAppAsync(long id) => + (await _client.PrimaryNode.GetJsonAsync($"api/oauth/app/{id}")).Data; + + public async Task FetchAppPublicDataAsync(long id) => + (await _client.PrimaryNode.GetJsonAsync($"api/oauth/app/public/{id}")).Data; +} \ No newline at end of file diff --git a/Valour/Sdk/Services/PermissionService.cs b/Valour/Sdk/Services/PermissionService.cs new file mode 100644 index 000000000..ce1a4241b --- /dev/null +++ b/Valour/Sdk/Services/PermissionService.cs @@ -0,0 +1,51 @@ +using Valour.Sdk.Client; +using Valour.Sdk.ModelLogic; +using Valour.Shared.Models; + +namespace Valour.Sdk.Services; + +public class PermissionService : ServiceBase +{ + private static readonly LogOptions LogOptions = new( + "PermissionService", + "#0083ab", + "#ab0055", + "#ab8900" + ); + + private readonly ValourClient _client; + private readonly CacheService _cache; + + public PermissionService(ValourClient client) + { + _client = client; + _cache = client.Cache; + SetupLogging(client.Logger, LogOptions); + } + + public async ValueTask FetchPermissionsNodeAsync(PermissionsNodeKey key, long planetId, bool skipCache = false) + { + var planet = await _client.PlanetService.FetchPlanetAsync(planetId, skipCache); + return await FetchPermissionsNodeAsync(key, planet, skipCache); + } + + public async ValueTask FetchPermissionsNodeAsync(PermissionsNodeKey key, Planet planet, bool skipCache = false) + { + if (!skipCache && + _cache.PermNodeKeyToId.TryGetValue(key, out var id) && + _cache.PermissionsNodes.TryGet(id, out var cached)) + return cached; + + var permNode = (await planet.Node.GetJsonAsync( + ISharedPermissionsNode.GetIdRoute(key.TargetId, key.RoleId, key.TargetType), + true)).Data; + + return _cache.Sync(permNode); + } + + public async Task> FetchPermissionsNodesByRoleAsync(long roleId, Planet planet) + { + var permissionNodes = (await planet.Node.GetJsonAsync>($"{ISharedPlanetRole.GetIdRoute(roleId)}/nodes")).Data; + return permissionNodes.SyncAll(_cache); + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/PlanetService.cs b/Valour/Sdk/Services/PlanetService.cs new file mode 100644 index 000000000..800a91420 --- /dev/null +++ b/Valour/Sdk/Services/PlanetService.cs @@ -0,0 +1,506 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.SignalR.Client; +using Valour.Sdk.Client; +using Valour.Sdk.ModelLogic; +using Valour.Sdk.Nodes; +using Valour.Shared; +using Valour.Shared.Models; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Services; + +public class PlanetService : ServiceBase +{ + /// + /// Run when a planet connection opens + /// + public HybridEvent PlanetConnected; + + /// + /// Run when SignalR closes a planet + /// + public HybridEvent PlanetDisconnected; + + /// + /// Run when a planet is joined + /// + public HybridEvent PlanetJoined; + + /// + /// Run when the joined planets list is updated + /// + public HybridEvent JoinedPlanetsUpdated; + + /// + /// Run when a planet is left + /// + public HybridEvent PlanetLeft; + + /// + /// The planets this client has joined + /// + public readonly IReadOnlyList JoinedPlanets; + + private readonly List _joinedPlanets = new(); + + /// + /// Currently opened planets + /// + public readonly IReadOnlyList ConnectedPlanets; + + private readonly List _connectedPlanets = new(); + + /// + /// Lookup for opened planets by id + /// + public readonly IReadOnlyDictionary ConnectedPlanetsLookup; + + private readonly Dictionary _connectedPlanetsLookup = new(); + + /// + /// A set of locks used to prevent planet connections from closing automatically + /// + public readonly IReadOnlyDictionary PlanetLocks; + + private readonly Dictionary _planetLocks = new(); + + private readonly LogOptions _logOptions = new( + "PlanetService", + "#3381a3", + "#a3333e", + "#a39433" + ); + + private readonly ValourClient _client; + + public PlanetService(ValourClient client) + { + _client = client; + + // Add victor dummy member + _client.Cache.PlanetMembers.PutReplace(long.MaxValue, new PlanetMember() + { + Nickname = "Victor", + Id = long.MaxValue, + MemberAvatar = "./_content/Valour.Client/media/logo/logo-256.webp" + }); + + // Setup readonly collections + JoinedPlanets = _joinedPlanets; + ConnectedPlanets = _connectedPlanets; + PlanetLocks = _planetLocks; + ConnectedPlanetsLookup = _connectedPlanetsLookup; + + // Setup logging + SetupLogging(client.Logger, _logOptions); + + // Setup reconnect logic + _client.NodeService.NodeReconnected += OnNodeReconnect; + _client.NodeService.NodeAdded += HookHubEvents; + } + + /// + /// Retrieves and returns a client planet by requesting from the server + /// + public async ValueTask FetchPlanetAsync(long id, bool skipCache = false) + { + if (!skipCache && _client.Cache.Planets.TryGet(id, out var cached)) + return cached; + + var planet = (await _client.PrimaryNode.GetJsonAsync($"api/planets/{id}")).Data; + + await planet.EnsureReadyAsync(); + + return _client.Cache.Sync(planet); + } + + /// + /// Returns the invite for the given invite code (id) + /// + public async Task FetchInviteAsync(string code, bool skipCache = false) + { + if (_client.Cache.PlanetInvites.TryGet(code, out var cached)) + return cached; + + var invite = (await _client.PrimaryNode.GetJsonAsync(ISharedPlanetInvite.GetIdRoute(code))).Data; + + return _client.Cache.Sync(invite); + } + + public async Task FetchInviteScreenData(string code) => + (await _client.PrimaryNode.GetJsonAsync( + $"{ISharedPlanetInvite.BaseRoute}/{code}/screen")).Data; + + /// + /// Fetches all planets that the user has joined from the server + /// + public async Task FetchJoinedPlanetsAsync() + { + var response = await _client.PrimaryNode.GetJsonAsync>($"api/users/me/planets"); + if (!response.Success) + return response.WithoutData(); + + var planets = response.Data; + + _joinedPlanets.Clear(); + + planets.SyncAll(_client.Cache); + + // Add to cache + foreach (var planet in planets) + { + _joinedPlanets.Add(planet); + } + + JoinedPlanetsUpdated?.Invoke(); + + return TaskResult.SuccessResult; + } + + /// + /// Returns if the given planet is open + /// + public bool IsPlanetConnected(Planet planet) => + ConnectedPlanets.Any(x => x.Id == planet.Id); + + /// + /// Opens a planet and prepares it for use + /// + public Task TryOpenPlanetConnection(long planetId, string key) + { + if (!_client.Cache.Planets.TryGet(planetId, out var planet)) + { + return Task.FromResult(TaskResult.FromFailure("Planet not found")); + } + + return TryOpenPlanetConnection(planet, key); + } + + /// + /// Opens a planet and prepares it for use + /// + public async Task TryOpenPlanetConnection(Planet planet, string key) + { + // Cannot open null + if (planet is null) + return TaskResult.FromFailure("Planet is null"); + + if (PlanetLocks.ContainsKey(key)) + { + _planetLocks[key] = planet.Id; + } + else + { + // Add lock + AddPlanetLock(key, planet.Id); + } + + // Already open + if (ConnectedPlanets.Contains(planet)) + return TaskResult.SuccessResult; + + // Mark as opened + _connectedPlanets.Add(planet); + _connectedPlanetsLookup[planet.Id] = planet; + + Log($"Opening planet {planet.Name} ({planet.Id})"); + + var sw = new Stopwatch(); + + sw.Start(); + + // Get node for planet + var node = await _client.NodeService.GetNodeForPlanetAsync(planet.Id); + + List tasks = new(); + + // Joins SignalR group + var result = await node.HubConnection.InvokeAsync("JoinPlanet", planet.Id); + + if (!result.Success) + { + LogError(result.Message); + return result; + } + + Log(result.Message); + + // Load roles early for cached speed + await planet.LoadRolesAsync(); + + // Load member data early for the same reason (speed) + tasks.Add(planet.FetchMemberDataAsync()); + + // Load channels + tasks.Add(planet.FetchChannelsAsync()); + + // Load permissions nodes + tasks.Add(planet.FetchPermissionsNodesAsync()); + + // requesting/loading the data does not require data from other requests/types + // so just await them all, instead of one by one + await Task.WhenAll(tasks); + + sw.Stop(); + + Log($"Time to open this Planet: {sw.ElapsedMilliseconds}ms"); + + // Log success + Log($"Joined SignalR group for planet {planet.Name} ({planet.Id})"); + + PlanetConnected?.Invoke(planet); + + return TaskResult.SuccessResult; + } + + /// + /// Closes a SignalR connection to a planet + /// + public Task TryClosePlanetConnection(long planetId, string key, bool force = false) + { + if (!_client.Cache.Planets.TryGet(planetId, out var planet)) + { + return Task.FromResult(TaskResult.FromFailure("Planet not found")); + } + + return TryClosePlanetConnection(planet, key, force); + } + + /// + /// Closes a SignalR connection to a planet + /// + public async Task TryClosePlanetConnection(Planet planet, string key, bool force = false) + { + if (!force) + { + var lockResult = RemovePlanetLock(key); + if (lockResult == ConnectionLockResult.Locked) + { + return TaskResult.FromFailure("Planet 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 (_planetLocks.Values.Any(x => x == planet.Id)) + { + return TaskResult.FromFailure("Planet is locked by other keys."); + } + } + } + + // Already closed + if (!ConnectedPlanets.Contains(planet)) + return TaskResult.SuccessResult; + + // Close connection + await planet.Node.HubConnection.SendAsync("LeavePlanet", planet.Id); + + // Remove from list + _connectedPlanets.Remove(planet); + _connectedPlanetsLookup.Remove(planet.Id); + + Log($"Left SignalR group for planet {planet.Name} ({planet.Id})"); + + // Invoke event + PlanetDisconnected?.Invoke(planet); + + return TaskResult.SuccessResult; + } + + /// + /// Prevents a planet from closing connections automatically. + /// Key is used to allow multiple locks per planet. + /// + private void AddPlanetLock(string key, long planetId) + { + _planetLocks[key] = planetId; + + Log($"Planet lock {key} added for {planetId}"); + } + + /// + /// Removes the lock for a planet. + /// Returns if there are any locks left for the planet. + /// + private ConnectionLockResult RemovePlanetLock(string key) + { + if (_planetLocks.TryGetValue(key, out var planetId)) + { + Log($"Planet lock {key} removed for {planetId}"); + _planetLocks.Remove(key); + return _planetLocks.Any(x => x.Value == planetId) + ? ConnectionLockResult.Locked + : ConnectionLockResult.Unlocked; + } + + return ConnectionLockResult.NotFound; + } + + /// + /// Adds a planet to the joined planets list and invokes the event. + /// + public void AddJoinedPlanet(Planet planet) + { + _joinedPlanets.Add(planet); + PlanetJoined?.Invoke(planet); + JoinedPlanetsUpdated?.Invoke(); + } + + /// + /// Removes a planet from the joined planets list and invokes the event. + /// + public void RemoveJoinedPlanet(Planet planet) + { + _joinedPlanets.Remove(planet); + + PlanetLeft?.Invoke(planet); + JoinedPlanetsUpdated?.Invoke(); + } + + public async Task> JoinPlanetAsync(long planetId) + { + var planet = await FetchPlanetAsync(planetId); + return await JoinPlanetAsync(planet); + } + + /// + /// Attempts to join the given planet + /// + public async Task> JoinPlanetAsync(Planet planet) + { + var result = await _client.PrimaryNode.PostAsyncWithResponse($"api/planets/{planet.Id}/discover"); + + if (result.Success) + { + AddJoinedPlanet(planet); + } + + return result; + } + + public Task> JoinPlanetAsync(long planetId, string inviteCode) + { + return _client.PrimaryNode.PostAsyncWithResponse( + $"api/planets/{planetId}/join?inviteCode={inviteCode}"); + } + + /// + /// Attempts to leave the given planet + /// + public async Task LeavePlanetAsync(Planet planet) + { + // Get member + var result = await planet.MyMember.DeleteAsync(); + + if (result.Success) + RemoveJoinedPlanet(planet); + + return result; + } + + public void SetJoinedPlanets(List planets) + { + _joinedPlanets.Clear(); + _joinedPlanets.AddRange(planets); + JoinedPlanetsUpdated?.Invoke(); + } + + public async Task> FetchDiscoverablePlanetsAsync() + { + var response = await _client.PrimaryNode.GetJsonAsync>($"api/planets/discoverable"); + if (!response.Success) + return new List(); + + return response.Data; + } + + public async ValueTask FetchRoleAsync(long id, long planetId, bool skipCache = false) + { + var planet = await FetchPlanetAsync(planetId, skipCache); + return await FetchRoleAsync(id, planet, skipCache); + } + + public async ValueTask FetchRoleAsync(long id, Planet planet, bool skipCache = false) + { + if (!skipCache && _client.Cache.PlanetRoles.TryGet(id, out var cached)) + return cached; + + var role = (await planet.Node.GetJsonAsync($"{ISharedPlanetRole.BaseRoute}/{id}")).Data; + + return _client.Cache.Sync(role); + } + + public async Task> FetchRoleMembershipCountsAsync(long planetId) + { + var planet = await FetchPlanetAsync(planetId); + return await FetchRoleMembershipCountsAsync(planet); + } + + public async Task> FetchRoleMembershipCountsAsync(Planet planet) + { + var response = await planet.Node.GetJsonAsync>($"{planet.IdRoute}/roles/counts"); + return response.Data; + } + + public async ValueTask FetchMemberByUserAsync(long userId, long planetId, bool skipCache = false) + { + var planet = await FetchPlanetAsync(planetId, skipCache); + return await FetchMemberByUserAsync(userId, planet, skipCache); + } + + public async ValueTask FetchMemberByUserAsync(long userId, Planet planet, bool skipCache = false) + { + var key = new PlanetMemberKey(userId, planet.Id); + + if (!skipCache && _client.Cache.MemberKeyToId.TryGetValue(key, out var id) && + _client.Cache.PlanetMembers.TryGet(id, out var cached)) + return cached; + + var member = + (await planet.Node.GetJsonAsync( + $"{ISharedPlanetMember.BaseRoute}/byuser/{planet.Id}/{userId}", true)).Data; + + return _client.Cache.Sync(member); + } + + public async ValueTask FetchMemberAsync(long id, long planetId, bool skipCache = false) + { + var planet = await FetchPlanetAsync(planetId, skipCache); + return await FetchMemberAsync(id, planet, skipCache); + } + + public async ValueTask FetchMemberAsync(long id, Planet planet, bool skipCache = false) + { + if (!skipCache && _client.Cache.PlanetMembers.TryGet(id, out var cached)) + return cached; + + var member = (await planet.Node.GetJsonAsync($"{ISharedPlanetMember.BaseRoute}/{id}")).Data; + + return _client.Cache.Sync(member); + } + + private void OnRoleOrderUpdate(RoleOrderEvent e) + { + if (!_client.Cache.Planets.TryGet(e.PlanetId, out var planet)) + return; + + planet.NotifyRoleOrderChange(e); + } + + private async Task OnNodeReconnect(Node node) + { + foreach (var planet in _connectedPlanets.Where(x => x.NodeName == node.Name)) + { + await node.HubConnection.SendAsync("JoinPlanet", planet.Id); + Log($"Rejoined SignalR group for planet {planet.Id}"); + } + } + + private void HookHubEvents(Node node) + { + node.HubConnection.On("RoleOrder-Update", OnRoleOrderUpdate); + } + +} \ No newline at end of file diff --git a/Valour/Sdk/Services/SafetyService.cs b/Valour/Sdk/Services/SafetyService.cs new file mode 100644 index 000000000..f2c1055c7 --- /dev/null +++ b/Valour/Sdk/Services/SafetyService.cs @@ -0,0 +1,28 @@ +using Valour.Sdk.Client; +using Valour.Shared; + +namespace Valour.Sdk.Services; + +public class SafetyService : ServiceBase +{ + private static readonly LogOptions LogOptions = new ( + "SafetyService", + "#036bfc", + "#fc0356", + "#fc8403" + ); + + private readonly ValourClient _client; + + public SafetyService(ValourClient client) + { + _client = client; + SetupLogging(client.Logger, LogOptions); + } + + public async Task PostReportAsync(Report report) + { + var response = await _client.PrimaryNode.PostAsync("api/reports", report); + return response; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/ServiceBase.cs b/Valour/Sdk/Services/ServiceBase.cs new file mode 100644 index 000000000..171404dce --- /dev/null +++ b/Valour/Sdk/Services/ServiceBase.cs @@ -0,0 +1,64 @@ +using Valour.Shared; + +namespace Valour.Sdk.Services; + +public class LogOptions +{ + public readonly string Prefix; + public readonly string Color; + public readonly string ErrorColor; + public readonly string WarningColor; + + public LogOptions(string prefix = "?", string color = "white", string errorColor = "red", string warningColor = "yellow") + { + Prefix = prefix; + Color = color; + ErrorColor = errorColor; + WarningColor = warningColor; + } +} + +public abstract class ServiceBase +{ + private LogOptions _logOptions; + private LoggingService _logger; + + protected void SetupLogging(LoggingService logger, LogOptions options = null) + { + _logger = logger; + _logOptions = options ?? new LogOptions(); + } + + protected void Log(string message) + { + _logger.Log(_logOptions.Prefix, message, _logOptions.Color); + } + + protected void LogError(string message) + { + _logger.Log(_logOptions.Prefix, message, _logOptions.ErrorColor); + } + + protected void LogError(string message, ITaskResult result) + { + _logger.Log(_logOptions.Prefix, message + "\n \n" + result.Message, _logOptions.ErrorColor); + } + + protected void LogIfError(string message, ITaskResult result) + { + if (result.Success) + return; + + LogError(message + "\n \n" + result.Message); + } + + protected void LogError(string message, Exception ex) + { + LogError(message + "\n \n Exception: \n \n" + ex.Message); + } + + protected void LogWarning(string message) + { + _logger.Log(_logOptions.Prefix, message, _logOptions.WarningColor); + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/StaffService.cs b/Valour/Sdk/Services/StaffService.cs new file mode 100644 index 000000000..97bf9c3d5 --- /dev/null +++ b/Valour/Sdk/Services/StaffService.cs @@ -0,0 +1,62 @@ +using Valour.Sdk.Client; +using Valour.Sdk.Models.Economy; +using Valour.Shared; +using Valour.Shared.Models; +using Valour.Shared.Models.Staff; + +namespace Valour.Sdk.Services; + +public class StaffService : ServiceBase +{ + private static readonly LogOptions LogOptions = new ( + "StaffService", + "#036bfc", + "#fc0356", + "#fc8403" + ); + + private readonly ValourClient _client; + + public StaffService(ValourClient client) + { + _client = client; + SetupLogging(client.Logger, LogOptions); + } + + public PagedReader GetReportPagedReader(ReportQueryModel model, int amount = 50) + { + return new PagedReader(_client.PrimaryNode, "api/staff/reports", amount, postData: model); + } + + public PagedReader GetUserQueryReader(UserQueryModel model, int amount = 50) + { + return new PagedReader(_client.PrimaryNode, "api/users/query", amount, postData: model); + } + + public async Task SetUserDisabledAsync(long userId, bool value) + { + var request = new DisableUserRequest() + { + UserId = userId, + Value = value + }; + + return await _client.PrimaryNode.PostAsync($"api/staff/disable", request); + } + + public async Task DeleteUserAsync(long userId) + { + var request = new DeleteUserRequest() + { + UserId = userId + }; + + return await _client.PrimaryNode.PostAsync($"api/staff/delete", request); + } + + public async Task GetMessageAsync(long messageId) + { + var result = await _client.PrimaryNode.GetJsonAsync($"api/staff/messages/{messageId}"); + return result.Data; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/SubscriptionService.cs b/Valour/Sdk/Services/SubscriptionService.cs new file mode 100644 index 000000000..efef7ae2d --- /dev/null +++ b/Valour/Sdk/Services/SubscriptionService.cs @@ -0,0 +1,54 @@ +using Valour.Sdk.Client; +using Valour.Shared; + +namespace Valour.Sdk.Services; + +public class SubscriptionService +{ + private readonly ValourClient _client; + + public SubscriptionService(ValourClient client) + { + _client = client; + } + + /// + /// Subscribe to Valour Plus! (...or Premium? What are we even calling it???) + /// + public async Task SubscribeAsync(string type) + { + var result = await _client.PrimaryNode.PostAsyncWithResponse($"api/subscriptions/{type}/start"); + if (!result.Success) + { + return new TaskResult(false, result.Message); + } + + return result.Data; + } + + /// + /// Unsubscribe (sobs quietly in the corner) + /// + public async Task UnsubscribeAsync() + { + var result = await _client.PrimaryNode.PostAsyncWithResponse($"api/subscriptions/end"); + if (!result.Success) + { + return new TaskResult(false, result.Message); + } + + return result.Data; + } + + public async Task GetSubscriptionPriceAsync(string type) + { + var result = await _client.PrimaryNode.GetJsonAsync($"api/subscriptions/{type}/price"); + return result.Data; + } + + public async Task GetActiveSubscriptionAsync() + { + var result = await _client.PrimaryNode.GetJsonAsync($"api/subscriptions/active/{_client.Me.Id}", true); + return result.Data; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/TenorService.cs b/Valour/Sdk/Services/TenorService.cs new file mode 100644 index 000000000..91848c360 --- /dev/null +++ b/Valour/Sdk/Services/TenorService.cs @@ -0,0 +1,99 @@ +using Valour.Sdk.Client; +using Valour.Shared; +using Valour.TenorTwo; +using Valour.TenorTwo.Models; + +namespace Valour.Sdk.Services; + +public class TenorService : ServiceBase +{ + /// + /// The key for Valour using the Tenor API + /// + private const string TenorKey = "AIzaSyCpYasE9IZNecc7ZPEjHTpOVssJT1aUC_4"; + + /// + /// Client for interacting with the Tenor API + /// + public TenorClient Client => _tenor; + + private readonly HttpClient _httpClient; + + /// + /// The Tenor favorites of this user + /// + public readonly IReadOnlyList TenorFavorites; + private List _tenorFavorites = new(); + + public static readonly List Formats = new() + { + MediaFormatType.gif, + MediaFormatType.tinygif + }; + + private readonly LogOptions _logOptions = new( + "PlanetService", + "#3381a3", + "#a3333e", + "#a39433" + ); + + private readonly ValourClient _client; + private readonly TenorClient _tenor; + + public TenorService(HttpClient httpClient, ValourClient client) + { + _client = client; + SetupLogging(client.Logger, _logOptions); + + _httpClient = httpClient; + TenorFavorites = _tenorFavorites; + _tenor = new TenorClient(TenorKey, "valour", http: httpClient); + } + + public HttpClient GetHttpClient() + { + return _httpClient; + } + + public async Task LoadTenorFavoritesAsync() + { + var response = await _client.PrimaryNode.GetJsonAsync>("api/users/me/tenorfavorites"); + if (!response.Success) + { + LogError("Failed to load Tenor favorites", response); + return; + } + + _tenorFavorites.Clear(); + _tenorFavorites.AddRange(response.Data); + + Log($"Loaded {TenorFavorites.Count} Tenor favorites"); + } + + /// + /// Tries to add the given Tenor favorite + /// + public async Task> AddTenorFavorite(TenorFavorite favorite) + { + var result = await favorite.CreateAsync(); + + if (result.Success) + _tenorFavorites.Add(result.Data); + + return result; + } + + /// + /// Tries to delete the given Tenor favorite + /// + public async Task RemoveTenorFavorite(TenorFavorite favorite) + { + var result = await favorite.DeleteAsync(); + + if (result.Success) + _tenorFavorites.RemoveAll(x => x.Id == favorite.Id); + + return result; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/ThemeService.cs b/Valour/Sdk/Services/ThemeService.cs new file mode 100644 index 000000000..534a1cf6d --- /dev/null +++ b/Valour/Sdk/Services/ThemeService.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Logging; +using Valour.Sdk.Client; +using Valour.Sdk.Models.Themes; + +namespace Valour.Sdk.Services; + +public class ThemeService : ServiceBase +{ + private static readonly LogOptions LogOptions = new ( + "ThemeService", + "#036bfc", + "#fc0356", + "#fc8403" + ); + + private readonly ValourClient _client; + + public ThemeService(ValourClient client) + { + _client = client; + SetupLogging(client.Logger, LogOptions); + } + + // No need to cache themes + public async Task FetchThemeAsync(long id) + { + var response = await _client.PrimaryNode.GetJsonAsync($"api/themes/{id}"); + if (!response.Success) + { + LogWarning($"Failed to get theme: {response.Message}"); + return null; + } + + return response.Data; + } + + public PagedReader GetAvailableThemeReader(int amount = 20, string search = null) + { + var query = new Dictionary() {{ "search", search }}; + var reader = new PagedReader(_client.PrimaryNode, "api/themes", amount, query); + return reader; + } + + public async Task> GetMyThemes() + { + var response = await _client.PrimaryNode.GetJsonAsync>("api/themes/me"); + if (!response.Success) + { + LogWarning($"Failed to get my themes: {response.Message}"); + return new List(); + } + + return response.Data; + } +} \ No newline at end of file diff --git a/Valour/Sdk/Services/UserService.cs b/Valour/Sdk/Services/UserService.cs new file mode 100644 index 000000000..bec9b7704 --- /dev/null +++ b/Valour/Sdk/Services/UserService.cs @@ -0,0 +1,43 @@ +using Valour.Sdk.Client; +using Valour.Shared; +using Valour.Shared.Models; + +namespace Valour.Sdk.Services; + +public class UserService : ServiceBase +{ + private readonly LogOptions _logOptions = new( + "UserService", + "#3381a3", + "#a3333e", + "#a39433" + ); + + private readonly ValourClient _client; + + public UserService(ValourClient client) + { + _client = client; + SetupLogging(_client.Logger, _logOptions); + } + + public async ValueTask FetchUserAsync(long id, bool skipCache = false) + { + if (!skipCache && _client.Cache.Users.TryGet(id, out var cached)) + return cached; + + var user = (await _client.PrimaryNode.GetJsonAsync($"{ISharedUser.BaseRoute}/{id}")).Data; + + return _client.Cache.Sync(user); + } + + public async ValueTask FetchProfileAsync(long userid, bool skipCache) + { + if (!skipCache && _client.Cache.UserProfiles.TryGet(userid, out var cached)) + return cached; + + var profile = (await _client.PrimaryNode.GetJsonAsync($"api/userProfiles/{userid}")).Data; + + return _client.Cache.Sync(profile); + } +} \ No newline at end of file diff --git a/Valour/Server/Api/Dynamic/MessageApi.cs b/Valour/Server/Api/Dynamic/MessageApi.cs new file mode 100644 index 000000000..b144f36f1 --- /dev/null +++ b/Valour/Server/Api/Dynamic/MessageApi.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Valour.Shared.Authorization; +using Valour.Shared.Models; + +namespace Valour.Server.Api.Dynamic; + +public class MessageApi +{ + [ValourRoute(HttpVerbs.Post, "api/messages")] + [UserRequired(UserPermissionsEnum.Messages)] + public static async Task PostMessageRouteAsync( + [FromBody] Message message, + long channelId, + MessageService messageService, + ChannelService channelService, + PlanetMemberService memberService, + TokenService tokenService, + PlanetRoleService roleService) + { + var token = await tokenService.GetCurrentTokenAsync(); + var userId = token.UserId; + + if (message is null) + return ValourResult.BadRequest("Include message in body"); + + var channel = await channelService.GetAsync(channelId); + if (channel is null) + return ValourResult.NotFound("Channel not found"); + + if (!await channelService.IsMemberAsync(channel, userId)) + return ValourResult.Forbid("You are not a member of this channel"); + + if (channel.ChannelType == ChannelTypeEnum.DirectChat) + { + if (!token.HasScope(UserPermissions.DirectMessages)) + { + return ValourResult.Forbid("Token lacks permission to post messages in direct chat channels"); + } + } + + // For planet channels, planet roles and membership are used + // to determine if the user can post messages and content + if (channel.PlanetId is not null) + { + var member = await memberService.GetCurrentAsync(channel.PlanetId.Value); + if (member is null) + return ValourResult.Forbid("You are not a member of the planet this channel belongs to"); + + // NOTE: We don't have to check View permission because lacking view will + // cause every other permission check to fail + + // Check for message posting permissions + if (!await memberService.HasPermissionAsync(member, channel, ChatChannelPermissions.PostMessages)) + return ValourResult.Forbid("You lack permission to post messages in this channel"); + + // If the message has attachments... + if (!string.IsNullOrWhiteSpace(message.AttachmentsData)) + { + if (!await memberService.HasPermissionAsync(member, channel, ChatChannelPermissions.AttachContent)) + return ValourResult.Forbid("You lack permission to attach content to messages in this channel"); + } + + // If the message has embed data... + if (!string.IsNullOrWhiteSpace(message.EmbedData)) + { + if (!await memberService.HasPermissionAsync(member, channel, ChatChannelPermissions.Embed)) + return ValourResult.Forbid("You lack permission to attach embeds to messages in this channel"); + } + + // Check mention permissions... + if (!string.IsNullOrWhiteSpace(message.MentionsData)) + { + var mentions = JsonSerializer.Deserialize>(message.MentionsData); + if (mentions is not null) + { + foreach (var mention in mentions) + { + if (mention.Type == MentionType.Role) + { + var role = await roleService.GetAsync(mention.TargetId); + if (role is null) + return ValourResult.BadRequest("Invalid role mention"); + + if (role.AnyoneCanMention) + continue; + + if (!await memberService.HasPermissionAsync(member, PlanetPermissions.MentionAll)) + return ValourResult.Forbid($"You lack permission to mention the role {role.Name}"); + } + } + } + else + { + message.MentionsData = null; + } + } + } + + var result = await messageService.PostMessageAsync(message); + if (!result.Success) + { + return ValourResult.BadRequest(result.Message); + } + + return Results.Json(result.Data); + } + + [ValourRoute(HttpVerbs.Put, "api/messages/{id}")] + [UserRequired(UserPermissionsEnum.Messages)] + public static async Task EditMessageRouteAsync( + [FromBody] Message message, + long id, + MessageService messageService, + TokenService tokenService) + { + var token = await tokenService.GetCurrentTokenAsync(); + + if (message.PlanetId is null) + { + if (!token.HasScope(UserPermissions.DirectMessages)) + { + return ValourResult.Forbid("Token lacks permission to delete messages in direct chat channels"); + } + } + + if (message.Id != id) + return ValourResult.BadRequest("Message id in body does not match message id in route"); + + if (message.AuthorUserId != token.UserId) + return ValourResult.Forbid("You are not the author of this message"); + + var result = await messageService.EditMessageAsync(message); + if (!result.Success) + { + return ValourResult.BadRequest(result.Message); + } + + return Results.Json(result.Data); + } + + [ValourRoute(HttpVerbs.Delete, "api/messages/{id}")] + [UserRequired(UserPermissionsEnum.Messages)] + public static async Task DeleteMessageRouteAsync( + long id, + MessageService messageService, + ChannelService channelService, + TokenService tokenService, + PlanetMemberService memberService) + { + var token = await tokenService.GetCurrentTokenAsync(); + var message = await messageService.GetMessageNoReplyAsync(id); + + // Direct messages require stronger token perms + if (message.PlanetId is null) + { + if (!token.HasScope(UserPermissions.DirectMessages)) + { + return ValourResult.Forbid("Token lacks permission to delete messages in direct chat channels"); + } + } + + // Determine permissions + + // First off, if the user is the sender they can always delete + if (message.AuthorUserId != token.UserId) + { + if (message.PlanetId is null) + return ValourResult.Forbid("Only the sender can delete these messages"); + + // Otherwise, we check planet channel permissions + var member = await memberService.GetCurrentAsync(message.PlanetId.Value); + if (member is null) + return ValourResult.Forbid("You are not a member of the planet this channel belongs to"); + + var channel = await channelService.GetAsync(message.ChannelId); + + if (!await memberService.HasPermissionAsync(member, channel, ChatChannelPermissions.ManageMessages)) + { + return ValourResult.Forbid("You do not have permission to manage messages in this channel"); + } + } + + var result = await messageService.DeleteMessageAsync(message.Id); + if (!result.Success) + { + return ValourResult.BadRequest(result.Message); + } + + return Results.Ok(); + } + + + [ValourRoute(HttpVerbs.Get, "api/messages/{id}")] + [UserRequired(UserPermissionsEnum.Messages)] + public static async Task GetMessageAsync( + long id, + MessageService messageService, + ChannelService channelService, + TokenService tokenService) + { + var token = await tokenService.GetCurrentTokenAsync(); + + var message = await messageService.GetMessageAsync(id); + if (message is null) + return ValourResult.NotFound(); + + var channel = await channelService.GetAsync(message.ChannelId); + if (!await channelService.IsMemberAsync(channel, token.UserId)) + return ValourResult.Forbid("You are not a member of this channel"); + + if (message.PlanetId is null) + { + if (!token.HasScope(UserPermissions.DirectMessages)) + { + return ValourResult.Forbid("Token lacks permission to delete messages in this channel"); + } + } + + return Results.Json(message); + } +} \ No newline at end of file diff --git a/Valour/Server/Models/HostedPlanet.cs b/Valour/Server/Models/HostedPlanet.cs new file mode 100644 index 000000000..ea505233a --- /dev/null +++ b/Valour/Server/Models/HostedPlanet.cs @@ -0,0 +1,32 @@ +using Valour.Server.Utilities; +using Valour.Shared.Extensions; +using Valour.Shared.Models; + +namespace Valour.Server.Models; + +/// +/// The HostedPlanet class is used for caching planet information on the server +/// for planets which are directly hosted by that node +/// +public class HostedPlanet : ISharedModel +{ + public Planet Planet { get; private set; } + + public SortedModelCache Roles; + + public long Id + { + get => Planet.Id; + set => Planet.Id = value; + } + + public HostedPlanet(Planet planet) + { + Planet = planet; + } + + public void Update(Planet updated) + { + Planet.CopyAllTo(updated); + } +} \ No newline at end of file diff --git a/Valour/Server/Services/HostedPlanetService.cs b/Valour/Server/Services/HostedPlanetService.cs new file mode 100644 index 000000000..0710359fe --- /dev/null +++ b/Valour/Server/Services/HostedPlanetService.cs @@ -0,0 +1,40 @@ +using System.Collections.Concurrent; +using Valour.Server.Utilities; + +namespace Valour.Server.Services; + +public class HostedPlanetService +{ + /// + /// A cache that holds planets hosted by this node. Nodes keep their hosted + /// planets in-memory to reduce database load. + /// + private readonly ModelCache _hostedPlanets = new(); + + private readonly HashSet _hostedPlanetIds = new(); + + public HostedPlanet Get(long id) + { + _hostedPlanets.Lookup.TryGetValue(id, out var planet); + return planet; + } + + public void Add(Planet planet) + { + var hosted = new HostedPlanet(planet); + _hostedPlanets.Add(hosted); + _hostedPlanetIds.Add(planet.Id); + } + + public void Remove(long id) + { + _hostedPlanets.Remove(id); + _hostedPlanetIds.Remove(id); + } + + public IEnumerable GetHostedPlanetIds() + { + return _hostedPlanetIds; + } + +} \ No newline at end of file diff --git a/Valour/Server/Services/MessageService.cs b/Valour/Server/Services/MessageService.cs new file mode 100644 index 000000000..3041bbc60 --- /dev/null +++ b/Valour/Server/Services/MessageService.cs @@ -0,0 +1,499 @@ +using System.Text.Json; +using Valour.Sdk.Models.Messages.Embeds; +using Valour.Sdk.Models.Messages.Embeds.Items; +using Valour.Server.Cdn; +using Valour.Server.Database; +using Valour.Server.Utilities; +using Valour.Server.Workers; +using Valour.Shared; +using Valour.Shared.Models; + +namespace Valour.Server.Services; + +public class MessageService +{ + private readonly ILogger _logger; + private readonly ValourDb _db; + private readonly NodeService _nodeService; + private readonly ChannelService _channelService; + private readonly NotificationService _notificationService; + private readonly ChannelStateService _stateService; + private readonly CoreHubService _coreHubService; + private readonly HttpClient _http; + + public MessageService( + ILogger logger, + ValourDb db, + NodeService nodeService, + NotificationService notificationService, + ChannelStateService stateService, + IHttpClientFactory http, + CoreHubService coreHubService, + ChannelService channelService) + { + _logger = logger; + _db = db; + _nodeService = nodeService; + _notificationService = notificationService; + _stateService = stateService; + _http = http.CreateClient(); + _coreHubService = coreHubService; + _channelService = channelService; + } + + /// + /// Returns the message with the given id + /// Will include the reply to message if it exists + /// + public async Task GetMessageAsync(long id) + { + var message = await _db.Messages.AsNoTracking() + .Include(x => x.ReplyToMessage) + .FirstOrDefaultAsync(x => x.Id == id); + + return message?.ToModel(); + } + + /// + /// Returns the message with the given id (no reply!) + /// + public async Task GetMessageNoReplyAsync(long id) => + (await _db.Messages.FindAsync(id)).ToModel(); + + /// + /// Used to post a message + /// + public async Task> PostMessageAsync(Message message) + { + if (message is null) + return TaskResult.FromFailure("Include message"); + + // Handle node planet ownership + if (message.PlanetId is not null) + { + if (!await _nodeService.IsHostingPlanet(message.PlanetId.Value)) + { + return TaskResult.FromFailure("Planet belongs to another node."); + } + } + + var user = await _db.Users.FindAsync(message.AuthorUserId); + if (user is null) + return TaskResult.FromFailure("Author user not found."); + + var channel = await _db.Channels.FindAsync(message.ChannelId); + if (channel is null) + return TaskResult.FromFailure("Channel not found."); + + if (channel.PlanetId != message.PlanetId) + return TaskResult.FromFailure("Invalid planet id. Must match channel's planet id."); + + if (!ISharedChannel.ChatChannelTypes.Contains(channel.ChannelType)) + return TaskResult.FromFailure("Channel is not a message channel."); + + Valour.Database.Planet planet = null; + Valour.Database.PlanetMember member = null; + + // Validation specifically for planet messages + if (channel.PlanetId is not null) + { + if (channel.PlanetId != message.PlanetId) + return TaskResult.FromFailure("Invalid planet id. Must match channel's planet id."); + + planet = await _db.Planets.FindAsync(channel.PlanetId); + if (planet is null) + return TaskResult.FromFailure("Planet not found."); + + if (!ISharedChannel.PlanetChannelTypes.Contains(channel.ChannelType)) + return TaskResult.FromFailure("Only planet channel messages can have a planet id."); + + if (message.AuthorMemberId is null) + return TaskResult.FromFailure("AuthorMemberId is required for planet channel messages."); + + member = await _db.PlanetMembers.FindAsync(message.AuthorMemberId); + if (member is null) + return TaskResult.FromFailure("Member id does not exist or is invalid for this planet."); + + if (member.UserId != message.AuthorUserId) + return TaskResult.FromFailure("Mismatch between member's user id and message author user id."); + } + + // Handle replies + if (message.ReplyToId is not null) + { + var replyTo = (await _db.Messages.FindAsync(message.ReplyToId)).ToModel(); + if (replyTo is null) + { + // Try to get from cache if it has not yet posted + replyTo = PlanetMessageWorker.GetStagedMessage(message.ReplyToId.Value); + + if (replyTo is null) + return TaskResult.FromFailure("ReplyToId does not exist."); + } + + // TODO: Technically we could support this in the future + if (replyTo.ChannelId != channel.Id) + return TaskResult.FromFailure("Cannot reply to a message from another channel."); + + message.ReplyTo = replyTo; + } + + if (string.IsNullOrEmpty(message.Content) && + string.IsNullOrEmpty(message.EmbedData) && + string.IsNullOrEmpty(message.AttachmentsData)) + return TaskResult.FromFailure("Message must contain content, embed data, or attachments."); + + if (message.Fingerprint is null) + return TaskResult.FromFailure("Fingerprint is required. Generating a random UUID is suggested."); + + if (message.Content != null && message.Content.Length > 2048) + return TaskResult.FromFailure("Content must be under 2048 chars"); + + + if (!string.IsNullOrWhiteSpace(message.EmbedData)) + { + if (message.EmbedData.Length > 65535) + { + return TaskResult.FromFailure("EmbedData must be under 65535 chars"); + } + + // load embed to check for anti-valour propaganda (incorrect media URIs) + var embed = JsonSerializer.Deserialize(message.EmbedData); + foreach (var page in embed.Pages) + { + foreach (var item in page.GetAllItems()) + { + if (item.ItemType == EmbedItemType.Media) + { + var at = ((EmbedMediaItem)item).Attachment; + var result = MediaUriHelper.ScanMediaUri(at); + if (!result.Success) + return TaskResult.FromFailure($"Error scanning media URI in embed | Page {page.Id} | ServerModel {item.Id}) | URI {at.Location}"); + } + } + } + } + + if (message.Content is null) + message.Content = ""; + + message.Id = IdManager.Generate(); + message.TimeSent = DateTime.UtcNow; + + List attachments = null; + // Handle attachments + if (!string.IsNullOrWhiteSpace(message.AttachmentsData)) + { + attachments = JsonSerializer.Deserialize>(message.AttachmentsData); + if (attachments is not null) + { + foreach (var at in attachments) + { + var result = MediaUriHelper.ScanMediaUri(at); + if (!result.Success) + return TaskResult.FromFailure($"Error scanning media URI in message attachments | {at.Location}"); + } + } + } + + // True if the scanning process makes changes to inline attachments + var inlineChange = false; + if (!string.IsNullOrWhiteSpace(message.Content)) + { + // Prevent markdown bypassing inline, e.g. [](https://example.com) + // This is because a direct image link is not proxied and can steal ip addresses + message.Content = message.Content.Replace("[](", "("); + + var inlineAttachments = await ProxyHandler.GetUrlAttachmentsFromContent(message.Content, _db, _http); + if (inlineAttachments is not null) + { + if (attachments is null) + { + attachments = inlineAttachments; + } + else + { + attachments.AddRange(inlineAttachments); + } + + inlineChange = true; + } + } + + // If there was a change, serialize the new attachments data back to the message + if (inlineChange) + { + message.AttachmentsData = JsonSerializer.Serialize(attachments); + } + + // Handle mentions + var mentions = MentionParser.Parse(message.Content); + if (mentions is not null) + { + foreach (var mention in mentions) + { + await _notificationService.HandleMentionAsync(mention, planet, message, member, user, channel); + } + + // Serialize mentions to the message + message.MentionsData = JsonSerializer.Serialize(mentions); + } + + if (planet is null) + { + if (channel.ChannelType == ChannelTypeEnum.DirectChat) + { + var channelMembers = await _db.ChannelMembers + .AsNoTracking() + .Include(x => x.User) + .Where(x => x.ChannelId == channel.Id) + .Select(x => x.UserId) + .ToListAsync(); + + await _db.Messages.AddAsync(message.ToDatabase()); + await _db.SaveChangesAsync(); + + await _coreHubService.RelayDirectMessage(message, _nodeService, channelMembers); + } + else + { + return TaskResult.FromFailure("Channel type not implemented!"); + } + } + else + { + PlanetMessageWorker.AddToQueue(message); + } + + StatWorker.IncreaseMessageCount(); + + // Update channel state + _stateService.SetChannelStateTime(channel.Id, message.TimeSent, channel.PlanetId); + if (channel.PlanetId is not null) + { + _coreHubService.NotifyChannelStateUpdate(channel.PlanetId.Value, channel.Id, message.TimeSent); + } + + return TaskResult.FromData(message); + } + + /// + /// Used to update a message + /// + public async Task> EditMessageAsync(Message updated) + { + if (updated is null) + return TaskResult.FromFailure("Include updated message"); + + ISharedMessage old = null; + Message stagedOld = null; + + var dbOld = await _db.Messages.FindAsync(updated.Id); + if (dbOld is not null) + { + old = dbOld; + } + else + { + stagedOld = PlanetMessageWorker.GetStagedMessage(updated.Id); + if (stagedOld is not null) + { + old = stagedOld; + } + else + { + return TaskResult.FromFailure("Message not found"); + } + } + + // Sanity checks + if (string.IsNullOrEmpty(updated.Content) && + string.IsNullOrEmpty(updated.EmbedData) && + string.IsNullOrEmpty(updated.AttachmentsData)) + return TaskResult.FromFailure("Updated message cannot be empty"); + + if (updated.EmbedData != null && updated.EmbedData.Length > 65535) + return TaskResult.FromFailure("EmbedData must be under 65535 chars"); + + if (updated.Content != null && updated.Content.Length > 2048) + return TaskResult.FromFailure("Content must be under 2048 chars"); + + List attachments = null; + bool inlineChange = false; + + // Handle attachments + if (!string.IsNullOrWhiteSpace(updated.AttachmentsData)) + { + attachments = JsonSerializer.Deserialize>(updated.AttachmentsData); + if (attachments is not null) + { + foreach (var at in attachments) + { + var result = MediaUriHelper.ScanMediaUri(at); + if (!result.Success) + return TaskResult.FromFailure(result.Message); + } + + // Remove old inline attachments + var n = attachments.RemoveAll(x => x.Inline); + if (n > 0) + inlineChange = true; + } + } + + // Handle new inline attachments + if (!string.IsNullOrWhiteSpace(updated.Content)) + { + var inlineAttachments = await ProxyHandler.GetUrlAttachmentsFromContent(updated.Content, _db, _http); + if (inlineAttachments is not null) + { + if (attachments is null) + { + attachments = inlineAttachments; + } + else + { + attachments.AddRange(inlineAttachments); + } + + inlineChange = true; + } + } + + // If there was a change, serialize the new attachments data back to the message + if (inlineChange) + { + updated.AttachmentsData = JsonSerializer.Serialize(attachments); + } + + old.Content = updated.Content; + old.AttachmentsData = updated.AttachmentsData; + old.EmbedData = updated.EmbedData; + old.EditedTime = DateTime.UtcNow; + + // In this case, the message has posted to the database so + // we save changes there + if (dbOld is not null) + { + try + { + _db.Messages.Update(dbOld); + await _db.SaveChangesAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to update message"); + return TaskResult.FromFailure("Failed to update message in database."); + } + } + + // Handle events + + if (updated.PlanetId is not null) + { + _coreHubService.RelayMessageEdit(updated); + } + else + { + var channelUserIds = await _db.ChannelMembers + .AsNoTracking() + .Where(x => x.ChannelId == updated.ChannelId) + .Select(x => x.UserId) + .ToListAsync(); + + await _coreHubService.RelayDirectMessageEdit(updated, _nodeService, channelUserIds); + } + + return TaskResult.FromData(updated); + } + + /// + /// Used to delete a message + /// + public async Task DeleteMessageAsync(long messageId) + { + Message message = null; + + var dbMessage = await _db.Messages.FindAsync(messageId); + if (dbMessage is null) + { + // Check staging + var staged = PlanetMessageWorker.GetStagedMessage(messageId); + if (staged is not null) + { + message = staged; + PlanetMessageWorker.RemoveFromQueue(staged); + } + else + { + return TaskResult.FromFailure("Message not found"); + } + } + else + { + message = dbMessage.ToModel(); + + try + { + _db.Messages.Remove(dbMessage); + await _db.SaveChangesAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to delete message"); + return TaskResult.FromFailure("Failed to delete message in database."); + } + } + + if (message.PlanetId is not null) + { + _coreHubService.NotifyMessageDeletion(message); + } + else + { + // TODO: Direct message deletion event + } + + return TaskResult.SuccessResult; + } + + public async Task> GetChannelMessagesAsync(long channelId, int count = 50, long index = long.MaxValue) + { + var channel = await _channelService.GetAsync(channelId); + if (channel is null) + return null; + + if (!ISharedChannel.ChatChannelTypes.Contains(channel.ChannelType)) + return null; + + // Not sure why this request would even be made + if (count < 1) + return new(); + + List staged = null; + + if (channel.ChannelType == ChannelTypeEnum.PlanetChat + && index == long.MaxValue) // ONLY INCLUDE STAGED FOR LATEST + { + staged = PlanetMessageWorker.GetStagedMessages(channel.Id); + } + + var messages = await _db.Messages + .AsNoTracking() + .Where(x => x.ChannelId == channel.Id && x.Id < index) + .Include(x => x.ReplyToMessage) + .OrderByDescending(x => x.Id) + .Take(count) + .Reverse() + .Select(x => x.ToModel()) + .ToListAsync(); + + // Not all channels actually stage messages + if (staged is not null) + { + messages.AddRange(staged); + } + + return messages; + } +} \ No newline at end of file diff --git a/Valour/Server/Services/PlanetPermissionService.cs b/Valour/Server/Services/PlanetPermissionService.cs new file mode 100644 index 000000000..6244fb86f --- /dev/null +++ b/Valour/Server/Services/PlanetPermissionService.cs @@ -0,0 +1,30 @@ +namespace Valour.Server.Services; + +// A note to those looking: +// The idea of using role combinations and sequential hashing to provide +// an extremely fast and low-storage alternative to traditional RBAC +// is a concept that I have been working on for a while. When I did the +// math and realized just how efficient it could be, I thought about +// monetizing this or patenting it to prevent a company like Discord +// from stealing it. But then I realized that this is a concept that +// should be free and open to everyone. So feel free to use this +// concept in your own projects, and if you want to credit me, that's +// cool too. And also mention Valour :) + +// I'm going to also give this system the name HACKR-AUTH +// (HAshed Combined Role Keyed AUTHorization) +// because it sounds cool and I like acronyms. + +// There is one slight downside: for a community with 100 roles, there +// is a 1 in 368 quadrillion chance of a hash collision. That's a risk +// I'm willing to take. + +// - Spike, 2024 + +/// +/// Provides methods for checking and enforcing permissions in planets +/// +public class PlanetPermissionService +{ + private readonly PlanetRoleService _roleService; +} \ No newline at end of file diff --git a/Valour/Server/Utilities/ModelCache.cs b/Valour/Server/Utilities/ModelCache.cs new file mode 100644 index 000000000..7f7d12325 --- /dev/null +++ b/Valour/Server/Utilities/ModelCache.cs @@ -0,0 +1,149 @@ +using System.Collections; +using Valour.Shared.Extensions; +using Valour.Shared.Models; + +namespace Valour.Server.Utilities; + +/// +/// The ModelCache is used for +/// caching collections of models that are frequently accessed and updated. +/// It performs no allocations and protects the internal store. +/// +public class ModelCache where T : ISharedModel +{ + public IReadOnlyList Values { get; private set; } + public IReadOnlyDictionary Lookup { get; private set; } + + private List _cache; + private Dictionary _lookup; + + public ModelCache() + { + _cache = new(); + _lookup = new(); + } + + public ModelCache(IEnumerable initial) + { + _cache = initial.ToList(); + _lookup = _cache.ToDictionary(x => x.Id); + } + + public void Reset(IEnumerable initial) + { + _cache = initial.ToList(); + _lookup = _cache.ToDictionary(x => x.Id); + } + + public void Add(T item) + { + _cache.Add(item); + _lookup.Add(item.Id, item); + } + + public void Remove(TId id) + { + if (_lookup.TryGetValue(id, out var item)) + { + _cache.Remove(item); + _lookup.Remove(id); + } + } + + public void Update(T updated) + { + if (_lookup.TryGetValue(updated.Id, out var old)) + { + updated.CopyAllTo(old); + } + else + { + Add(updated); + } + } + + public T Get(TId id) + { + _lookup.TryGetValue(id, out var item); + return item; + } +} + +public class SortedModelCache where T : ISortable, ISharedModel +{ + public IReadOnlyList Values { get; private set; } + public IReadOnlyDictionary Lookup { get; private set; } + + private List _cache; + private Dictionary _lookup; + + public SortedModelCache() + { + _cache = new(); + _lookup = new(); + } + + public SortedModelCache(IEnumerable initial) + { + _cache = initial.ToList(); + _lookup = _cache.ToDictionary(x => x.Id); + + if (_cache.Count > 0) + { + _cache.Sort(ISortable.Compare); + } + } + + public void Reset(IEnumerable initial) + { + _cache = initial.ToList(); + _lookup = _cache.ToDictionary(x => x.Id); + + if (_cache.Count > 0) + { + _cache.Sort(ISortable.Compare); + } + } + + public void Add(T item) + { + _cache.Add(item); + _lookup.Add(item.Id, item); + _cache.Sort(ISortable.Compare); + } + + public void Remove(TId id) + { + if (_lookup.TryGetValue(id, out var item)) + { + _cache.Remove(item); + _lookup.Remove(id); + } + } + + public void Update(T updated) + { + if (_lookup.TryGetValue(updated.Id, out var old)) + { + var oldPos = old.GetSortPosition(); + updated.CopyAllTo(old); + + // check if the position has changed + if (oldPos != updated.GetSortPosition()) + { + _cache.Sort(ISortable.Compare); + } + } + else + { + Add(updated); + _cache.Sort(ISortable.Compare); + } + } + + public T Get(TId id) + { + _lookup.TryGetValue(id, out var item); + return item; + } +} \ No newline at end of file diff --git a/Valour/Shared/Extensions/CopyToExtensions.cs b/Valour/Shared/Extensions/CopyToExtensions.cs new file mode 100644 index 000000000..ecc54face --- /dev/null +++ b/Valour/Shared/Extensions/CopyToExtensions.cs @@ -0,0 +1,78 @@ +using System.Reflection; + +namespace Valour.Shared.Extensions; + +public static class CopyToExtension +{ + public static void CopyAllTo(this T source, T target, PropertyInfo[] properties, FieldInfo[] fields) + { + if (properties is not null) + { + foreach (var property in properties) + { + if (property.CanWrite) + property.SetValue(target, property.GetValue(source, null), null); + } + } + + if (fields is not null) + { + foreach (var field in fields) + { + if (!field.IsStatic) + field.SetValue(target, field.GetValue(source)); + } + } + } + + public static void CopyAllTo(this T source, T target) + { + var type = typeof(T); + foreach (var sourceProperty in type.GetProperties()) + { + var targetProperty = type.GetProperty(sourceProperty.Name); + if (targetProperty.CanWrite) + targetProperty.SetValue(target, sourceProperty.GetValue(source, null), null); + } + foreach (var sourceField in type.GetFields()) + { + var targetField = type.GetField(sourceField.Name); + if (!targetField.IsStatic) + targetField.SetValue(target, sourceField.GetValue(source)); + } + } + + public static void CopyAllNonDefaultTo(this T source, T target) + { + var type = typeof(T); + foreach (var sourceProperty in type.GetProperties()) + { + var targetProperty = type.GetProperty(sourceProperty.Name); + if (targetProperty.CanWrite) + { + var sourceValue = sourceProperty.GetValue(source, null); + if (sourceValue != null && !IsDefaultValue(sourceValue)) + { + targetProperty.SetValue(target, sourceValue, null); + } + } + } + foreach (var sourceField in type.GetFields()) + { + var targetField = type.GetField(sourceField.Name); + if (!targetField.IsStatic) + { + var sourceValue = sourceField.GetValue(source); + if (sourceValue != null && !IsDefaultValue(sourceValue)) + { + targetField.SetValue(target, sourceValue); + } + } + } + } + + private static bool IsDefaultValue(T value) + { + return EqualityComparer.Default.Equals(value, default(T)); + } +} \ No newline at end of file diff --git a/Valour/Shared/Models/ChannelPosition.cs b/Valour/Shared/Models/ChannelPosition.cs new file mode 100644 index 000000000..c7d5b8d81 --- /dev/null +++ b/Valour/Shared/Models/ChannelPosition.cs @@ -0,0 +1,209 @@ +using System.Runtime.CompilerServices; + +namespace Valour.Shared.Models; + +/// +/// A wrapper for a channel position that provides utility methods +/// +public struct ChannelPosition +{ + public uint RawPosition { get; init; } + public uint Depth { get; init; } + public uint LocalPosition { get; init; } + + public ChannelPosition(uint rawPosition) + { + RawPosition = rawPosition; + Depth = GetDepth(rawPosition); + LocalPosition = GetLocalPosition(rawPosition, Depth); + } + + // Okay, I know what you're thinking: Why do these methods just call back to + // the static methods? Why not just implement it in the instance? + // The answer is that the database project needs to use the same + // implementations, and the database will not understand the struct instance. + // Thus, the actual implementations are static. + + public ChannelPosition Append(uint relativePosition) + { + if (Depth >= 4) + throw new InvalidOperationException("Cannot append to a channel with a depth of 4"); + + return new ChannelPosition(AppendRelativePosition(RawPosition, relativePosition)); + } + + public ChannelPosition GetParentPosition() + { + return new ChannelPosition(GetParentPosition(RawPosition)); + } + + public uint GetDirectChildMask() + { + return GetDirectChildMaskByDepth(Depth); + } + + //////////////////// + // Static Methods // + //////////////////// + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetDepth(ISharedChannel channel) => GetDepth(channel.RawPosition); + + public static uint GetDepth(uint position) + { + // This is the planet + if (position == 0) + return 0; + + // Check if the third and fourth bytes (depth 3 and 4) are present + if ((position & 0x0000FFFFu) == 0) + { + // If they are not, we must be in the first or second layer + if ((position & 0x00FF0000u) == 0) + { + // If the second byte is also zero, it's in the first layer (top level) + return 1; + } + // Otherwise, it's in the second layer + return 2; + } + else + { + // Check the lowest byte first (fourth layer) + if ((position & 0x000000FFu) == 0) + { + // If the fourth byte is zero, it's in the third layer + return 3; + } + + // If none of the previous checks matched, it’s in the fourth layer + return 4; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetLocalPosition(ISharedChannel channel) => GetLocalPosition(channel.RawPosition); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetLocalPosition(uint position) + { + var depth = GetDepth(position); + // use depth to determine amount to shift + var shift = 8 * (4 - depth); + var shifted = position >> (int)shift; + // now clear the higher bits + return shifted & 0xFFu; + } + + // Overload for if you already know the depth + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetLocalPosition(uint position, uint depth) + { + // use depth to determine amount to shift + var shift = 8 * (4 - depth); + var shifted = position >> (int)shift; + // now clear the higher bits + return shifted & 0xFFu; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint AppendRelativePosition(uint parentPosition, uint relativePosition) + { + var depth = GetDepth(parentPosition); + return AppendRelativePosition(parentPosition, relativePosition, depth); + } + + // Overload for if you already know the depth + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint AppendRelativePosition(uint parentPosition, uint relativePosition, uint depth) + { + // use depth to determine amount to shift + var shift = 8 * (3 - depth); + // shift the relative position to the correct position + var shifted = relativePosition << (int)shift; + // now add the relative position to the parent position + return parentPosition | shifted; + } + + /// + /// Returns the bounds of the descendants of a channel + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static (uint lower, uint upper) GetDescendentBounds(uint parentPosition) + { + var depth = GetDepth(parentPosition); + return GetDescendentBounds(parentPosition, depth); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static (uint lower, uint upper) GetDescendentBounds(uint parentPosition, uint depth) + { + var lower = GetLowerBound(parentPosition, depth); + var upper = GetUpperBound(parentPosition, depth); + + return (lower, upper); + } + + // This one is quite easy + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetLowerBound(uint parentPosition) + { + var depth = GetDepth(parentPosition); + return GetLowerBound(parentPosition, depth); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetLowerBound(uint parentPosition, uint depth) + { + return AppendRelativePosition(parentPosition, 1, depth); + } + + // This one is a bit more complex + // We cannot just append because we need to cover all of the bytes past the depth + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetUpperBound(uint parentPosition) + { + var depth = GetDepth(parentPosition); + return GetUpperBound(parentPosition, depth); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetUpperBound(uint parentPosition, uint depth) + { + var maxed = 0xFAFAFAFAu; // literally means 250-250-250-250 + // maxed gets shifted right by 8 * (4 - depth) + var shift = 8 * depth; // so if depth = 1, shift = one byte, maxed = 0-250-250-250 + var shifted = maxed >> (int)shift; + + return parentPosition | shifted; + } + + /// + /// Given a depth, returns a bit mask that can be used to get the direct children of a channel + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetDirectChildMaskByDepth(uint depth) + { + return (0xFFFFFFFFu >> (int)((depth + 1) * 8)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetParentPosition(uint position) + { + var depth = GetDepth(position); + return GetParentPosition(position, depth); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint GetParentPosition(uint position, uint depth) + { + if (depth < 2) + return 0; + + var shift = 8 * (depth - 1); + + // shift to right then invert to avoid long + return position & ~(0xFFFFFFFFu >> (int)shift); + } + +} \ No newline at end of file diff --git a/Valour/Shared/Models/IMessageAuthor.cs b/Valour/Shared/Models/IMessageAuthor.cs new file mode 100644 index 000000000..9b21ed86e --- /dev/null +++ b/Valour/Shared/Models/IMessageAuthor.cs @@ -0,0 +1,8 @@ +namespace Valour.Shared.Models; + +public interface IMessageAuthor : ISharedModel +{ + public string Name { get; } + public string GetAvatar(AvatarFormat format = AvatarFormat.WebpAnimated256); + public string GetFailedAvatar(); +} \ No newline at end of file diff --git a/Valour/Shared/Models/ISharedModel.cs b/Valour/Shared/Models/ISharedModel.cs new file mode 100644 index 000000000..ae8ff6cba --- /dev/null +++ b/Valour/Shared/Models/ISharedModel.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Valour.Shared.Models; + +public interface ISharedModel +{ + public object GetId(); +} + +public interface ISharedModel : ISharedModel +{ + TId Id { get; set; } + object ISharedModel.GetId() + { + return Id; + } +} + + diff --git a/Valour/Shared/Models/ISharedPlanetModel.cs b/Valour/Shared/Models/ISharedPlanetModel.cs new file mode 100644 index 000000000..5deee8714 --- /dev/null +++ b/Valour/Shared/Models/ISharedPlanetModel.cs @@ -0,0 +1,13 @@ +namespace Valour.Shared.Models; + +public interface ISharedPlanetModel +{ + long PlanetId { get; set; } +} + +/// +/// Planet items are items which are owned by a planet +/// +public interface ISharedPlanetModel : ISharedPlanetModel, ISharedModel +{ +} diff --git a/Valour/Shared/Models/ISortable.cs b/Valour/Shared/Models/ISortable.cs new file mode 100644 index 000000000..034249d85 --- /dev/null +++ b/Valour/Shared/Models/ISortable.cs @@ -0,0 +1,26 @@ +namespace Valour.Shared.Models; + +public interface ISortable +{ + public static readonly SortableComparer Comparer = new SortableComparer(); + + public uint GetSortPosition(); + + public static int Compare(ISortable x, ISortable y) + { + return x.GetSortPosition().CompareTo(y.GetSortPosition()); + } + + public static int Compare(T x, T y) where T : ISortable + { + return x.GetSortPosition().CompareTo(y.GetSortPosition()); + } +} + +public class SortableComparer : IComparer +{ + int IComparer.Compare(ISortable x, ISortable y) + { + return ISortable.Compare(x, y); + } +} \ No newline at end of file diff --git a/Valour/Shared/Models/PlanetListInfo.cs b/Valour/Shared/Models/PlanetListInfo.cs new file mode 100644 index 000000000..cc6a1545a --- /dev/null +++ b/Valour/Shared/Models/PlanetListInfo.cs @@ -0,0 +1,20 @@ +namespace Valour.Shared.Models; + +/// +/// This information is used to give the client a summary of a planet that has +/// not yet been loaded. +/// +public class PlanetListInfo +{ + public long PlanetId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public bool HasCustomIcon { get; set; } + public bool HasAnimatedIcon { get; set; } + public bool Nsfw { get; set; } + public int MemberCount { get; set; } + + // planet unread/read state + public bool HasUnread { get; set; } + public int UnreadNotificationCount { get; set; } +} \ No newline at end of file diff --git a/Valour/Shared/Models/QueryResponse.cs b/Valour/Shared/Models/QueryResponse.cs new file mode 100644 index 000000000..4b9e13a5b --- /dev/null +++ b/Valour/Shared/Models/QueryResponse.cs @@ -0,0 +1,13 @@ +namespace Valour.Shared.Models; + +public class QueryResponse +{ + public static QueryResponse Empty = new QueryResponse() + { + Items = new List(), + TotalCount = 0 + }; + + public List Items { get; set; } + public int TotalCount { get; set; } +} \ No newline at end of file diff --git a/Valour/Shared/Models/RoleOrderEvent.cs b/Valour/Shared/Models/RoleOrderEvent.cs new file mode 100644 index 000000000..ec9874552 --- /dev/null +++ b/Valour/Shared/Models/RoleOrderEvent.cs @@ -0,0 +1,7 @@ +namespace Valour.Shared.Models; + +public class RoleOrderEvent +{ + public long PlanetId { get; set; } + public List Order { get; set; } +} \ No newline at end of file diff --git a/Valour/Shared/Utilities/HybridEvent.cs b/Valour/Shared/Utilities/HybridEvent.cs new file mode 100644 index 000000000..b8b0c42fb --- /dev/null +++ b/Valour/Shared/Utilities/HybridEvent.cs @@ -0,0 +1,441 @@ +using Microsoft.Extensions.ObjectPool; + +namespace Valour.Shared.Utilities; + +/// +/// The hybrid event handler allows a given method signature to be called both +/// synchronously and asynchronously. Built for efficiency using two separate +/// lists of delegates, with list pooling to minimize allocations. +/// +public class HybridEvent : IDisposable +{ + // Synchronous and asynchronous handler lists + private List> _syncHandlers; + private List> _asyncHandlers; + + // Init is false until the handler lists are initialized + private bool _init; + + // Lock object for synchronous and asynchronous handler access + private readonly object _syncLock = new(); + private readonly object _asyncLock = new(); + + // Object pool for list reuse + // This is static because it is shared across all instances of HybridEvent + private static readonly ObjectPool>> SyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + private static readonly ObjectPool>> AsyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + + // Object pool for task list + private static readonly ObjectPool> TaskListPool = + new DefaultObjectPool>(new ListPolicy()); + + private void InitIfNeeded() + { + if (!_init) + { + // set up handler lists + lock (_syncLock) + { + _syncHandlers = SyncListPool.Get(); + } + + lock (_asyncLock) + { + _asyncHandlers = AsyncListPool.Get(); + } + + _init = true; + } + } + + // Add a synchronous handler + public void AddHandler(Action handler) + { + InitIfNeeded(); + + lock (_syncLock) + { + _syncHandlers.Add(handler); + } + } + + // Add an asynchronous handler + public void AddHandler(Func handler) + { + InitIfNeeded(); + + lock (_asyncLock) + { + _asyncHandlers.Add(handler); + } + } + + // Remove a synchronous handler + public void RemoveHandler(Action handler) + { + lock (_syncLock) + { + _syncHandlers.Remove(handler); + } + } + + // Remove an asynchronous handler + public void RemoveHandler(Func handler) + { + lock (_asyncLock) + { + _asyncHandlers.Remove(handler); + } + } + + // Invoke all synchronous handlers with list pooling to prevent allocation + private void InvokeSyncHandlers(TEventData data) + { + // Get a pooled list for copying handlers + var handlersCopy = SyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_syncLock) + { + handlersCopy.AddRange(_syncHandlers); // Copy handlers into pooled list + } + + try + { + // Invoke all handlers + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + handlersCopy[i].Invoke(data); // No allocations, just iterating over the pooled list + } + } + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + SyncListPool.Return(handlersCopy); + } + } + + // Invoke all asynchronous handlers concurrently with list pooling + private async Task InvokeAsyncHandlers(TEventData data) + { + // Get a pooled list for copying async handlers + var handlersCopy = AsyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_asyncLock) + { + handlersCopy.AddRange(_asyncHandlers); // Copy async handlers into pooled list + } + + var tasks = TaskListPool.Get(); + + try + { + // Invoke all async handlers in parallel using Task.WhenAll + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + tasks.Add(handlersCopy[i].Invoke(data)); // Add tasks to list + } + } + + await Task.WhenAll(tasks); // Wait for all async handlers to complete + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + AsyncListPool.Return(handlersCopy); + TaskListPool.Return(tasks); + } + } + + // Invoke both sync and async handlers + public void Invoke(TEventData data) + { + InvokeSyncHandlers(data); // Call synchronous handlers first + _ = InvokeAsyncHandlers(data); // Then call asynchronous handlers + } + + // Enable += and -= operators for adding/removing handlers + public static HybridEvent operator +(HybridEvent handler, Action action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator +(HybridEvent handler, Func action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Action action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Func action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + // Cleanup everything + public void Dispose() + { + _syncHandlers.Clear(); + _asyncHandlers.Clear(); + } + + // Custom object pooling policy for List + private class ListPolicy : PooledObjectPolicy> + { + public override List Create() => new List(); + public override bool Return(List obj) + { + obj.Clear(); // Clear the list before returning to pool + return true; + } + } +} + +/// +/// The hybrid event handler allows a given method signature to be called both +/// synchronously and asynchronously. Built for efficiency using two separate +/// lists of delegates, with list pooling to minimize allocations. +/// +public class HybridEvent : IDisposable +{ + // Synchronous and asynchronous handler lists + private List _syncHandlers; + private List> _asyncHandlers; + + // Init is false until the handler lists are initialized + private bool _init; + + // Lock object for synchronous and asynchronous handler access + private readonly object _syncLock = new(); + private readonly object _asyncLock = new(); + + // Object pool for list reuse + // This is static because it is shared across all instances of HybridEvent + private static readonly ObjectPool> SyncListPool = + new DefaultObjectPool>(new ListPolicy()); + private static readonly ObjectPool>> AsyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + + // Object pool for task list + private static readonly ObjectPool> TaskListPool = + new DefaultObjectPool>(new ListPolicy()); + + private void InitIfNeeded() + { + if (!_init) + { + // set up handler lists + lock (_syncLock) + { + _syncHandlers = SyncListPool.Get(); + } + + lock (_asyncLock) + { + _asyncHandlers = AsyncListPool.Get(); + } + + _init = true; + } + } + + // Add a synchronous handler + public void AddHandler(Action handler) + { + InitIfNeeded(); + + lock (_syncLock) + { + _syncHandlers.Add(handler); + } + } + + // Add an asynchronous handler + public void AddHandler(Func handler) + { + InitIfNeeded(); + + lock (_asyncLock) + { + _asyncHandlers.Add(handler); + } + } + + // Remove a synchronous handler + public void RemoveHandler(Action handler) + { + lock (_syncLock) + { + _syncHandlers.Remove(handler); + } + } + + // Remove an asynchronous handler + public void RemoveHandler(Func handler) + { + lock (_asyncLock) + { + _asyncHandlers.Remove(handler); + } + } + + // Invoke all synchronous handlers with list pooling to prevent allocation + private void InvokeSyncHandlers() + { + // Get a pooled list for copying handlers + var handlersCopy = SyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_syncLock) + { + handlersCopy.AddRange(_syncHandlers); // Copy handlers into pooled list + } + + try + { + // Invoke all handlers + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + handlersCopy[i].Invoke(); // No allocations, just iterating over the pooled list + } + } + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + SyncListPool.Return(handlersCopy); + } + } + + // Invoke all asynchronous handlers concurrently with list pooling + private async Task InvokeAsyncHandlers() + { + // Get a pooled list for copying async handlers + var handlersCopy = AsyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_asyncLock) + { + handlersCopy.AddRange(_asyncHandlers); // Copy async handlers into pooled list + } + + var tasks = TaskListPool.Get(); + + try + { + // Invoke all async handlers in parallel using Task.WhenAll + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + tasks.Add(handlersCopy[i].Invoke()); // Add tasks to list + } + } + + await Task.WhenAll(tasks); // Wait for all async handlers to complete + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + AsyncListPool.Return(handlersCopy); + TaskListPool.Return(tasks); + } + } + + // Invoke both sync and async handlers + public void Invoke() + { + _ = Task.Run(InvokeSyncHandlers); // Call synchronous handlers first + _ = InvokeAsyncHandlers(); // Then call asynchronous handlers + } + + // Enable += and -= operators for adding/removing handlers + public static HybridEvent operator +(HybridEvent handler, Action action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator +(HybridEvent handler, Func action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Action action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Func action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + // Cleanup everything + public void Dispose() + { + _syncHandlers.Clear(); + _asyncHandlers.Clear(); + } + + // Custom object pooling policy for List + private class ListPolicy : PooledObjectPolicy> + { + public override List Create() => new List(); + public override bool Return(List obj) + { + obj.Clear(); // Clear the list before returning to pool + return true; + } + } +} + + diff --git a/Valour/Tests/Models/ChannelTests.cs b/Valour/Tests/Models/ChannelTests.cs new file mode 100644 index 000000000..4ee82f190 --- /dev/null +++ b/Valour/Tests/Models/ChannelTests.cs @@ -0,0 +1,75 @@ +using Valour.Shared.Models; + +namespace Valour.Tests.Models; + +public class ChannelTests +{ + [Fact] + public void TestChannelDepth() + { + // Depth checks + var pos = new ChannelPosition(0x00_00_00_00); + + // Depth 0 (planet) + Assert.Equal(0u, pos.Depth); + + // Depth 1 + pos = new ChannelPosition(0x01_00_00_00); + Assert.Equal(1u, pos.Depth); + + // Depth 2 + pos = new ChannelPosition(0x01_01_00_00); + Assert.Equal(2u, pos.Depth); + + // Depth 3 + pos = new ChannelPosition(0x01_01_01_00); + Assert.Equal(3u, pos.Depth); + + // Depth 4 + pos = new ChannelPosition(0x01_01_01_01); + Assert.Equal(4u, pos.Depth); + + // Local position checks + pos = new ChannelPosition(0x01_00_00_00); + Assert.Equal(1u, pos.LocalPosition); + + pos = new ChannelPosition(0x01_01_00_00); + Assert.Equal(1u, pos.LocalPosition); + + pos = new ChannelPosition(0x01_02_00_00); + Assert.Equal(2u, pos.LocalPosition); + + pos = new ChannelPosition(0x01_01_01_05); + Assert.Equal(5u, pos.LocalPosition); + } + + [Fact] + public void TestChannelPositionOperations1() + { + var parentPosition = new ChannelPosition(0x01_01_00_00u); + var relativePosition = 0x02u; + + var appended = parentPosition.Append(relativePosition); + + Assert.Equal(0x01_01_02_00u, appended.RawPosition); + + Assert.Equal(3u, appended.Depth); + + Assert.Equal(2u, appended.LocalPosition); + } + + [Fact] + public void TestChannelPositionOperations2() + { + var parentPosition = new ChannelPosition(0x01_01_01_00u); + var relativePosition = 0xA0u; + + var appended = parentPosition.Append(relativePosition); + + Assert.Equal(0x01_01_01_A0u, appended.RawPosition); + + Assert.Equal(4u, appended.Depth); + + Assert.Equal(0xA0u, appended.LocalPosition); + } +} \ No newline at end of file diff --git a/Valour/Tests/Valour.Tests.csproj b/Valour/Tests/Valour.Tests.csproj new file mode 100644 index 000000000..ac101a565 --- /dev/null +++ b/Valour/Tests/Valour.Tests.csproj @@ -0,0 +1,38 @@ + + + + net9.0 + True + enable + enable + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + +