diff --git a/.editorconfig b/.editorconfig index e67a88a445..29780e45e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,7 +17,7 @@ indent_size = 4 trim_trailing_whitespace = true #license header -file_header_template = Copyright (c) ppy Pty Ltd , sim1222 . Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text. +file_header_template = Copyright (c) sim1222 . Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text. #Roslyn naming styles diff --git a/.idea/.idea.osu/.idea/aws.xml b/.idea/.idea.osu/.idea/aws.xml deleted file mode 100644 index b63b642cfb..0000000000 --- a/.idea/.idea.osu/.idea/aws.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index eebd079f68..aacf8211c4 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -65,8 +65,8 @@ namespace osu.Android Debug.Assert(Window != null); - Window.AddFlags(WindowManagerFlags.Fullscreen); - Window.AddFlags(WindowManagerFlags.KeepScreenOn); + // Window.AddFlags(WindowManagerFlags.Fullscreen); + // Window.AddFlags(WindowManagerFlags.KeepScreenOn); Debug.Assert(WindowManager?.DefaultDisplay != null); Debug.Assert(Resources?.DisplayMetrics != null); @@ -76,7 +76,7 @@ namespace osu.Android float smallestWidthDp = Math.Min(displaySize.X, displaySize.Y) / Resources.DisplayMetrics.Density; bool isTablet = smallestWidthDp >= 600f; - RequestedOrientation = DefaultOrientation = isTablet ? ScreenOrientation.FullUser : ScreenOrientation.SensorLandscape; + RequestedOrientation = DefaultOrientation = isTablet ? ScreenOrientation.FullUser : ScreenOrientation.User; } protected override void OnNewIntent(Intent intent) => handleIntent(intent); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 69e7dee1a5..5f2acb964f 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -163,6 +163,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f); + + SetDefault(OsuSetting.MisskeyToken, string.Empty); } public IDictionary GetLoggableState() => @@ -358,5 +360,9 @@ namespace osu.Game.Configuration DiscordRichPresence, AutomaticallyDownloadWhenSpectating, ShowOnlineExplicitContent, + + //Misskey + + MisskeyToken, } } diff --git a/osu.Game/Online/MisskeyAPI/APIAccess.cs b/osu.Game/Online/MisskeyAPI/APIAccess.cs new file mode 100644 index 0000000000..786a58d3a8 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/APIAccess.cs @@ -0,0 +1,483 @@ +// Copyright (c) ppy Pty Ltd , sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ExceptionExtensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Configuration; +using osu.Game.Online.MisskeyAPI.Requests; +using osu.Game.Online.MisskeyAPI.Requests.Responses; +using osu.Game.Users; + +namespace osu.Game.Online.MisskeyAPI +{ + public class APIAccess : Component, IAPIProvider + { + private readonly OsuConfigManager config; + + private readonly string versionHash; + + private readonly Auth authentication; + + private readonly Queue queue = new Queue(); + + public string APIEndpointUrl { get; } + + public int APIVersion => 20220217; // We may want to pull this from the game version eventually. + + public Exception LastLoginError { get; private set; } + + public string ProvidedUsername { get; private set; } + + private string password; + + public IBindable LocalUser => localUser; + //public IBindableList Friends => friends; + public IBindable Activity => activity; + + private Bindable localUser { get; } = new Bindable(createGuestUser()); + + //private BindableList friends { get; } = new BindableList(); + + private Bindable activity { get; } = new Bindable(); + + protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); + + private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); + + private readonly Logger log; + + public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) + { + this.config = config; + this.versionHash = versionHash; + + APIEndpointUrl = endpointConfiguration.APIEndpointUrl; + + authentication = new Auth(APIEndpointUrl); + log = Logger.GetLogger(LoggingTarget.Network); + + ProvidedUsername = config.Get(OsuSetting.Username); + + authentication.TokenString = config.Get(OsuSetting.MisskeyToken); + authentication.Token.ValueChanged += onTokenChanged; + + var thread = new Thread(run) + { + Name = "APIAccess", + IsBackground = true + }; + + thread.Start(); + } + + private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.MisskeyToken, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); + + internal new void Schedule(Action action) => base.Schedule(action); + + public string AccessToken => authentication.RequestAccessToken(); + + /// + /// Number of consecutive requests which failed due to network issues. + /// + private int failureCount; + + private void run() + { + while (!cancellationToken.IsCancellationRequested) + { + switch (State.Value) + { + case APIState.Failing: + //todo: replace this with a ping request. + log.Add(@"In a failing state, waiting a bit before we try again..."); + Thread.Sleep(5000); + + if (!IsLoggedIn) goto case APIState.Connecting; + + if (queue.Count == 0) + { + log.Add(@"Queueing a ping request"); + Queue(new Requests.I()); + } + + break; + + case APIState.Offline: + case APIState.Connecting: + // work to restore a connection... + if (!HasLogin) + { + state.Value = APIState.Offline; + Thread.Sleep(50); + continue; + } + + state.Value = APIState.Connecting; + + // save the username at this point, if the user requested for it to be. + config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); + + if (!authentication.HasValidAccessToken) + { + LastLoginError = null; + + try + { + authentication.AuthenticateWithLogin(ProvidedUsername, password); + } + catch (Exception e) + { + //todo: this fails even on network-related issues. we should probably handle those differently. + LastLoginError = e; + log.Add(@"Login failed!"); + password = null; + authentication.Clear(); + continue; + } + } + + var userReq = new Requests.I(); + + userReq.Failure += ex => + { + if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + failConnectionProcess(); + }; + userReq.Success += u => + { + localUser.Value = u; + + // todo: save/pull from settings + // localUser.Value.Status.Value = new UserStatusOnline(); + + failureCount = 0; + }; + + if (!handleRequest(userReq)) + { + failConnectionProcess(); + continue; + } + + // getting user's friends is considered part of the connection process. + // var friendsReq = new GetFriendsRequest(); + // + // friendsReq.Failure += _ => failConnectionProcess(); + // friendsReq.Success += res => + // { + // friends.AddRange(res); + // + // //we're connected! + // state.Value = APIState.Online; + // }; + // + // if (!handleRequest(friendsReq)) + // { + // failConnectionProcess(); + // continue; + // } + + // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. + // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests + // before actually going online. + while (State.Value > APIState.Offline && State.Value < APIState.Online) + Thread.Sleep(500); + + break; + } + + // hard bail if we can't get a valid access token. + if (authentication.RequestAccessToken() == null) + { + Logout(); + continue; + } + + while (true) + { + APIRequest req; + + lock (queue) + { + if (queue.Count == 0) break; + + req = queue.Dequeue(); + } + + handleRequest(req); + } + + Thread.Sleep(50); + } + + void failConnectionProcess() + { + // if something went wrong during the connection process, we want to reset the state (but only if still connecting). + if (State.Value == APIState.Connecting) + state.Value = APIState.Failing; + } + } + + public void Perform(APIRequest request) + { + try + { + request.Perform(this); + } + catch (Exception e) + { + // todo: fix exception handling + request.Fail(e); + } + } + + public Task PerformAsync(APIRequest request) => + Task.Factory.StartNew(() => Perform(request), TaskCreationOptions.LongRunning); + + public void Login(string username, string password) + { + Debug.Assert(State.Value == APIState.Offline); + + ProvidedUsername = username; + this.password = password; + } + + // public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => + // new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); + + public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true) + { + throw new NotImplementedException(); + } + + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) + { + Debug.Assert(State.Value == APIState.Offline); + + var req = new RegistrationRequest + { + Url = $@"{APIEndpointUrl}/users", + Method = HttpMethod.Post, + Username = username, + Email = email, + Password = password + }; + + try + { + req.Perform(); + } + catch (Exception e) + { + try + { + return JObject.Parse(req.GetResponseString().AsNonNull()).SelectToken("form_error", true).AsNonNull().ToObject(); + } + catch + { + // if we couldn't deserialize the error message let's throw the original exception outwards. + e.Rethrow(); + } + } + + return null; + } + + /// + /// Handle a single API request. + /// Ensures all exceptions are caught and dealt with correctly. + /// + /// The request. + /// true if the request succeeded. + private bool handleRequest(APIRequest req) + { + try + { + req.Perform(this); + + if (req.CompletionState != APIRequestCompletionState.Completed) + return false; + + // we could still be in initialisation, at which point we don't want to say we're Online yet. + if (IsLoggedIn) state.Value = APIState.Online; + failureCount = 0; + return true; + } + catch (HttpRequestException re) + { + log.Add($"{nameof(HttpRequestException)} while performing request {req}: {re.Message}"); + handleFailure(); + return false; + } + catch (SocketException se) + { + log.Add($"{nameof(SocketException)} while performing request {req}: {se.Message}"); + handleFailure(); + return false; + } + catch (WebException we) + { + log.Add($"{nameof(WebException)} while performing request {req}: {we.Message}"); + handleWebException(we); + return false; + } + catch (Exception ex) + { + Logger.Error(ex, "Error occurred while handling an API request."); + return false; + } + } + + private readonly Bindable state = new Bindable(); + + /// + /// The current connectivity state of the API. + /// + public IBindable State => state; + + private void handleWebException(WebException we) + { + HttpStatusCode statusCode = (we.Response as HttpWebResponse)?.StatusCode + ?? (we.Status == WebExceptionStatus.UnknownError ? HttpStatusCode.NotAcceptable : HttpStatusCode.RequestTimeout); + + // special cases for un-typed but useful message responses. + switch (we.Message) + { + case "Unauthorized": + case "Forbidden": + statusCode = HttpStatusCode.Unauthorized; + break; + } + + switch (statusCode) + { + case HttpStatusCode.Unauthorized: + Logout(); + break; + + case HttpStatusCode.RequestTimeout: + handleFailure(); + break; + } + } + + private void handleFailure() + { + failureCount++; + log.Add($@"API failure count is now {failureCount}"); + + if (failureCount >= 3 && State.Value == APIState.Online) + { + state.Value = APIState.Failing; + flushQueue(); + } + } + + public bool IsLoggedIn => localUser.Value.Id != "system" && localUser.Value.Id != "guest"; // TODO: should this also be true if attempting to connect? + + public void Queue(APIRequest request) + { + lock (queue) + { + if (state.Value == APIState.Offline) + { + request.Fail(new WebException(@"User not logged in")); + return; + } + + queue.Enqueue(request); + } + } + + private void flushQueue(bool failOldRequests = true) + { + lock (queue) + { + var oldQueueRequests = queue.ToArray(); + + queue.Clear(); + + if (failOldRequests) + { + foreach (var req in oldQueueRequests) + req.Fail(new WebException($@"Request failed from flush operation (state {state.Value})")); + } + } + } + + public void Logout() + { + password = null; + authentication.Clear(); + + // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present + Schedule(() => + { + localUser.Value = createGuestUser(); + // friends.Clear(); + }); + + state.Value = APIState.Offline; + flushQueue(); + } + + private static Requests.Responses.I createGuestUser() => new GuestUser(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + flushQueue(); + cancellationToken.Cancel(); + } + } + + internal class GuestUser : Requests.Responses.I + { + public GuestUser() + { + Username = @"Guest"; + Id = SYSTEM_USER_ID; + } + } + + public enum APIState + { + /// + /// We cannot login (not enough credentials). + /// + Offline, + + /// + /// We are having connectivity issues. + /// + Failing, + + /// + /// We are in the process of (re-)connecting. + /// + Connecting, + + /// + /// We are online. + /// + Online + } +} diff --git a/osu.Game/Online/MisskeyAPI/Login.cs b/osu.Game/Online/MisskeyAPI/APIException.cs similarity index 54% rename from osu.Game/Online/MisskeyAPI/Login.cs rename to osu.Game/Online/MisskeyAPI/APIException.cs index d3ebac0172..91ab00e004 100644 --- a/osu.Game/Online/MisskeyAPI/Login.cs +++ b/osu.Game/Online/MisskeyAPI/APIException.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; + namespace osu.Game.Online.MisskeyAPI { - public class Login + public class APIException : InvalidOperationException { - public void login(string username, string password) + public APIException(string message, Exception innerException) + : base(message, innerException) { - } } } diff --git a/osu.Game/Online/MisskeyAPI/APIRequest.cs b/osu.Game/Online/MisskeyAPI/APIRequest.cs new file mode 100644 index 0000000000..c79e76f6c4 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/APIRequest.cs @@ -0,0 +1,236 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using JetBrains.Annotations; +using Newtonsoft.Json; +using osu.Framework.IO.Network; +using osu.Framework.Logging; +using osu.Game.Online.MisskeyAPI.Requests.Responses; + +namespace osu.Game.Online.MisskeyAPI +{ + /// + /// An API request with a well-defined response type. + /// + /// Type of the response (used for deserialisation). + public abstract class APIRequest : APIRequest where T : class + { + protected override WebRequest CreateWebRequest() => new JsonWebRequest(Uri); + + /// + /// The deserialised response object. May be null if the request or deserialisation failed. + /// + [CanBeNull] + public T Response { get; private set; } + + /// + /// Invoked on successful completion of an API request. + /// This will be scheduled to the API's internal scheduler (run on update thread automatically). + /// + public new event APISuccessHandler Success; + + protected APIRequest() + { + base.Success += () => Success?.Invoke(Response); + } + + protected override void PostProcess() + { + base.PostProcess(); + + if (WebRequest != null) + { + Response = ((JsonWebRequest)WebRequest).ResponseObject; + Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network); + } + } + + internal void TriggerSuccess(T result) + { + if (Response != null) + throw new InvalidOperationException("Attempted to trigger success more than once"); + + Response = result; + + TriggerSuccess(); + } + } + + /// + /// AN API request with no specified response type. + /// + public abstract class APIRequest + { + protected abstract string Target { get; } + + protected virtual WebRequest CreateWebRequest() => new WebRequest(Uri); + + protected virtual string Uri => $@"{API.APIEndpointUrl}/api/{Target}"; + + protected APIAccess API; + protected WebRequest WebRequest; + + //// + //// The currently logged in user. Note that this will only be populated during . + //// + // protected APIUser User { get; private set; } + + /// + /// Invoked on successful completion of an API request. + /// This will be scheduled to the API's internal scheduler (run on update thread automatically). + /// + public event APISuccessHandler Success; + + /// + /// Invoked on failure to complete an API request. + /// This will be scheduled to the API's internal scheduler (run on update thread automatically). + /// + public event APIFailureHandler Failure; + + private readonly object completionStateLock = new object(); + + /// + /// The state of this request, from an outside perspective. + /// This is used to ensure correct notification events are fired. + /// + public APIRequestCompletionState CompletionState { get; private set; } + + public void Perform(IAPIProvider api) + { + if (!(api is APIAccess apiAccess)) + { + Fail(new NotSupportedException($"A {nameof(APIAccess)} is required to perform requests.")); + return; + } + + API = apiAccess; + //User = apiAccess.LocalUser.Value; + + if (isFailing) return; + + WebRequest = CreateWebRequest(); + WebRequest.Failed += Fail; + WebRequest.AllowRetryOnTimeout = false; + + WebRequest.AddHeader("x-api-version", API.APIVersion.ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrEmpty(API.AccessToken)) + // WebRequest.AddRaw("i", $"{API.AccessToken}"); + + if (isFailing) return; + + Logger.Log($@"Performing request {this}", LoggingTarget.Network); + WebRequest.Perform(); + + if (isFailing) return; + + PostProcess(); + + TriggerSuccess(); + } + + /// + /// Perform any post-processing actions after a successful request. + /// + protected virtual void PostProcess() + { + } + + internal void TriggerSuccess() + { + lock (completionStateLock) + { + if (CompletionState != APIRequestCompletionState.Waiting) + return; + + CompletionState = APIRequestCompletionState.Completed; + } + + if (API == null) + Success?.Invoke(); + else + API.Schedule(() => Success?.Invoke()); + } + + internal void TriggerFailure(Exception e) + { + lock (completionStateLock) + { + if (CompletionState != APIRequestCompletionState.Waiting) + return; + + CompletionState = APIRequestCompletionState.Failed; + } + + if (API == null) + Failure?.Invoke(e); + else + API.Schedule(() => Failure?.Invoke(e)); + } + + public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); + + public void Fail(Exception e) + { + lock (completionStateLock) + { + if (CompletionState != APIRequestCompletionState.Waiting) + return; + + WebRequest?.Abort(); + + // in the case of a cancellation we don't care about whether there's an error in the response. + if (!(e is OperationCanceledException)) + { + string responseString = WebRequest?.GetResponseString(); + + // naive check whether there's an error in the response to avoid unnecessary JSON deserialisation. + if (!string.IsNullOrEmpty(responseString) && responseString.Contains(@"""error""")) + { + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(responseString); + if (error != null) + e = new APIException(error.ErrorMessage, e); + } + catch + { + } + } + } + + Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); + TriggerFailure(e); + } + } + + /// + /// Whether this request is in a failing or failed state. + /// + private bool isFailing + { + get + { + lock (completionStateLock) + return CompletionState == APIRequestCompletionState.Failed; + } + } + + private class DisplayableError + { + [JsonProperty("error")] + public string ErrorMessage { get; set; } + } + } + + public delegate void APIFailureHandler(Exception e); + + public delegate void APISuccessHandler(); + + public delegate void APIProgressHandler(long current, long total); + + public delegate void APISuccessHandler(T content); +} diff --git a/osu.Game/Online/MisskeyAPI/APIRequestCompletionState.cs b/osu.Game/Online/MisskeyAPI/APIRequestCompletionState.cs new file mode 100644 index 0000000000..716beb0b5b --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/APIRequestCompletionState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.MisskeyAPI +{ + public enum APIRequestCompletionState + { + /// + /// Not yet run or currently waiting on response. + /// + Waiting, + + /// + /// Ran to completion. + /// + Completed, + + /// + /// Cancelled or failed due to error. + /// + Failed + } +} diff --git a/osu.Game/Online/MisskeyAPI/Auth.cs b/osu.Game/Online/MisskeyAPI/Auth.cs new file mode 100644 index 0000000000..1867cd0d15 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Auth.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Net.Http; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.MisskeyAPI +{ + public class Auth + { + // private readonly string clientId; + private readonly string endpoint; + + public readonly Bindable Token = new Bindable(); + + public string TokenString + { + get => Token.Value?.ToString(); + set => Token.Value = string.IsNullOrEmpty(value) ? null : AuthToken.Parse(value); + } + + internal Auth( + // string clientId, + string endpoint) + { + // Debug.Assert(clientId != null); + Debug.Assert(endpoint != null); + + // this.clientId = clientId; + this.endpoint = endpoint; + } + + internal void AuthenticateWithLogin(string username, string password) + { + if (string.IsNullOrEmpty(username)) throw new ArgumentException("Missing username."); + if (string.IsNullOrEmpty(password)) throw new ArgumentException("Missing password."); + + var accessTokenRequest = new AccessTokenRequestPassword(username, password) + { + Url = $@"{endpoint}/oauth/token", + Method = HttpMethod.Post, + // ClientId = clientId + }; + + using (accessTokenRequest) + { + try + { + accessTokenRequest.Perform(); + } + catch (Exception ex) + { + Token.Value = null; + + var throwableException = ex; + + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(accessTokenRequest.GetResponseString() ?? string.Empty); + if (error != null) + throwableException = new APIException(error.UserDisplayableError, ex); + } + catch + { + } + + throw throwableException; + } + + Token.Value = accessTokenRequest.ResponseObject; + } + } + + internal bool AuthenticateWithRefresh(string refresh) + { + try + { + var refreshRequest = new AccessTokenRequestRefresh(refresh) + { + Url = $@"{endpoint}/oauth/token", + Method = HttpMethod.Post, + // ClientId = clientId + }; + + using (refreshRequest) + { + refreshRequest.Perform(); + + Token.Value = refreshRequest.ResponseObject; + return true; + } + } + catch + { + //todo: potentially only kill the refresh token on certain exception types. + Token.Value = null; + return false; + } + } + + private static readonly object access_token_retrieval_lock = new object(); + + /// + /// Should be run before any API request to make sure we have a valid key. + /// + private bool ensureAccessToken() + { + // if we already have a valid access token, let's use it. + if (accessTokenValid) return true; + + // we want to ensure only a single authentication update is happening at once. + lock (access_token_retrieval_lock) + { + // re-check if valid, in case another request completed and revalidated our access. + if (accessTokenValid) return true; + + // if not, let's try using our refresh token to request a new access token. + if (!string.IsNullOrEmpty(Token.Value?.RefreshToken)) + // ReSharper disable once PossibleNullReferenceException + AuthenticateWithRefresh(Token.Value.RefreshToken); + + return accessTokenValid; + } + } + + private bool accessTokenValid => Token.Value?.IsValid ?? false; + + internal bool HasValidAccessToken => RequestAccessToken() != null; + + internal string RequestAccessToken() + { + if (!ensureAccessToken()) return null; + + return Token.Value.AccessToken; + } + + internal void Clear() + { + Token.Value = null; + } + + private class AccessTokenRequestRefresh : AccessTokenRequest + { + internal readonly string RefreshToken; + + internal AccessTokenRequestRefresh(string refreshToken) + { + RefreshToken = refreshToken; + GrantType = @"refresh_token"; + } + + protected override void PrePerform() + { + AddParameter("refresh_token", RefreshToken); + + base.PrePerform(); + } + } + + private class AccessTokenRequestPassword : AccessTokenRequest + { + internal readonly string Username; + internal readonly string Password; + + internal AccessTokenRequestPassword(string username, string password) + { + Username = username; + Password = password; + GrantType = @"password"; + } + + protected override void PrePerform() + { + AddParameter("username", Username); + AddParameter("password", Password); + + base.PrePerform(); + } + } + + private class AccessTokenRequest : JsonWebRequest + { + protected string GrantType; + + // internal string ClientId; + + protected override void PrePerform() + { + AddParameter("grant_type", GrantType); + // AddParameter("client_id", ClientId); + AddParameter("scope", "*"); + + base.PrePerform(); + } + } + + private class OAuthError + { + public string UserDisplayableError => !string.IsNullOrEmpty(Hint) ? Hint : ErrorIdentifier; + + [JsonProperty("error")] + public string ErrorIdentifier { get; set; } + + [JsonProperty("hint")] + public string Hint { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + } + } +} diff --git a/osu.Game/Online/MisskeyAPI/AuthToken.cs b/osu.Game/Online/MisskeyAPI/AuthToken.cs new file mode 100644 index 0000000000..89ef9a768e --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/AuthToken.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using Newtonsoft.Json; + +namespace osu.Game.Online.MisskeyAPI +{ + [Serializable] + public class AuthToken + { + /// + /// OAuth 2.0 access token. + /// + [JsonProperty(@"access_token")] + public string AccessToken; + + [JsonProperty(@"expires_in")] + public long ExpiresIn + { + get => AccessTokenExpiry - DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + set => AccessTokenExpiry = DateTimeOffset.Now.AddSeconds(value).ToUnixTimeSeconds(); + } + + public bool IsValid => !string.IsNullOrEmpty(AccessToken) && ExpiresIn > 30; + + public long AccessTokenExpiry; + + /// + /// OAuth 2.0 refresh token. + /// + [JsonProperty(@"refresh_token")] + public string RefreshToken; + + public override string ToString() => $@"{AccessToken}|{AccessTokenExpiry.ToString(NumberFormatInfo.InvariantInfo)}|{RefreshToken}"; + + public static AuthToken Parse(string value) + { + try + { + string[] parts = value.Split('|'); + return new AuthToken + { + AccessToken = parts[0], + AccessTokenExpiry = long.Parse(parts[1], NumberFormatInfo.InvariantInfo), + RefreshToken = parts[2] + }; + } + catch + { + } + + return null; + } + } +} diff --git a/osu.Game/Online/MisskeyAPI/DefaultEndpointConfigration.cs b/osu.Game/Online/MisskeyAPI/DefaultEndpointConfigration.cs new file mode 100644 index 0000000000..725bd68ba7 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/DefaultEndpointConfigration.cs @@ -0,0 +1,13 @@ +// Copyright (c) sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.MisskeyAPI +{ + public class DefaultEndpointConfigration : EndpointConfiguration + { + public DefaultEndpointConfigration() + { + APIEndpointUrl = "https://misskey.io"; + } + } +} diff --git a/osu.Game/Online/MisskeyAPI/EndpointConfiguration.cs b/osu.Game/Online/MisskeyAPI/EndpointConfiguration.cs new file mode 100644 index 0000000000..5451e388af --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/EndpointConfiguration.cs @@ -0,0 +1,13 @@ +// Copyright (c) sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.MisskeyAPI +{ + public class EndpointConfiguration + { + /// + /// The endpoint for the main (osu-web) API. + /// + public string APIEndpointUrl { get; set; } + } +} diff --git a/osu.Game/Online/MisskeyAPI/IAPIProvider.cs b/osu.Game/Online/MisskeyAPI/IAPIProvider.cs index 6d1f6c4001..b0d4d80a22 100644 --- a/osu.Game/Online/MisskeyAPI/IAPIProvider.cs +++ b/osu.Game/Online/MisskeyAPI/IAPIProvider.cs @@ -1,15 +1,123 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + +using System; +using System.Threading.Tasks; +using osu.Framework.Bindables; +using osu.Game.Online.MisskeyAPI.Requests.Responses; +using osu.Game.Users; + namespace osu.Game.Online.MisskeyAPI { public interface IAPIProvider { + /// + /// The local user. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// + IBindable LocalUser { get; } + + /// + /// The current user's activity. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// + IBindable Activity { get; } + + /// + /// Retrieve the OAuth access token. + /// + string AccessToken { get; } + + /// + /// Returns whether the local user is logged in. + /// + bool IsLoggedIn { get; } + + /// + /// The last username provided by the end-user. + /// May not be authenticated. + /// + string ProvidedUsername { get; } + + /// + /// The URL endpoint for this API. Does not include a trailing slash. + /// + string APIEndpointUrl { get; } + + // /// + // /// The root URL of of the website, excluding the trailing slash. + // /// + // string WebsiteRootUrl { get; } + + /// + /// The version of the API. + /// + int APIVersion { get; } + + /// + /// The last login error that occurred, if any. + /// + Exception? LastLoginError { get; } + + /// + /// The current connection state of the API. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// + IBindable State { get; } + + /// + /// Queue a new request. + /// + /// The request to perform. + void Queue(APIRequest request); + + /// + /// Perform a request immediately, bypassing any API state checks. + /// + /// + /// Can be used to run requests as a guest user. + /// + /// The request to perform. + void Perform(APIRequest request); + + /// + /// Perform a request immediately, bypassing any API state checks. + /// + /// + /// Can be used to run requests as a guest user. + /// + /// The request to perform. + Task PerformAsync(APIRequest request); + /// /// Attempt to login using the provided credentials. This is a non-blocking operation. /// /// The user's username. /// The user's password. void Login(string username, string password); + + /// + /// Log out the current user. + /// + void Logout(); + + /// + /// Constructs a new . May be null if not supported. + /// + /// The name of the client this connector connects for, used for logging. + /// The endpoint to the hub. + /// Whether to use MessagePack for serialisation if available on this platform. + IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); + + /// + /// Create a new user account. This is a blocking operation. + /// + /// The email to create the account with. + /// The username to create the account with. + /// The password to create the account with. + /// Any errors encoutnered during account creation. + RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password); } } diff --git a/osu.Game/Online/MisskeyAPI/RegistrationRequest.cs b/osu.Game/Online/MisskeyAPI/RegistrationRequest.cs new file mode 100644 index 0000000000..3295a2aedf --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/RegistrationRequest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.MisskeyAPI +{ + public class RegistrationRequest : WebRequest + { + internal string Username; + internal string Email; + internal string Password; + + protected override void PrePerform() + { + AddParameter("user[username]", Username); + AddParameter("user[user_email]", Email); + AddParameter("user[password]", Password); + + base.PrePerform(); + } + + public class RegistrationRequestErrors + { + public UserErrors User; + + public class UserErrors + { + [JsonProperty("username")] + public string[] Username; + + [JsonProperty("user_email")] + public string[] Email; + + [JsonProperty("password")] + public string[] Password; + } + } + } +} diff --git a/osu.Game/Online/MisskeyAPI/Requests/I/I.cs b/osu.Game/Online/MisskeyAPI/Requests/I/I.cs new file mode 100644 index 0000000000..82f6afff23 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Requests/I/I.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd , sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.MisskeyAPI.Requests +{ + public class I : APIRequest + { + public I() + { + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + return req; + } + + protected override string Target => @"meta"; + } +} diff --git a/osu.Game/Online/MisskeyAPI/Requests/Meta.cs b/osu.Game/Online/MisskeyAPI/Requests/Meta.cs new file mode 100644 index 0000000000..ef30ca6db1 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Requests/Meta.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd , sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.MisskeyAPI.Requests +{ + public class Meta : APIRequest + { + public string instanceUrl { get; } + + public Meta(string instanceUrl) + { + this.instanceUrl = instanceUrl; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + return req; + } + + public string APIEndpointUrl => $"https://{instanceUrl}"; + protected override string Target => @"meta"; + } +} diff --git a/osu.Game/Online/MisskeyAPI/Requests/Responses/UsersShow.cs b/osu.Game/Online/MisskeyAPI/Requests/Responses/UsersShow.cs deleted file mode 100644 index 52d3980e6c..0000000000 --- a/osu.Game/Online/MisskeyAPI/Requests/Responses/UsersShow.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using JetBrains.Annotations; - -namespace osu.Game.Online.MisskeyAPI.Requests.Responses -{ - public class UsersShow - { - public partial class Emojis - { - [CanBeNull] - public List Emoji { get; set; } - } - - public partial class Emoji - { - public string Name { get; set; } - public Uri Url { get; set; } - } - - [JsonProperty("id")] - public string Id; - - [JsonProperty("name")] - [CanBeNull] - public string Name; - - [JsonProperty("username")] - public string Username; - - [JsonProperty("host")] - [CanBeNull] - public string Host; - - [JsonProperty("avatarUrl")] - [CanBeNull] - public Uri AvatarUrl; - - [JsonProperty("avatarBlurhash")] - [CanBeNull] - public string AvatarBlurhash; - - [JsonProperty("avatarColor")] - [CanBeNull] - public string AvatarColor; - - [JsonProperty("isAdmin")] - public bool isAdmin; - - [JsonProperty("isModerator")] - public bool isModerator; - - [JsonProperty("isBot")] - public bool isBot; - - [JsonProperty("isCat")] - public bool isCat; - - [JsonProperty("emojis")] - public Emojis emojis; - - [JsonProperty("onlineStatus")] - [CanBeNull] - public string onlineStatus; - - - - - } -} diff --git a/osu.Game/Online/MisskeyAPI/Responses/Emoji.cs b/osu.Game/Online/MisskeyAPI/Responses/Emoji.cs new file mode 100644 index 0000000000..44c60f8cae --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Responses/Emoji.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.MisskeyAPI.Requests.Responses +{ + public class Emoji : IEquatable + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("aliases")] + public List Aliases; + + [JsonProperty("category")] + public string Category; + + [JsonProperty("host")] + public string Host; + + [JsonProperty("url")] + public string Url; + + public bool Equals(Emoji other) => Id == other?.Id; + } +} diff --git a/osu.Game/Online/MisskeyAPI/Responses/I/I.cs b/osu.Game/Online/MisskeyAPI/Responses/I/I.cs new file mode 100644 index 0000000000..c850074567 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Responses/I/I.cs @@ -0,0 +1,574 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; + + +namespace osu.Game.Online.MisskeyAPI.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class I //: IEquatable + { + /// + /// A user ID which can be used to represent any system user which is not attached to a user profile. + /// + public const string SYSTEM_USER_ID = "system"; + + // Root myDeserializedClass = JsonConvert.DeserializeObject(myJsonResponse); + public class Channel + { + } + + public class Emoji + { + [JsonProperty("name")] + public string Name; + + [JsonProperty("url")] + public string Url; + } + + public class Field + { + [JsonProperty("name")] + public string Name; + + [JsonProperty("value")] + public string Value; + } + + public class File + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("createdAt")] + public DateTime CreatedAt; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("type")] + public string Type; + + [JsonProperty("md5")] + public string Md5; + + [JsonProperty("size")] + public int Size; + + [JsonProperty("isSensitive")] + public bool IsSensitive; + + [JsonProperty("blurhash")] + public string Blurhash; + + [JsonProperty("properties")] + public Properties Properties; + + [JsonProperty("url")] + public string Url; + + [JsonProperty("thumbnailUrl")] + public string ThumbnailUrl; + + [JsonProperty("comment")] + public string Comment; + + [JsonProperty("folderId")] + public string FolderId; + + [JsonProperty("folder")] + [CanBeNull] + public Folder Folder; + + [JsonProperty("userId")] + public string UserId; + + [JsonProperty("user")] + [CanBeNull] + public User User; + } + + public class Folder + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("createdAt")] + public DateTime CreatedAt; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("foldersCount")] + public int? FoldersCount; + + [JsonProperty("filesCount")] + public int? FilesCount; + + [JsonProperty("parentId")] + public string ParentId; + + [JsonProperty("parent")] + [CanBeNull] + public Parent Parent; + } + + public class Integration + { + } + + public class MyReaction + { + } + + public class Parent + { + } + + public class PinnedNote + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("createdAt")] + public DateTime CreatedAt; + + [JsonProperty("text")] + public string Text; + + [JsonProperty("cw")] + [CanBeNull] + public string Cw; + + [JsonProperty("userId")] + public string UserId; + + [JsonProperty("user")] + public User User; + + [JsonProperty("replyId")] + [CanBeNull] + public string ReplyId; + + [JsonProperty("renoteId")] + [CanBeNull] + public string RenoteId; + + [JsonProperty("reply")] + [CanBeNull] + public Reply Reply; + + [JsonProperty("renote")] + [CanBeNull] + public Renote Renote; + + [JsonProperty("isHidden")] + public bool? IsHidden; + + [JsonProperty("visibility")] + public string Visibility; + + [JsonProperty("mentions")] + [CanBeNull] + public List Mentions; + + [JsonProperty("visibleUserIds")] + [CanBeNull] + public List VisibleUserIds; + + [JsonProperty("fileIds")] + [CanBeNull] + public List FileIds; + + [JsonProperty("files")] + [CanBeNull] + public List Files; + + [JsonProperty("tags")] + [CanBeNull] + public List Tags; + + [JsonProperty("poll")] + [CanBeNull] + public Poll Poll; + + [JsonProperty("channelId")] + [CanBeNull] + public string ChannelId; + + [JsonProperty("channel")] + [CanBeNull] + public Channel Channel; + + [JsonProperty("localOnly")] + public bool? LocalOnly; + + [JsonProperty("emojis")] + public List Emojis; + + [JsonProperty("reactions")] + public Reactions Reactions; + + [JsonProperty("renoteCount")] + public int RenoteCount; + + [JsonProperty("repliesCount")] + public int RepliesCount; + + [JsonProperty("uri")] + [CanBeNull] + public string Uri; + + [JsonProperty("url")] + [CanBeNull] + public string Url; + + [JsonProperty("myReaction")] + [CanBeNull] + public MyReaction MyReaction; + } + + public class PinnedPages + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("createdAt")] + public DateTime CreatedAt; + + [JsonProperty("updatedAt")] + public DateTime UpdatedAt; + + [JsonProperty("title")] + public string Title; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("summary")] + public string Summary; + + [JsonProperty("content")] + public List Content; + + [JsonProperty("variables")] + public List Variables; + + [JsonProperty("userId")] + public string UserId; + + [JsonProperty("user")] + public User User; + } + + public class Poll + { + } + + public class Properties + { + [JsonProperty("width")] + public int? Width; + + [JsonProperty("height")] + public int? Height; + + [JsonProperty("orientation")] + public int? Orientation; + + [JsonProperty("avgColor")] + [CanBeNull] + public string AvgColor; + } + + public class Reactions + { + } + + public class Renote + { + } + + public class Reply + { + } + + [JsonProperty("id")] + public string Id { get; set; } = "guest"; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("username")] + public string Username; + + [JsonProperty("host")] + public string Host; + + [JsonProperty("avatarUrl")] + public string AvatarUrl; + + [JsonProperty("avatarBlurhash")] + public object AvatarBlurhash; + + [JsonProperty("avatarColor")] + public object AvatarColor; + + [JsonProperty("isAdmin")] + public bool? IsAdmin; + + [JsonProperty("isModerator")] + public bool? IsModerator; + + [JsonProperty("isBot")] + public bool? IsBot; + + [JsonProperty("isCat")] + public bool? IsCat; + + [JsonProperty("emojis")] + public List Emojis; + + [JsonProperty("onlineStatus")] + public string OnlineStatus; + + [JsonProperty("url")] + public string Url; + + [JsonProperty("uri")] + public string Uri; + + [JsonProperty("createdAt")] + public DateTime CreatedAt; + + [JsonProperty("updatedAt")] + public DateTime UpdatedAt; + + [JsonProperty("lastFetchedAt")] + public DateTime LastFetchedAt; + + [JsonProperty("bannerUrl")] + public string BannerUrl; + + [JsonProperty("bannerBlurhash")] + public object BannerBlurhash; + + [JsonProperty("bannerColor")] + public object BannerColor; + + [JsonProperty("isLocked")] + public bool IsLocked; + + [JsonProperty("isSilenced")] + public bool IsSilenced; + + [JsonProperty("isSuspended")] + public bool IsSuspended; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("location")] + public string Location; + + [JsonProperty("birthday")] + public string Birthday; + + [JsonProperty("lang")] + public string Lang; + + [JsonProperty("fields")] + public List Fields; + + [JsonProperty("followersCount")] + public int FollowersCount; + + [JsonProperty("followingCount")] + public int FollowingCount; + + [JsonProperty("notesCount")] + public int NotesCount; + + [JsonProperty("pinnedNoteIds")] + public List PinnedNoteIds; + + [JsonProperty("pinnedNotes")] + public List PinnedNotes; + + [JsonProperty("pinnedPageId")] + public string PinnedPageId; + + [JsonProperty("pinnedPage")] + public PinnedPages PinnedPage; + + [JsonProperty("publicReactions")] + public bool PublicReactions; + + [JsonProperty("twoFactorEnabled")] + public bool TwoFactorEnabled; + + [JsonProperty("usePasswordLessLogin")] + public bool UsePasswordLessLogin; + + [JsonProperty("securityKeys")] + public bool SecurityKeys; + + [JsonProperty("isFollowing")] + public bool? IsFollowing; + + [JsonProperty("isFollowed")] + public bool? IsFollowed; + + [JsonProperty("hasPendingFollowRequestFromYou")] + public bool? HasPendingFollowRequestFromYou; + + [JsonProperty("hasPendingFollowRequestToYou")] + public bool? HasPendingFollowRequestToYou; + + [JsonProperty("isBlocking")] + public bool? IsBlocking; + + [JsonProperty("isBlocked")] + public bool? IsBlocked; + + [JsonProperty("isMuted")] + public bool? IsMuted; + + [JsonProperty("avatarId")] + public string AvatarId; + + [JsonProperty("bannerId")] + public string BannerId; + + [JsonProperty("injectFeaturedNote")] + public bool InjectFeaturedNote; + + [JsonProperty("receiveAnnouncementEmail")] + public bool ReceiveAnnouncementEmail; + + [JsonProperty("alwaysMarkNsfw")] + public bool AlwaysMarkNsfw; + + [JsonProperty("carefulBot")] + public bool CarefulBot; + + [JsonProperty("autoAcceptFollowed")] + public bool AutoAcceptFollowed; + + [JsonProperty("noCrawle")] + public bool NoCrawle; + + [JsonProperty("isExplorable")] + public bool IsExplorable; + + [JsonProperty("isDeleted")] + public bool IsDeleted; + + [JsonProperty("hideOnlineStatus")] + public bool HideOnlineStatus; + + [JsonProperty("hasUnreadSpecifiedNotes")] + public bool HasUnreadSpecifiedNotes; + + [JsonProperty("hasUnreadMentions")] + public bool HasUnreadMentions; + + [JsonProperty("hasUnreadAnnouncement")] + public bool HasUnreadAnnouncement; + + [JsonProperty("hasUnreadAntenna")] + public bool HasUnreadAntenna; + + [JsonProperty("hasUnreadChannel")] + public bool HasUnreadChannel; + + [JsonProperty("hasUnreadMessagingMessage")] + public bool HasUnreadMessagingMessage; + + [JsonProperty("hasUnreadNotification")] + public bool HasUnreadNotification; + + [JsonProperty("hasPendingReceivedFollowRequest")] + public bool HasPendingReceivedFollowRequest; + + [JsonProperty("integrations")] + public Integration Integrations; + + [JsonProperty("mutedWords")] + public List> MutedWords; + + [JsonProperty("mutedInstances")] + public List MutedInstances; + + [JsonProperty("mutingNotificationTypes")] + public List MutingNotificationTypes; + + [JsonProperty("emailNotificationTypes")] + public List EmailNotificationTypes; + + [JsonProperty("email")] + [CanBeNull] + public string Email; + + [JsonProperty("emailVerified")] + public bool? EmailVerified; + + [JsonProperty("securityKeysList")] + [CanBeNull] + public List SecurityKeysList; + + public class SecurityKeysLists + { + } + + public class User + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("username")] + public string Username; + + [JsonProperty("host")] + public string Host; + + [JsonProperty("avatarUrl")] + public string AvatarUrl; + + [JsonProperty("avatarBlurhash")] + public object AvatarBlurhash; + + [JsonProperty("avatarColor")] + public object AvatarColor; + + [JsonProperty("isAdmin")] + public bool IsAdmin; + + [JsonProperty("isModerator")] + public bool IsModerator; + + [JsonProperty("isBot")] + public bool IsBot; + + [JsonProperty("isCat")] + public bool IsCat; + + [JsonProperty("emojis")] + public List Emojis; + + [JsonProperty("onlineStatus")] + public string OnlineStatus; + + //public bool Equals(I other) => this.MatchesOnlineID(other); + } + } +} diff --git a/osu.Game/Online/MisskeyAPI/Responses/Meta.cs b/osu.Game/Online/MisskeyAPI/Responses/Meta.cs new file mode 100644 index 0000000000..e2d1638e6f --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Responses/Meta.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + + +namespace osu.Game.Online.MisskeyAPI.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class Meta //: IEquatable + { + public class Feature + { + [JsonProperty("registration")] + public bool Registration; + + [JsonProperty("localTimeLine")] + public bool LocalTimeLine; + + [JsonProperty("globalTimeLine")] + public bool GlobalTimeLine; + + [JsonProperty("hcaptcha")] + public bool Hcaptcha; + + [JsonProperty("recaptcha")] + public bool Recaptcha; + + [JsonProperty("serviceWorker")] + public bool ServiceWorker; + + [JsonProperty("miauth")] + public bool? Miauth; + } + + [JsonProperty("maintainerName")] + public string MaintainerName; + + [JsonProperty("maintainerEmail")] + public string MaintainerEmail; + + [JsonProperty("version")] + public string Version; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("uri")] + public string Uri; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("langs")] + public List Langs; + + [JsonProperty("tosUrl")] + public string TosUrl; + + [JsonProperty("defaultDarkTheme")] + public string DefaultDarkTheme; + + [JsonProperty("defaultLightTheme")] + public string DefaultLightTheme; + + [JsonProperty("driveCapacityPerLocalUserMb")] + public int DriveCapacityPerLocalUserMb; + + [JsonProperty("emailRequiredForSignup")] + public bool EmailRequiredForSignup; + + [JsonProperty("recaptchaSiteKey")] + public string RecaptchaSiteKey; + + [JsonProperty("swPublickey")] + public string SwPublickey; + + [JsonProperty("mascotImageUrl")] + public string MascotImageUrl; + + [JsonProperty("bannerUrl")] + public string BannerUrl; + + [JsonProperty("iconUrl")] + public string IconUrl; + + [JsonProperty("maxNoteTextLength")] + public int MaxNoteTextLength; + + [JsonProperty("emojis")] + public List Emojis; + + [JsonProperty("requireSetup")] + public bool RequireSetup; + + [JsonProperty("enableEmail")] + public bool EnableEmail; + + [JsonProperty("enableServiceWorker")] + public bool EnableServiceWorker; + + [JsonProperty("translatorAvailable")] + public bool TranslatorAvailable; + + [JsonProperty("features")] + public Feature Features; + } +} diff --git a/osu.Game/Online/MisskeyAPI/Requests/Responses/Signin.cs b/osu.Game/Online/MisskeyAPI/Responses/Signin.cs similarity index 99% rename from osu.Game/Online/MisskeyAPI/Requests/Responses/Signin.cs rename to osu.Game/Online/MisskeyAPI/Responses/Signin.cs index 3fa4efc871..191c26b1c1 100644 --- a/osu.Game/Online/MisskeyAPI/Requests/Responses/Signin.cs +++ b/osu.Game/Online/MisskeyAPI/Responses/Signin.cs @@ -7,13 +7,10 @@ namespace osu.Game.Online.MisskeyAPI.Requests.Responses { public class Signin { - [JsonProperty("id")] public string Id; [JsonProperty("i")] public string i; - - } } diff --git a/osu.Game/Online/MisskeyAPI/OAuth.cs b/osu.Game/Online/MisskeyAPI/Responses/UsersNotes.cs similarity index 60% rename from osu.Game/Online/MisskeyAPI/OAuth.cs rename to osu.Game/Online/MisskeyAPI/Responses/UsersNotes.cs index 66071d74da..2aa322574e 100644 --- a/osu.Game/Online/MisskeyAPI/OAuth.cs +++ b/osu.Game/Online/MisskeyAPI/Responses/UsersNotes.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Online.MisskeyAPI +using Newtonsoft.Json; + +namespace osu.Game.Online.MisskeyAPI.Requests.Responses { - public class OAuth + public class UsersNotes { + } } diff --git a/osu.Game/Online/MisskeyAPI/Responses/UsersShow.cs b/osu.Game/Online/MisskeyAPI/Responses/UsersShow.cs new file mode 100644 index 0000000000..68ed8318d0 --- /dev/null +++ b/osu.Game/Online/MisskeyAPI/Responses/UsersShow.cs @@ -0,0 +1,381 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace osu.Game.Online.MisskeyAPI.Requests.Responses +{ + public class UsersShow + { + // Root myDeserializedClass = JsonConvert.DeserializeObject(myJsonResponse); + public class Emoji + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + } + + public class Field + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("value")] + public string Value { get; set; } + } + + public class File + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("md5")] + public string Md5 { get; set; } + + [JsonProperty("size")] + public int Size { get; set; } + + [JsonProperty("isSensitive")] + public bool IsSensitive { get; set; } + + [JsonProperty("blurhash")] + public object Blurhash { get; set; } + + [JsonProperty("properties")] + public Properties Properties { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("thumbnailUrl")] + public object ThumbnailUrl { get; set; } + + [JsonProperty("comment")] + public object Comment { get; set; } + + [JsonProperty("folderId")] + public object FolderId { get; set; } + + [JsonProperty("folder")] + public object Folder { get; set; } + + [JsonProperty("userId")] + public object UserId { get; set; } + + [JsonProperty("user")] + public object User { get; set; } + } + + public class Integrations + { + } + + public class PinnedNote + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("userId")] + public string UserId { get; set; } + + [JsonProperty("user")] + public User User { get; set; } + + [JsonProperty("text")] + public string Text { get; set; } + + [JsonProperty("cw")] + public object Cw { get; set; } + + [JsonProperty("visibility")] + public string Visibility { get; set; } + + [JsonProperty("renoteCount")] + public int RenoteCount { get; set; } + + [JsonProperty("repliesCount")] + public int RepliesCount { get; set; } + + [JsonProperty("reactions")] + public Reactions Reactions { get; set; } + + [JsonProperty("emojis")] + public List Emojis { get; set; } + + [JsonProperty("fileIds")] + public List FileIds { get; set; } + + [JsonProperty("files")] + public List Files { get; set; } + + [JsonProperty("replyId")] + public object ReplyId { get; set; } + + [JsonProperty("renoteId")] + public object RenoteId { get; set; } + } + + public class Properties + { + } + + public class Reactions + { + [JsonProperty(":thinkhappy@miss.nem.one:")] + public int ThinkhappyMissNemOne { get; set; } + } + + public class Root + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } + + [JsonProperty("host")] + public object Host { get; set; } + + [JsonProperty("avatarUrl")] + public string AvatarUrl { get; set; } + + [JsonProperty("avatarBlurhash")] + public string AvatarBlurhash { get; set; } + + [JsonProperty("avatarColor")] + public object AvatarColor { get; set; } + + [JsonProperty("isAdmin")] + public bool IsAdmin { get; set; } + + [JsonProperty("isModerator")] + public bool IsModerator { get; set; } + + [JsonProperty("isBot")] + public bool IsBot { get; set; } + + [JsonProperty("isCat")] + public bool IsCat { get; set; } + + [JsonProperty("emojis")] + public List Emojis { get; set; } + + [JsonProperty("onlineStatus")] + public string OnlineStatus { get; set; } + + [JsonProperty("url")] + public object Url { get; set; } + + [JsonProperty("uri")] + public object Uri { get; set; } + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updatedAt")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("lastFetchedAt")] + public object LastFetchedAt { get; set; } + + [JsonProperty("bannerUrl")] + public string BannerUrl { get; set; } + + [JsonProperty("bannerBlurhash")] + public string BannerBlurhash { get; set; } + + [JsonProperty("bannerColor")] + public object BannerColor { get; set; } + + [JsonProperty("isLocked")] + public bool IsLocked { get; set; } + + [JsonProperty("isSilenced")] + public bool IsSilenced { get; set; } + + [JsonProperty("isSuspended")] + public bool IsSuspended { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("location")] + public string Location { get; set; } + + [JsonProperty("birthday")] + public string Birthday { get; set; } + + [JsonProperty("lang")] + public object Lang { get; set; } + + [JsonProperty("fields")] + public List Fields { get; set; } + + [JsonProperty("followersCount")] + public int FollowersCount { get; set; } + + [JsonProperty("followingCount")] + public int FollowingCount { get; set; } + + [JsonProperty("notesCount")] + public int NotesCount { get; set; } + + [JsonProperty("pinnedNoteIds")] + public List PinnedNoteIds { get; set; } + + [JsonProperty("pinnedNotes")] + public List PinnedNotes { get; set; } + + [JsonProperty("pinnedPageId")] + public object PinnedPageId { get; set; } + + [JsonProperty("pinnedPage")] + public object PinnedPage { get; set; } + + [JsonProperty("publicReactions")] + public bool PublicReactions { get; set; } + + [JsonProperty("ffVisibility")] + public string FfVisibility { get; set; } + + [JsonProperty("twoFactorEnabled")] + public bool TwoFactorEnabled { get; set; } + + [JsonProperty("usePasswordLessLogin")] + public bool UsePasswordLessLogin { get; set; } + + [JsonProperty("securityKeys")] + public bool SecurityKeys { get; set; } + + [JsonProperty("avatarId")] + public string AvatarId { get; set; } + + [JsonProperty("bannerId")] + public string BannerId { get; set; } + + [JsonProperty("injectFeaturedNote")] + public bool InjectFeaturedNote { get; set; } + + [JsonProperty("receiveAnnouncementEmail")] + public bool ReceiveAnnouncementEmail { get; set; } + + [JsonProperty("alwaysMarkNsfw")] + public bool AlwaysMarkNsfw { get; set; } + + [JsonProperty("carefulBot")] + public bool CarefulBot { get; set; } + + [JsonProperty("autoAcceptFollowed")] + public bool AutoAcceptFollowed { get; set; } + + [JsonProperty("noCrawle")] + public bool NoCrawle { get; set; } + + [JsonProperty("isExplorable")] + public bool IsExplorable { get; set; } + + [JsonProperty("isDeleted")] + public bool IsDeleted { get; set; } + + [JsonProperty("hideOnlineStatus")] + public bool HideOnlineStatus { get; set; } + + [JsonProperty("hasUnreadSpecifiedNotes")] + public bool HasUnreadSpecifiedNotes { get; set; } + + [JsonProperty("hasUnreadMentions")] + public bool HasUnreadMentions { get; set; } + + [JsonProperty("hasUnreadAnnouncement")] + public bool HasUnreadAnnouncement { get; set; } + + [JsonProperty("hasUnreadAntenna")] + public bool HasUnreadAntenna { get; set; } + + [JsonProperty("hasUnreadChannel")] + public bool HasUnreadChannel { get; set; } + + [JsonProperty("hasUnreadMessagingMessage")] + public bool HasUnreadMessagingMessage { get; set; } + + [JsonProperty("hasUnreadNotification")] + public bool HasUnreadNotification { get; set; } + + [JsonProperty("hasPendingReceivedFollowRequest")] + public bool HasPendingReceivedFollowRequest { get; set; } + + [JsonProperty("integrations")] + public Integrations Integrations { get; set; } + + [JsonProperty("mutedWords")] + public List MutedWords { get; set; } + + [JsonProperty("mutedInstances")] + public List MutedInstances { get; set; } + + [JsonProperty("mutingNotificationTypes")] + public List MutingNotificationTypes { get; set; } + + [JsonProperty("emailNotificationTypes")] + public List EmailNotificationTypes { get; set; } + + [JsonProperty("showTimelineReplies")] + public bool ShowTimelineReplies { get; set; } + } + + public class User + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } + + [JsonProperty("host")] + public object Host { get; set; } + + [JsonProperty("avatarUrl")] + public string AvatarUrl { get; set; } + + [JsonProperty("avatarBlurhash")] + public string AvatarBlurhash { get; set; } + + [JsonProperty("avatarColor")] + public object AvatarColor { get; set; } + + [JsonProperty("isAdmin")] + public bool IsAdmin { get; set; } + + [JsonProperty("isCat")] + public bool IsCat { get; set; } + + [JsonProperty("emojis")] + public List Emojis { get; set; } + + [JsonProperty("onlineStatus")] + public string OnlineStatus { get; set; } + } + } +} diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index e13c729a11..893d1177bd 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Net.Mime; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -15,12 +17,14 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Misskey; using osuTK; namespace osu.Game.Overlays.Login { public class LoginForm : FillFlowContainer { + private TextBox hostname; private TextBox username; private TextBox password; private ShakeContainer shakeSignIn; @@ -33,8 +37,8 @@ namespace osu.Game.Overlays.Login private void performLogin() { if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - // api?.Login(username.Text, password.Text); - shakeSignIn.Shake(); + api?.Login(username.Text, password.Text); + //shakeSignIn.Shake(); else shakeSignIn.Shake(); } @@ -51,6 +55,12 @@ namespace osu.Game.Overlays.Login Children = new Drawable[] { + hostname = new OsuTextBox() + { + PlaceholderText = "instanceHostName", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, username = new OsuTextBox { PlaceholderText = UsersStrings.LoginUsername.ToLower(), diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 0a6af59208..751a8b51a2 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -112,7 +112,7 @@ namespace osu.Game.Screens.Menu OnSolo = loadSoloSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), OnPlaylists = () => this.Push(new Playlists()), - OnMisskey = () => this.Push(new MisskeyLogin()), + OnMisskey = () => this.Push(new MisskeyScreenSelector()), OnExit = confirmAndExit, } } diff --git a/osu.Game/Screens/Misskey/MisskeyInstanceSelect.cs b/osu.Game/Screens/Misskey/MisskeyInstanceSelect.cs new file mode 100644 index 0000000000..088a5b868f --- /dev/null +++ b/osu.Game/Screens/Misskey/MisskeyInstanceSelect.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd , sim1222 . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using DiffPlex; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osuTK.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.MisskeyAPI; +using osu.Game.Online.MisskeyAPI.Requests; +using osu.Game.Online.MisskeyAPI.Requests.Responses; +using osuTK; +using Meta = osu.Game.Online.MisskeyAPI.Requests.Meta; + +namespace osu.Game.Screens.Misskey +{ + public class MisskeyInstanceSelect : OsuScreen + { + private Container contentContainer; + + private SeekLimitedSearchTextBox searchTextBox; + // private OsuButton submitButton; + + // private string instanceName = String.Empty; + + // [Resolved] + private IAPIProvider api { get; set; } + + // private Meta getMeta; + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader(true)] + private void load() + { + InternalChild = contentContainer = new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.5f, 0.4f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.6f, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + AutoSizeEasing = Easing.OutQuint, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 3, + Colour = colours.Yellow, + Alpha = 1, + }, + searchTextBox = new SeekLimitedSearchTextBox() + { + RelativeSizeAxes = Axes.X, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = "misskey.io" + }, + new OsuButton() + { + Text = "Submit", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 15f, + Width = 0.6f, + Action = insetanceFetch, + } + } + }, + } + }; + + // insetanceFetch(); + // + // searchTextBox.Current.ValueChanged += _ => insetanceFetch(); + } + + private void insetanceFetch() + { + var getMeta = new Meta(searchTextBox.Text); + + getMeta.Success += response => + { + Logger.Log($"{response}"); + }; + + api.Queue(getMeta); + } + } +} diff --git a/osu.Game/Screens/Misskey/MisskeyLogin.cs b/osu.Game/Screens/Misskey/MisskeyLogin.cs index 8c98b2b042..59e32d9c62 100644 --- a/osu.Game/Screens/Misskey/MisskeyLogin.cs +++ b/osu.Game/Screens/Misskey/MisskeyLogin.cs @@ -4,15 +4,22 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osuTK; using osuTK.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Overlays.Login; +using osuTK; namespace osu.Game.Screens.Misskey { public class MisskeyLogin : OsuScreen { + private LoginPanel panel; + + private const float transition_time = 400; + public override bool HideOverlaysOnEnter => false; private Container contentContainer; @@ -23,9 +30,6 @@ namespace osu.Game.Screens.Misskey //private LoginPanel loginPanel; - [Resolved] - private OsuGameBase game { get; set; } - [Resolved] private OsuColour colours { get; set; } @@ -44,13 +48,34 @@ namespace osu.Game.Screens.Misskey { new Box { - Colour = new Color4(255, 255, 255, 255), RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f, 0.4f), - Anchor = Anchor.Centre, + Colour = Color4.Black, + Alpha = 0.6f, }, new Container { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + AutoSizeDuration = transition_time, + AutoSizeEasing = Easing.OutQuint, + Children = new Drawable[] + { + panel = new LoginPanel + { + Padding = new MarginPadding(10), + RequestHide = Hide, + }, + new Box + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 3, + Colour = colours.Yellow, + Alpha = 1, + }, + } } } }; diff --git a/osu.Game/Screens/Misskey/MisskeyScreenSelector.cs b/osu.Game/Screens/Misskey/MisskeyScreenSelector.cs new file mode 100644 index 0000000000..5e95a7764e --- /dev/null +++ b/osu.Game/Screens/Misskey/MisskeyScreenSelector.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Misskey +{ + public class MisskeyScreenSelector : OsuScreen + { + private Container contentContainer; + + [Resolved] + private OsuGameBase game { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader(true)] + private void load() + { + InternalChild = contentContainer = new Container() + { + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.9f, 0.8f), + + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeaFoamDark, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + //Width = 0.35f, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Children = new Drawable[] + { + new BasicButton() + { + Text = "MisskeyLogin", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 60f, + Width = 0.9f, + Action = () => this.Push(new MisskeyLogin()) + }, + new BasicButton() + { + Text = "MisskeyInstanceSelect", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 60f, + Width = 0.9f, + Action = () => this.Push(new MisskeyInstanceSelect()) + } + } + } + } + }; + } + } +}