diff --git a/src/Artemis.UI/Extensions/Bitmap.cs b/src/Artemis.UI/Extensions/Bitmap.cs index 8c14ee4da..e30bc754f 100644 --- a/src/Artemis.UI/Extensions/Bitmap.cs +++ b/src/Artemis.UI/Extensions/Bitmap.cs @@ -1,4 +1,6 @@ +using System; using System.IO; +using System.Threading.Tasks; using Avalonia.Media.Imaging; using SkiaSharp; @@ -24,21 +26,23 @@ public class BitmapExtensions private static Bitmap Resize(SKBitmap source, int size) { - int newWidth, newHeight; - float aspectRatio = (float) source.Width / source.Height; + // Get smaller dimension. + int minDim = Math.Min(source.Width, source.Height); - if (aspectRatio > 1) - { - newWidth = size; - newHeight = (int) (size / aspectRatio); - } - else - { - newWidth = (int) (size * aspectRatio); - newHeight = size; - } + // Calculate crop rectangle position for center crop. + int deltaX = (source.Width - minDim) / 2; + int deltaY = (source.Height - minDim) / 2; + + // Create crop rectangle. + SKRectI rect = new(deltaX, deltaY, deltaX + minDim, deltaY + minDim); + + // Do the actual cropping of the bitmap. + using SKBitmap croppedBitmap = new(minDim, minDim); + source.ExtractSubset(croppedBitmap, rect); + + // Resize to the desired size after cropping. + using SKBitmap resizedBitmap = croppedBitmap.Resize(new SKImageInfo(size, size), SKFilterQuality.High); - using SKBitmap resizedBitmap = source.Resize(new SKImageInfo(newWidth, newHeight), SKFilterQuality.High); return new Bitmap(resizedBitmap.Encode(SKEncodedImageFormat.Png, 100).AsStream()); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml index 9af92602f..628846513 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -23,8 +23,9 @@ VerticalAlignment="Center" Margin="0 0 10 0" Width="80" - Height="80"> - + Height="80" + ClipToBounds="True"> + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 0691c5b53..22e378a1d 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -1,26 +1,34 @@ using System; using System.Reactive; +using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; using ReactiveUI; namespace Artemis.UI.Screens.Workshop.Entries; -public class EntryListViewModel : ViewModelBase +public class EntryListViewModel : ActivatableViewModelBase { private readonly IRouter _router; + private readonly ObservableAsPropertyHelper _entryIcon; - public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router) + public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router, IWorkshopService workshopService) { _router = router; + Entry = entry; + EntryIcon = workshopService.GetEntryIcon(entry.Id, CancellationToken.None); NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); } public IGetEntries_Entries_Items Entry { get; } - public ReactiveCommand NavigateToEntry { get; } + public Task EntryIcon { get; } + public ReactiveCommand NavigateToEntry { get; } private async Task ExecuteNavigateToEntry() { diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 98e45f00b..340c84ee4 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -13,6 +13,7 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; +using DryIoc.ImTools; using ReactiveUI; using StrawberryShake; @@ -20,8 +21,8 @@ namespace Artemis.UI.Screens.Workshop.Profile; public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel { - private readonly IRouter _router; private readonly INotificationService _notificationService; + private readonly Func _getEntryListViewModel; private readonly IWorkshopClient _workshopClient; private readonly ObservableAsPropertyHelper _showPagination; private readonly ObservableAsPropertyHelper _isLoading; @@ -31,18 +32,22 @@ public class ProfileListViewModel : RoutableScreen getEntryListViewModel) { _workshopClient = workshopClient; - _router = router; _notificationService = notificationService; + _getEntryListViewModel = getEntryListViewModel; _showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); _isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); CategoriesViewModel = categoriesViewModel; // Respond to page changes - this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => _router.Navigate($"workshop/profiles/{p}"))); + this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); // Respond to filter changes this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => { @@ -57,7 +62,7 @@ public class ProfileListViewModel : RoutableScreen _showPagination.Value; public bool IsLoading => _isLoading.Value; - + public CategoriesViewModel CategoriesViewModel { get; } public List? Entries @@ -108,10 +113,10 @@ public class ProfileListViewModel : RoutableScreen entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); entries.EnsureNoErrors(); - + if (entries.Data?.Entries?.Items != null) { - Entries = entries.Data.Entries.Items.Select(n => new EntryListViewModel(n, _router)).ToList(); + Entries = entries.Data.Entries.Items.Select(n => _getEntryListViewModel(n)).ToList(); TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); } else diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 60d2c92c6..05ef0355a 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -13,12 +13,14 @@ using Artemis.Core; using Artemis.UI.Shared.Routing; using System; using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Services; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; public class UploadStepViewModel : SubmissionViewModel { private readonly IWorkshopClient _workshopClient; + private readonly IWorkshopService _workshopService; private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; private readonly IWindowService _windowService; private readonly IRouter _router; @@ -30,9 +32,10 @@ public class UploadStepViewModel : SubmissionViewModel private Guid? _entryId; /// - public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) + public UploadStepViewModel(IWorkshopClient workshopClient, IWorkshopService workshopService, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) { _workshopClient = workshopClient; + _workshopService = workshopService; _entryUploadHandlerFactory = entryUploadHandlerFactory; _windowService = windowService; _router = router; @@ -87,6 +90,10 @@ public class UploadStepViewModel : SubmissionViewModel if (cancellationToken.IsCancellationRequested) return; + + // Upload image + if (State.Icon != null) + await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken); // Create the workshop entry try diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index 46c4060fc..05f1ecb21 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -44,6 +44,7 @@ public static class ContainerExtensions container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); container.Register(Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); diff --git a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql index d39be92f2..864caa217 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql @@ -1,5 +1,5 @@ query GetCategories { - categories { + categories(order: {name: ASC}) { id name icon diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql index c7e639c31..a1879e786 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql @@ -1,5 +1,5 @@ query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) { - entries(where: $filter skip: $skip take: $take) { + entries(where: $filter skip: $skip take: $take, order: {createdAt: DESC}) { totalCount items { id diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index b159031c1..eb9e4e459 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -26,6 +26,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi private readonly BehaviorSubject _isLoggedInSubject = new(false); private AuthenticationToken? _token; + private bool _noStoredRefreshToken; public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) { @@ -50,7 +51,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi private void SetCurrentUser(TokenResponse response) { _token = new AuthenticationToken(response); - _authenticationRepository.SetRefreshToken(_token.RefreshToken); + SetStoredRefreshToken(_token.RefreshToken); JwtSecurityTokenHandler handler = new(); JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken); @@ -80,7 +81,10 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi if (response.IsError) { if (response.Error is OidcConstants.TokenErrors.ExpiredToken or OidcConstants.TokenErrors.InvalidGrant) + { + SetStoredRefreshToken(null); return false; + } throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); } @@ -118,8 +122,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi { // If not logged in, attempt to auto login first if (!_isLoggedInSubject.Value) - await AutoLogin(); + await InternalAutoLogin(); + // If there is no token, even after an auto-login, there's no bearer to add if (_token == null) return null; @@ -142,14 +147,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi try { - if (!force && _isLoggedInSubject.Value) - return true; - - string? refreshToken = _authenticationRepository.GetRefreshToken(); - if (refreshToken == null) - return false; - - return await UseRefreshToken(refreshToken); + return await InternalAutoLogin(force); } finally { @@ -157,6 +155,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi } } + /// public async Task Login(CancellationToken cancellationToken) { @@ -240,8 +239,36 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi { _token = null; _claims.Clear(); - _authenticationRepository.SetRefreshToken(null); + SetStoredRefreshToken(null); _isLoggedInSubject.OnNext(false); } + + private async Task InternalAutoLogin(bool force = false) + { + if (!force && _isLoggedInSubject.Value) + return true; + + if (_noStoredRefreshToken) + return false; + + string? refreshToken = GetStoredRefreshToken(); + if (refreshToken == null) + return false; + + return await UseRefreshToken(refreshToken); + } + + private string? GetStoredRefreshToken() + { + string? token = _authenticationRepository.GetRefreshToken(); + _noStoredRefreshToken = token == null; + return token; + } + + private void SetStoredRefreshToken(string? token) + { + _authenticationRepository.SetRefreshToken(token); + _noStoredRefreshToken = token == null; + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs new file mode 100644 index 000000000..34e39b5c8 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using Artemis.UI.Shared.Services.MainWindow; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.UploadHandlers; +using Avalonia.Media.Imaging; +using Avalonia.Threading; + +namespace Artemis.WebClient.Workshop.Services; + +public class WorkshopService : IWorkshopService +{ + private readonly Dictionary _entryIconCache = new(); + private readonly IHttpClientFactory _httpClientFactory; + private readonly SemaphoreSlim _iconCacheLock = new(1); + + public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService) + { + _httpClientFactory = httpClientFactory; + mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.Delay(1000); + ClearCache(); + }); + } + + /// + public async Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken) + { + await _iconCacheLock.WaitAsync(cancellationToken); + try + { + if (_entryIconCache.TryGetValue(entryId, out Stream? cachedBitmap)) + { + cachedBitmap.Seek(0, SeekOrigin.Begin); + return new Bitmap(cachedBitmap); + } + } + finally + { + _iconCacheLock.Release(); + } + + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + try + { + HttpResponseMessage response = await client.GetAsync($"entries/{entryId}/icon", cancellationToken); + response.EnsureSuccessStatusCode(); + Stream data = await response.Content.ReadAsStreamAsync(cancellationToken); + + _entryIconCache[entryId] = data; + return new Bitmap(data); + } + catch (HttpRequestException) + { + // ignored + return null; + } + } + + public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken) + { + icon.Seek(0, SeekOrigin.Begin); + + // Submit the archive + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + + // Construct the request + MultipartFormDataContent content = new(); + ProgressableStreamContent streamContent = new(icon, progress); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + content.Add(streamContent, "file", "file.png"); + + // Submit + HttpResponseMessage response = await client.PostAsync($"entries/{entryId}/icon", content, cancellationToken); + if (!response.IsSuccessStatusCode) + return ImageUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + return ImageUploadResult.FromSuccess(); + } + + private void ClearCache() + { + try + { + List values = _entryIconCache.Values.ToList(); + _entryIconCache.Clear(); + foreach (Stream bitmap in values) + bitmap.Dispose(); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } +} + +public interface IWorkshopService +{ + Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken); + Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs new file mode 100644 index 000000000..97f8e861c --- /dev/null +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs @@ -0,0 +1,21 @@ +namespace Artemis.WebClient.Workshop.UploadHandlers; + +public class ImageUploadResult +{ + public bool IsSuccess { get; set; } + public string? Message { get; set; } + + public static ImageUploadResult FromFailure(string? message) + { + return new ImageUploadResult + { + IsSuccess = false, + Message = message + }; + } + + public static ImageUploadResult FromSuccess() + { + return new ImageUploadResult {IsSuccess = true}; + } +} \ No newline at end of file