diff --git a/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor b/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor index 605feca93..fb40ba78d 100644 --- a/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor @@ -1,116 +1,197 @@ -@inherits Modal -@inject IJSRuntime JsRuntime -@implements IDisposable - -@if (Data.Attachment is null) -{ -
Loading content...
- return; -} - - - -
- @if (_loaded) - { - switch (Data.Attachment.Type) - { - case MessageAttachmentType.Image: - { - if (_imageReady) - { - - } - - break; - } - case MessageAttachmentType.Video: - - break; - case MessageAttachmentType.Audio: - - break; - default: - - break; - } - } -
-
- -
- - -
-
-
- -@code -{ - public class ModalParams - { - public byte[] Bytes { get; set; } - public Func OnConfirm; - public Message Message; - public MessageAttachment Attachment; - } - - private DotNetObjectReference _thisRef; - - private bool _imageReady; - private bool _loaded = false; - private bool _isUploading = false; - - protected override async Task OnInitializedAsync(){ - - Console.WriteLine(JsonSerializer.Serialize(Data.Attachment)); - - _thisRef = DotNetObjectReference.Create(this); - - // Load into blob form and get url - var blobUrl = await JsRuntime.InvokeAsync("createBlob", Data.Bytes, Data.Attachment.MimeType); - - // Build attachment object - Data.Attachment.Location = blobUrl; - - if (Data.Attachment.Type == MessageAttachmentType.Image) - { - _imageReady = false; - await JsRuntime.InvokeVoidAsync("getImageSize", blobUrl, _thisRef); - } - - _loaded = true; - } - - [JSInvokable] - public void SetImageSize(int width, int height) - { - Data.Attachment.Width = width; - Data.Attachment.Height = height; - - Console.WriteLine($"Set image size to {width},{height}"); - - _imageReady = true; - - StateHasChanged(); - } - - public async Task OnKeyDown(KeyboardEventArgs e){ - if (e.Key.ToLower() == "enter") - await OnClickConfirm(); - } - - public void OnClickCancel() => Close(); - - public async Task OnClickConfirm(){ - if (_isUploading) return; - _isUploading = true; - await Data.OnConfirm.Invoke(); - Close(); - } - - public void Dispose() - { - _thisRef?.Dispose(); - } -} \ No newline at end of file +@inherits Modal +@inject IJSRuntime JsRuntime +@inject UploadService UploadSvc +@implements IDisposable + +@if (Data.Attachment is null) +{ +
Loading content...
+ return; +} + + + +
+ @if (_loaded) + { + switch (Data.Attachment.Type) + { + case MessageAttachmentType.Image: + { + if (_imageReady) + { + + } + + break; + } + case MessageAttachmentType.Video: + + break; + case MessageAttachmentType.Audio: + + break; + default: + + break; + } + } +
+ @if (_isUploading) + { +
+
+
+
+ @(_progress?.Percent ?? 0)% + @(_progress?.FormattedUploaded ?? "0 B") / @(_progress?.FormattedTotal ?? "0 B") +
+ } + @if (_uploadError is not null) + { +
Upload failed: @_uploadError
+ } +
+ +
+ @if (_isUploading) + { + + } + else + { + + + } +
+
+
+ +@code +{ + public class ModalParams + { + public byte[] Bytes { get; set; } + public Func OnConfirm; + public Message Message; + public MessageAttachment Attachment; + public string UploadUrl { get; set; } + public string AuthToken { get; set; } + public Action OnUploadSuccess { get; set; } + } + + private DotNetObjectReference _thisRef; + + private bool _imageReady; + private bool _loaded = false; + private bool _isUploading = false; + private UploadProgressInfo? _progress; + private string _uploadError; + + private CancellationTokenSource? _uploadCts; + + protected override async Task OnInitializedAsync() + { + Console.WriteLine(JsonSerializer.Serialize(Data.Attachment)); + + _thisRef = DotNetObjectReference.Create(this); + + // Load into blob form and get url + var blobUrl = await JsRuntime.InvokeAsync("createBlob", Data.Bytes, Data.Attachment.MimeType); + + // Build attachment object + Data.Attachment.Location = blobUrl; + + if (Data.Attachment.Type == MessageAttachmentType.Image) + { + _imageReady = false; + await JsRuntime.InvokeVoidAsync("getImageSize", blobUrl, _thisRef); + } + + _loaded = true; + } + + [JSInvokable] + public void SetImageSize(int width, int height) + { + Data.Attachment.Width = width; + Data.Attachment.Height = height; + + Console.WriteLine($"Set image size to {width},{height}"); + + _imageReady = true; + + StateHasChanged(); + } + + public async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key.ToLower() == "enter" && !_isUploading) + await OnClickConfirm(); + } + + public void OnClickCancel() => Close(); + + public void OnClickCancelUpload() + { + _uploadCts?.Cancel(); + } + + public async Task OnClickConfirm() + { + if (_isUploading) return; + + // New path: use UploadService for real progress + if (!string.IsNullOrEmpty(Data.UploadUrl)) + { + _isUploading = true; + _progress = null; + _uploadError = null; + _uploadCts = new CancellationTokenSource(); + StateHasChanged(); + + var result = await UploadSvc.UploadAsync( + Data.UploadUrl, + Data.Bytes, + Data.Attachment.MimeType, + Data.Attachment.FileName, + Data.AuthToken, + onProgress: p => + { + _progress = p; + InvokeAsync(StateHasChanged); + }, + cancellationToken: _uploadCts.Token); + + _isUploading = false; + _uploadCts.Dispose(); + _uploadCts = null; + + if (result.Success) + { + Data.OnUploadSuccess?.Invoke(result.Response); + Close(); + } + else + { + _uploadError = result.Error ?? "Unknown error"; + } + + StateHasChanged(); + } + else + { + // Fallback: old method without progress + _isUploading = true; + await Data.OnConfirm.Invoke(); + Close(); + } + } + + public void Dispose() + { + _uploadCts?.Cancel(); + _uploadCts?.Dispose(); + _thisRef?.Dispose(); + } +} diff --git a/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor.css b/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor.css index 7bda77f1a..f7447777a 100644 --- a/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor.css +++ b/Valour/Client/Components/Menus/Modals/Upload/FileUploadComponent.razor.css @@ -41,4 +41,55 @@ text-align: center; margin-top: 10px; margin-bottom: 10px; +} + +.upload-progress-container { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; + margin-top: 12px; +} + +.upload-progress-bar { + height: 100%; + background: linear-gradient(90deg, #5865F2, #EB459E); + border-radius: 4px; + transition: width 0.2s ease; +} + +.upload-progress-info { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.upload-progress-text { + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.upload-progress-bytes { + color: rgba(255, 255, 255, 0.5); +} + +.upload-error { + color: #ED4245; + font-size: 13px; + margin-top: 8px; + padding: 8px; + background: rgba(237, 66, 69, 0.1); + border-radius: 6px; +} + +::deep .v-btn.danger { + background: #ED4245; + color: white; +} + +::deep .v-btn.danger:hover { + background: #c03537; } \ No newline at end of file diff --git a/Valour/Client/Components/Windows/ChannelWindows/InputComponent.razor b/Valour/Client/Components/Windows/ChannelWindows/InputComponent.razor index 5b2a6efd2..34468788a 100644 --- a/Valour/Client/Components/Windows/ChannelWindows/InputComponent.razor +++ b/Valour/Client/Components/Windows/ChannelWindows/InputComponent.razor @@ -1,1015 +1,1024 @@ -@inject IJSRuntime JsRuntime -@implements IAsyncDisposable -@inject ValourClient Client - -@using System.Net.Http.Headers -@using System.Text.RegularExpressions -@using Valour.Client.Emojis -@using Valour.Shared.Cdn -@using Valour.Shared.Models -@using Valour.TenorTwo.Models -@using Media = Valour.TenorTwo.Models.Media - - - - - -@code { - - private bool _loading = true; - - [Parameter] - public ChatWindowComponent ChatComponent { get; set; } - - [CascadingParameter] - public ModalRoot ModalRoot { get; set; } - - /* End Planet Stuff */ - - /// - /// Placeholder text shown in the input box when empty - /// - private string PlaceholderText =>_loading ? "Loading channel..." : $"Discuss in {ChatComponent.Channel.Name}"; - - /// - /// The tenor menu component - /// - private TenorMenuComponent TenorMenu { get; set; } - - /// - /// A reference to the inner input component - /// - private ElementReference InnerInputRef { get; set; } - - /// - /// The component that displays the mention selection - /// - private MentionSelectComponent MentionSelector { get; set; } - - /// - /// True if this input is currently editing a message - /// - public bool IsEditing { get; set; } - - /// - /// The message component for the preview message - /// - public MessageComponent PreviewMessageComponent { get; private set; } - - /// - /// The message (if any) that is currently being edited - /// - public MessageComponent EditingMessageComponent { get; private set; } - private string EditingOriginalText { get; set; } - - /// - /// The preview message - /// - protected Message PreviewMessage { get; set; } - - /// - /// Dotnet object reference for use in Javascript - /// - private DotNetObjectReference _thisRef; - /// - /// Module for calling Javascript functions - /// - private IJSObjectReference _jsModule; - - /// - /// Js context for the input - /// - private IJSObjectReference _jsCtx; - - /// - /// If the current user is able to post messages using this input - /// - protected bool CanUserPost { get; set; } = true; // Start as true - - /// - /// Allows this component to render when true - /// - public bool CanRenderFlag { get; set; } - - private string _uploadMenuStyle = "display: none;"; - - private EmojiMart _emojis; - private Planet _emojiPlanetSubscription; - private bool _pendingCustomEmojiRefresh; - - private async Task OnClickTextbox() - { - await ChatComponent.ScrollToBottomAnimated(); - } - - private void ToggleEmojis() - { - _emojis.ToggleVisible(); - } - - private Task CloseEmojis(OutsidePickerClickEvent e) - { - if (e.Target == "emoji-button") - return Task.CompletedTask; - - _emojis.ToggleVisible(); - - return Task.CompletedTask; - } - - private void DetachEmojiPlanetSubscription() - { - if (_emojiPlanetSubscription is null) - return; - - _emojiPlanetSubscription.Emojis.Changed -= OnPlanetEmojiChanged; - _emojiPlanetSubscription = null; - } - - private async Task AttachEmojiPlanetSubscriptionAsync(Planet planet) - { - if (_emojiPlanetSubscription?.Id == planet.Id) - return; - - DetachEmojiPlanetSubscription(); - - _emojiPlanetSubscription = planet; - _emojiPlanetSubscription.Emojis.Changed += OnPlanetEmojiChanged; - - await RefreshCustomEmojiPickerAsync(); - } - - private Task OnPlanetEmojiChanged(IModelEvent _) - { - return RefreshCustomEmojiPickerAsync(); - } - - private Task RefreshCustomEmojiPickerAsync() - { - if (_emojis is null) - { - _pendingCustomEmojiRefresh = true; - return Task.CompletedTask; - } - - var planet = ChatComponent?.Channel?.Planet; - var custom = PlanetEmojiMapper.GetPickerItems(planet); - var categoryName = string.IsNullOrWhiteSpace(planet?.Name) ? "Planet" : planet.Name; - var categoryIcon = planet?.GetIconUrl(IconFormat.Webp64); - return _emojis.SetCustomEmojisAsync(custom, categoryIcon, categoryName); - } - - protected override bool ShouldRender() - => CanRenderFlag; - - public void Refresh() - { - CanRenderFlag = true; - StateHasChanged(); - } - - private void OnClickSendCurrency() - { - var data = new EcoPayModal.ModalParams() - { - Input = this - }; - - ModalRoot.OpenModal(data); - } - - public async Task ShowTenorMenu() - { - await TenorMenu.Show(); - } - - private Task ShowUploadMenu() - { - if (_uploadMenuStyle != "") - { - _uploadMenuStyle = ""; - Refresh(); - } - - return Task.CompletedTask; - } - - private void HideUploadMenu() - { - if (_uploadMenuStyle != "display: none;") - { - _uploadMenuStyle = "display: none;"; - Refresh(); - } - } - - private async Task OnClickUploadAsync() - { - await _jsCtx.InvokeVoidAsync("openUploadFile", _inputFileRef.Element); - } - - private static Dictionary _retainedInputCache = new(); - - async ValueTask IAsyncDisposable.DisposeAsync() - { - _retainedInputCache.Remove(ChatComponent.Channel.Id, out var _); - _thisRef?.Dispose(); - DetachEmojiPlanetSubscription(); - - try - { - if (_jsCtx is not null) - { - await _jsCtx.InvokeVoidAsync("cleanup"); - await _jsCtx.DisposeAsync(); - } - - if (_jsModule is not null) - await _jsModule.DisposeAsync(); - } - catch (JSDisconnectedException) { } - catch (JSException) { } - } - - public async Task NotifyChannelLoadedAsync() - { - var planet = ChatComponent.Channel.Planet; - if (planet is not null) - { - await planet.EnsureReadyAsync(); - await planet.LoadEmojisAsync(); - CanUserPost = await ChatComponent.Channel.HasPermissionAsync(planet.MyMember, ChatChannelPermissions.PostMessages); - await AttachEmojiPlanetSubscriptionAsync(planet); - } - else - { - CanUserPost = await ChatComponent.Channel.HasPermissionAsync(Client.Me.Id, ChatChannelPermissions.PostMessages); - DetachEmojiPlanetSubscription(); - if (_emojis is not null) - await _emojis.SetCustomEmojisAsync(Array.Empty()); - } - - PreviewMessage = BuildNewMessage(); - - _loading = false; - _pendingCustomEmojiRefresh = true; - - Refresh(); - } - - public Message BuildNewMessage() - { - _retainedInputCache.TryGetValue(ChatComponent.Channel.Id, out var prevContent); - - return new Message(Client) - { - AuthorUserId = Client.Me.Id, - Content = prevContent, - ChannelId = ChatComponent.Channel.Id, - AuthorMemberId = ChatComponent.Channel.Planet?.MyMember.Id, - TimeSent = DateTime.UtcNow, - ReplyToId = null, - PlanetId = ChatComponent.Channel.PlanetId, - Fingerprint = Guid.NewGuid().ToString(), - }; - } - - private async Task OnEmojiSelectedAsync(EmojiClickEvent e) - { - await InjectEmojiAsync( - e.Id, - e.Native, - e.Unified, - e.Shortcodes, - false, - false, - e.IsCustom, - e.Token, - e.Src); - } - - public ValueTask SetInputContent(string content) - { - return _jsCtx.InvokeVoidAsync("setInputContent", content ?? string.Empty); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - _thisRef = DotNetObjectReference.Create(this); - _jsModule = await JsRuntime.InvokeAsync("import", "./_content/Valour.Client/Components/Windows/ChannelWindows/InputComponent.razor.js?version=$(SHORTHASH)"); - _jsCtx = await _jsModule.InvokeAsync("init", _thisRef, InnerInputRef); - await JsRuntime.InvokeAsync("initializeFileDropZone", _dropZoneElement, _inputFileRef.Element); - - await OnAfterJsSetupAsync(); - } - - if (_pendingCustomEmojiRefresh && !_loading && _emojis is not null) - { - _pendingCustomEmojiRefresh = false; - await RefreshCustomEmojiPickerAsync(); - } - - CanRenderFlag = false; - } - - public virtual async Task OnAfterJsSetupAsync() - { - if (PreviewMessage?.Content is not null) - { - await SetInputContent(PreviewMessage.Content); - RefreshPreviewMessage(); - } - else { - await SetInputContent(""); - RefreshPreviewMessage(); - } - - await RefreshCustomEmojiPickerAsync(); - } - - #region File Drop System - - // Drop zone stuff - private InputFile _inputFileRef; - private ElementReference _dropZoneElement; - - public async Task OnBeginEdit(MessageComponent component, Message message) - { - EditingMessageComponent = component; - PreviewMessage = message; - EditingOriginalText = PreviewMessage.Content; - - if (PreviewMessageComponent is not null) - { - PreviewMessageComponent.SetMessage(PreviewMessage); - } - - await OnChatboxUpdate(message.Content, message.Content.Split(' ').LastOrDefault()); - - await SetInputContent(message.Content); - - Refresh(); - - await SelectEnd(); - } - - public async Task OnStopEdit(bool cancelled = false) - { - if (cancelled) - { - // Return to original - //EditingMessageComponent.MessageData.Clear(); - EditingMessageComponent.ParamData.Message.Content = EditingOriginalText; - } - - // Clear preview - EditingMessageComponent = null; - PreviewMessage = BuildNewMessage(); - PreviewMessageComponent.SetMessage(PreviewMessage); - - // Clear input - await OnChatboxUpdate(null, ""); - await SetInputContent(string.Empty); - - PreviewMessageComponent.ReRender(); - } - - private async Task LoadFiles(InputFileChangeEventArgs e) - { - //var file = await e.File.RequestImageFileAsync("jpeg", 256, 256); - - var file = e.File; - var maxBytes = UserSubscriptionTypes.GetMaxUploadBytes(Client.Me.SubscriptionType); - var maxSize = (int)Math.Min(maxBytes, int.MaxValue); - - var attachmentType = MessageAttachmentType.File; - - // Determine if audio or video or image - - var mime = file.ContentType; - var uploadPath = "file"; - - // We only actually need to check the first letter, - // since only 'image/' starts with i - if (mime[0] == 'i') - { - // Ensure that the mime type is supported by ImageSharp processing - if (CdnUtils.ImageSharpSupported.Contains(mime)) - { - attachmentType = MessageAttachmentType.Image; - uploadPath = "image"; - } - } - // Same thing here - only 'video/' starts with v - else if (mime[0] == 'v') - { - attachmentType = MessageAttachmentType.Video; - } - // Unfortunately 'audio/' and 'application/' both start with 'a' - else if (mime[0] == 'a' && mime[1] == 'u') - { - attachmentType = MessageAttachmentType.Audio; - } - - Console.WriteLine($"Selected file {file.Name} with size {file.Size}"); - - if (file.Size > maxSize) - { - - // Special case for images: ability to compress! - if (attachmentType == MessageAttachmentType.Image) - { - var data = new CompressComponent.ModalParams() - { - File = file, - Input = this, - }; - - ModalRoot.OpenModal(data); - - return; - } - else - { - var data = - new InfoModalComponent.ModalParams( - $"File too large!", - Client.Me.SubscriptionType is null - ? "The max upload size is 10MB. To raise this limit, consider subscribing!" - : $"Your max upload size is {maxBytes / (1024 * 1024)}MB.", - "Okay", - null - ); - - ModalRoot.OpenModal(data); - return; - } - } - - await ShowUploadMenu(file.OpenReadStream(maxSize), attachmentType, file.ContentType, file.Name, uploadPath); - - Refresh(); - } - - public async Task ShowUploadMenu(Stream data, MessageAttachmentType type, string mime, string name, string path) - { - // Convert stream to byte array - var bytes = new byte[data.Length]; - _ = await data.ReadAsync(bytes, 0, bytes.Length); - - var content = new MultipartFormDataContent(); - - var byteContent = new ByteArrayContent(bytes); - if (!string.IsNullOrWhiteSpace(mime)) - { - byteContent.Headers.ContentType = new MediaTypeHeaderValue(mime); - } - - content.Add(byteContent, name, name); - - MessageAttachment newAttachment = new(type) - { - Local = true, - MimeType = mime, - FileName = name, - }; - - var modalData = new FileUploadComponent.ModalParams() - { - Bytes = bytes, - Attachment = newAttachment, - Message = PreviewMessage, - OnConfirm = async () => - { - var result = await Client.PrimaryNode.PostMultipartDataWithResponse($"upload/{path}", content); - - if (result.Success) - { - newAttachment.Location = result.Data; - AddMessageAttachment(newAttachment); - } - else - { - Console.WriteLine(result.Message); - } - } - }; - - ModalRoot.OpenModal(modalData); - } - - public void RemoveAttachment(int id) - { - var attachments = PreviewMessage.Attachments; - if (attachments is null) - return; - - if (id == -1) - id = attachments.Count - 1; - - if (id > attachments.Count - 1) - return; - - attachments.RemoveAt(id); - - PreviewMessage.SetAttachments(attachments); - - RefreshPreviewMessage(); - Refresh(); - } - - #endregion - - public void AddReceipt(string transactionId) - { - var attachment = new MessageAttachment(MessageAttachmentType.ValourReceipt) - { - Location = $"https://app.valour.gg/api/eco/transactions/{transactionId}", - }; - - AddMessageAttachment(attachment); - } - - public void AddTenor(Media media) - { - var format = media.GetFormat(MediaFormatType.gif); - - AddMessageAttachment(new MessageAttachment(MessageAttachmentType.Image) - { - Location = format.Url, - MimeType = "image/gif", - Width = format.Dims[0], - Height = format.Dims[1], - FileName = media.Title + ".gif" - }); - } - - private static readonly Regex InviteRegex = new("https?://(?:app\\.)?valour\\.gg/[iI]/([a-zA-Z0-9]+)", RegexOptions.Compiled); - - private void InsertInviteAttachments(Message message) - { - if (string.IsNullOrWhiteSpace(message.Content)) - return; - - var matches = InviteRegex.Matches(message.Content); - if (matches.Count == 0) - return; - - var attachments = message.Attachments ?? new List(); - - foreach (Match match in matches) - { - var code = match.Groups[1].Value; - attachments.Add(new MessageAttachment(MessageAttachmentType.ValourInvite) - { - Location = $"https://app.valour.gg/i/{code}", - Inline = false - }); - } - - message.SetAttachments(attachments); - } - - public async Task UpdateMentionMenu(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - if (MentionSelector.Visible) - { - await MentionSelector.SetVisible(false); - } - - Refresh(); - return; - } - - var mode = text[0]; - var isAutocompleteToken = mode == '@' || mode == '#' || mode == ':'; - - // ":" autocomplete should wait until the user has typed at least one character. - if (mode == ':' && text.Length <= 1) - { - isAutocompleteToken = false; - } - - if (isAutocompleteToken) - { - if (!MentionSelector.Visible || MentionSelector.Mode != mode) - { - await MentionSelector.SetVisible(true, mode); - } - - await MentionSelector.SetText(text); - } - else - { - if (MentionSelector.Visible) - { - await MentionSelector.SetVisible(false); - } - } - - Refresh(); - } - - public void AddMessageAttachment(MessageAttachment attachment) - { - var attachments = PreviewMessage.Attachments; - attachments ??= new List(); - - attachments.Add(attachment); - PreviewMessage.SetAttachments(attachments); - - RefreshPreviewMessage(); - Refresh(); - } - - public void RefreshPreviewMessage() - { - //PreviewMessage.Content = string.Empty; - if (PreviewMessage is not null) - PreviewMessage.TimeSent = DateTime.UtcNow; - - if (PreviewMessageComponent is not null) - { - PreviewMessageComponent.SetLast(ChatComponent.GetLastMessage()); - PreviewMessageComponent.SetMessage(PreviewMessage); - } - } - - public async Task SetReplyMessage(Message message) - { - PreviewMessage.ReplyTo = message; - PreviewMessage.ReplyToId = message?.Id; - - RefreshPreviewMessage(); - Refresh(); - - await FocusInputAsync(); - } - - public Task RemoveReply() - { - PreviewMessage.ReplyToId = null; - PreviewMessageComponent.ParamData.Message.ReplyTo = null; - PreviewMessageComponent.ReRender(); - return Task.CompletedTask; - } - - public async Task PostMessage(Message message) - { - // New message - if (message.Id == 0) - { - await ChatComponent.AddQueuedMessage(message); - - var result = await message.PostAsync(); - - if (!result.Success) - { - ChatComponent.RemoveQueuedMessage(message.Fingerprint); - - Message errorMsg = new(Client) - { - Content = $"Hey there, friend! Your message didn't post properly.\n Reason: {result.Message}", - AuthorUserId = ISharedUser.VictorUserId, - ChannelId = ChatComponent.Channel.Id, - Id = long.MaxValue - }; - - ChatComponent.RemoveErrorMessage(); - await ChatComponent.AddMessage(errorMsg); - } - else - { - ChatComponent.RemoveErrorMessage(); - } - - PreviewMessageComponent.ParamData.Message.ReplyTo = null; - PreviewMessage.Clear(); - PreviewMessageComponent.ReRender(); - _retainedInputCache.Remove(ChatComponent.Channel.Id, out _); - } - // Editing message - else - { - var result = await message.UpdateAsync(); - if (!result.Success) - { - Message errorMsg = new(Client) - { - Content = $"Hey there, friend! Your message didn't edit properly.\n Reason: {result.Message}", - AuthorUserId = long.MaxValue, - ChannelId = ChatComponent.Channel.Id, - Id = 0 - }; - - ChatComponent.RemoveErrorMessage(); - await ChatComponent.AddMessage(errorMsg); - } - else - { - PreviewMessage = BuildNewMessage(); - PreviewMessageComponent.SetMessage(PreviewMessage); - - await OnStopEdit(); - } - } - } - - ///////////////////// - // JS Interop Zone // - ///////////////////// - - public ValueTask InjectElementAsync(string text, string coverText, string classList, string styleList) - { - return _jsCtx.InvokeVoidAsync("injectElement", text, coverText, classList, styleList); - } - - public ValueTask InjectEmojiAsync( - string emoji, - string native, - string unified, - string shortcodes, - bool deleteCurrentWord = false, - bool appendSpace = false, - bool isCustom = false, - string customToken = null, - string customSrc = null) - { - return _jsCtx.InvokeVoidAsync( - "injectEmoji", - emoji, - native, - unified, - shortcodes, - deleteCurrentWord, - appendSpace, - isCustom, - customToken, - customSrc); - } - - public async ValueTask> SearchEmojisAsync(string query, int maxResults = 10) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - List native = _emojis is null - ? new List() - : await _emojis.SearchAsync(query, maxResults); - - var custom = PlanetEmojiMapper.Search(ChatComponent?.Channel?.Planet, query, maxResults); - if (custom.Count == 0) - return native.Take(maxResults).ToList(); - - var merged = custom - .Concat(native) - .GroupBy(x => x.IsCustom ? $"c-{x.CustomId ?? 0}" : $"n-{x.Unified ?? x.Native ?? x.Id}") - .Select(x => x.First()) - .Take(maxResults) - .ToList(); - - return merged; - } - - public ValueTask InjectEmojiForAutocompleteAsync(EmojiClickEvent emoji) - { - return InjectEmojiAsync( - emoji.Id, - emoji.Native, - emoji.Unified, - emoji.Shortcodes, - true, - true, - emoji.IsCustom, - emoji.Token, - emoji.Src); - } - - public async Task OnSubmitClick() - { - await _jsCtx.InvokeVoidAsync("submitMessage", true); - } - - private ValueTask SelectEnd() - { - return _jsCtx.InvokeVoidAsync("moveCursorToEnd"); - } - - private ValueTask FocusInputAsync() - { - return _jsCtx.InvokeVoidAsync("focus"); - } - - // JS -> C# - [JSInvokable] - public Task OnCaretUpdate(string currentWord) - { - return UpdateMentionMenu(currentWord); - } - - /// - /// This runs every time a key is pressed when the chatbox is selected - /// - [JSInvokable] - public async Task OnChatboxUpdate(string input, string currentWord) - { - //Console.WriteLine(input); - - await UpdateMentionMenu(currentWord); - - if (input is not null) - { - // Fix for dumb formatting in HTML - input = input.Replace("\n\n«", "«").Replace("» \n\n", "»"); - } - - _retainedInputCache[ChatComponent.Channel.Id] = input; - - if (PreviewMessage is null) - { - PreviewMessage = BuildNewMessage(); - } - - PreviewMessage.Content = input; - RefreshPreviewMessage(); - - if (EditingMessageComponent is not null) - { - EditingMessageComponent.BuildMessage(); - } - - await ChatComponent.ScrollToBottom(); - await ChatComponent.Channel.SendIsTyping(); - - Refresh(); - } - - [JSInvokable] - public async Task MentionSubmit() - { - var handled = await MentionSelector.Submit(); - Refresh(); - return handled; - } - - [JSInvokable] - public void MoveMentionSelect(int n) - { - MentionSelector.MoveSelect(n); - } - - [JSInvokable] - public async Task OnUpArrowNonMention() - { - if (!string.IsNullOrEmpty(PreviewMessage.Content)) - { - return; - } - - // Get last message where author is the current user - var lastMessage = ChatComponent.RenderedMessages - .LastOrDefault(m => m.ParamData.Message.AuthorUserId == Client.Me.Id); - - if (lastMessage is null) - { - return; - } - - await lastMessage.OpenEditMode(); - await SelectEnd(); - } - - [JSInvokable] - public async Task OnEscape() - { - if (EditingMessageComponent is null) - return; - - await EditingMessageComponent.CloseEditMode(true); - } - - [JSInvokable] - public async Task OnChatboxSubmit() - { - if (PreviewMessage.Content is not null) - { - PreviewMessage.Content = - PreviewMessage.Content.TrimEnd('\n'); - - PreviewMessage.Content = - PreviewMessage.Content.Trim(); - } - - if (PreviewMessage.IsEmpty()) - { - return; - } - - var postMessage = PreviewMessage; - InsertInviteAttachments(postMessage); - - // Queue ghost message BEFORE clearing the preview so both changes - // are batched into the same render frame (no layout shift gap). - // Only for new messages — edits are already visible in the chat. - if (EditingMessageComponent is null) - { - ChatComponent.QueuedMessages.Add(postMessage); - } - - // New message for preview - PreviewMessage = BuildNewMessage(); - PreviewMessageComponent.SetMessage(PreviewMessage); - - // Queue a chat re-render so the ghost appears in the same paint - // as the preview clearing (both flush at the next yield). - _ = ChatComponent.ReRender(); - - await OnChatboxUpdate(null, ""); - - // Post message to server - await PostMessage(postMessage); - } -} +@inject IJSRuntime JsRuntime +@implements IAsyncDisposable +@inject ValourClient Client + +@using System.Net.Http.Headers +@using System.Text.RegularExpressions +@using Valour.Client.Emojis +@using Valour.Shared.Cdn +@using Valour.Shared.Models +@using Valour.TenorTwo.Models +@using Media = Valour.TenorTwo.Models.Media + + + + + +@code { + + private bool _loading = true; + + [Parameter] + public ChatWindowComponent ChatComponent { get; set; } + + [CascadingParameter] + public ModalRoot ModalRoot { get; set; } + + /* End Planet Stuff */ + + /// + /// Placeholder text shown in the input box when empty + /// + private string PlaceholderText =>_loading ? "Loading channel..." : $"Discuss in {ChatComponent.Channel.Name}"; + + /// + /// The tenor menu component + /// + private TenorMenuComponent TenorMenu { get; set; } + + /// + /// A reference to the inner input component + /// + private ElementReference InnerInputRef { get; set; } + + /// + /// The component that displays the mention selection + /// + private MentionSelectComponent MentionSelector { get; set; } + + /// + /// True if this input is currently editing a message + /// + public bool IsEditing { get; set; } + + /// + /// The message component for the preview message + /// + public MessageComponent PreviewMessageComponent { get; private set; } + + /// + /// The message (if any) that is currently being edited + /// + public MessageComponent EditingMessageComponent { get; private set; } + private string EditingOriginalText { get; set; } + + /// + /// The preview message + /// + protected Message PreviewMessage { get; set; } + + /// + /// Dotnet object reference for use in Javascript + /// + private DotNetObjectReference _thisRef; + /// + /// Module for calling Javascript functions + /// + private IJSObjectReference _jsModule; + + /// + /// Js context for the input + /// + private IJSObjectReference _jsCtx; + + /// + /// If the current user is able to post messages using this input + /// + protected bool CanUserPost { get; set; } = true; // Start as true + + /// + /// Allows this component to render when true + /// + public bool CanRenderFlag { get; set; } + + private string _uploadMenuStyle = "display: none;"; + + private EmojiMart _emojis; + private Planet _emojiPlanetSubscription; + private bool _pendingCustomEmojiRefresh; + + private async Task OnClickTextbox() + { + await ChatComponent.ScrollToBottomAnimated(); + } + + private void ToggleEmojis() + { + _emojis.ToggleVisible(); + } + + private Task CloseEmojis(OutsidePickerClickEvent e) + { + if (e.Target == "emoji-button") + return Task.CompletedTask; + + _emojis.ToggleVisible(); + + return Task.CompletedTask; + } + + private void DetachEmojiPlanetSubscription() + { + if (_emojiPlanetSubscription is null) + return; + + _emojiPlanetSubscription.Emojis.Changed -= OnPlanetEmojiChanged; + _emojiPlanetSubscription = null; + } + + private async Task AttachEmojiPlanetSubscriptionAsync(Planet planet) + { + if (_emojiPlanetSubscription?.Id == planet.Id) + return; + + DetachEmojiPlanetSubscription(); + + _emojiPlanetSubscription = planet; + _emojiPlanetSubscription.Emojis.Changed += OnPlanetEmojiChanged; + + await RefreshCustomEmojiPickerAsync(); + } + + private Task OnPlanetEmojiChanged(IModelEvent _) + { + return RefreshCustomEmojiPickerAsync(); + } + + private Task RefreshCustomEmojiPickerAsync() + { + if (_emojis is null) + { + _pendingCustomEmojiRefresh = true; + return Task.CompletedTask; + } + + var planet = ChatComponent?.Channel?.Planet; + var custom = PlanetEmojiMapper.GetPickerItems(planet); + var categoryName = string.IsNullOrWhiteSpace(planet?.Name) ? "Planet" : planet.Name; + var categoryIcon = planet?.GetIconUrl(IconFormat.Webp64); + return _emojis.SetCustomEmojisAsync(custom, categoryIcon, categoryName); + } + + protected override bool ShouldRender() + => CanRenderFlag; + + public void Refresh() + { + CanRenderFlag = true; + StateHasChanged(); + } + + private void OnClickSendCurrency() + { + var data = new EcoPayModal.ModalParams() + { + Input = this + }; + + ModalRoot.OpenModal(data); + } + + public async Task ShowTenorMenu() + { + await TenorMenu.Show(); + } + + private Task ShowUploadMenu() + { + if (_uploadMenuStyle != "") + { + _uploadMenuStyle = ""; + Refresh(); + } + + return Task.CompletedTask; + } + + private void HideUploadMenu() + { + if (_uploadMenuStyle != "display: none;") + { + _uploadMenuStyle = "display: none;"; + Refresh(); + } + } + + private async Task OnClickUploadAsync() + { + await _jsCtx.InvokeVoidAsync("openUploadFile", _inputFileRef.Element); + } + + private static Dictionary _retainedInputCache = new(); + + async ValueTask IAsyncDisposable.DisposeAsync() + { + _retainedInputCache.Remove(ChatComponent.Channel.Id, out var _); + _thisRef?.Dispose(); + DetachEmojiPlanetSubscription(); + + try + { + if (_jsCtx is not null) + { + await _jsCtx.InvokeVoidAsync("cleanup"); + await _jsCtx.DisposeAsync(); + } + + if (_jsModule is not null) + await _jsModule.DisposeAsync(); + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + + public async Task NotifyChannelLoadedAsync() + { + var planet = ChatComponent.Channel.Planet; + if (planet is not null) + { + await planet.EnsureReadyAsync(); + await planet.LoadEmojisAsync(); + CanUserPost = await ChatComponent.Channel.HasPermissionAsync(planet.MyMember, ChatChannelPermissions.PostMessages); + await AttachEmojiPlanetSubscriptionAsync(planet); + } + else + { + CanUserPost = await ChatComponent.Channel.HasPermissionAsync(Client.Me.Id, ChatChannelPermissions.PostMessages); + DetachEmojiPlanetSubscription(); + if (_emojis is not null) + await _emojis.SetCustomEmojisAsync(Array.Empty()); + } + + PreviewMessage = BuildNewMessage(); + + _loading = false; + _pendingCustomEmojiRefresh = true; + + Refresh(); + } + + public Message BuildNewMessage() + { + _retainedInputCache.TryGetValue(ChatComponent.Channel.Id, out var prevContent); + + return new Message(Client) + { + AuthorUserId = Client.Me.Id, + Content = prevContent, + ChannelId = ChatComponent.Channel.Id, + AuthorMemberId = ChatComponent.Channel.Planet?.MyMember.Id, + TimeSent = DateTime.UtcNow, + ReplyToId = null, + PlanetId = ChatComponent.Channel.PlanetId, + Fingerprint = Guid.NewGuid().ToString(), + }; + } + + private async Task OnEmojiSelectedAsync(EmojiClickEvent e) + { + await InjectEmojiAsync( + e.Id, + e.Native, + e.Unified, + e.Shortcodes, + false, + false, + e.IsCustom, + e.Token, + e.Src); + } + + public ValueTask SetInputContent(string content) + { + return _jsCtx.InvokeVoidAsync("setInputContent", content ?? string.Empty); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _thisRef = DotNetObjectReference.Create(this); + _jsModule = await JsRuntime.InvokeAsync("import", "./_content/Valour.Client/Components/Windows/ChannelWindows/InputComponent.razor.js?version=$(SHORTHASH)"); + _jsCtx = await _jsModule.InvokeAsync("init", _thisRef, InnerInputRef); + await JsRuntime.InvokeAsync("initializeFileDropZone", _dropZoneElement, _inputFileRef.Element); + + await OnAfterJsSetupAsync(); + } + + if (_pendingCustomEmojiRefresh && !_loading && _emojis is not null) + { + _pendingCustomEmojiRefresh = false; + await RefreshCustomEmojiPickerAsync(); + } + + CanRenderFlag = false; + } + + public virtual async Task OnAfterJsSetupAsync() + { + if (PreviewMessage?.Content is not null) + { + await SetInputContent(PreviewMessage.Content); + RefreshPreviewMessage(); + } + else { + await SetInputContent(""); + RefreshPreviewMessage(); + } + + await RefreshCustomEmojiPickerAsync(); + } + + #region File Drop System + + // Drop zone stuff + private InputFile _inputFileRef; + private ElementReference _dropZoneElement; + + public async Task OnBeginEdit(MessageComponent component, Message message) + { + EditingMessageComponent = component; + PreviewMessage = message; + EditingOriginalText = PreviewMessage.Content; + + if (PreviewMessageComponent is not null) + { + PreviewMessageComponent.SetMessage(PreviewMessage); + } + + await OnChatboxUpdate(message.Content, message.Content.Split(' ').LastOrDefault()); + + await SetInputContent(message.Content); + + Refresh(); + + await SelectEnd(); + } + + public async Task OnStopEdit(bool cancelled = false) + { + if (cancelled) + { + // Return to original + //EditingMessageComponent.MessageData.Clear(); + EditingMessageComponent.ParamData.Message.Content = EditingOriginalText; + } + + // Clear preview + EditingMessageComponent = null; + PreviewMessage = BuildNewMessage(); + PreviewMessageComponent.SetMessage(PreviewMessage); + + // Clear input + await OnChatboxUpdate(null, ""); + await SetInputContent(string.Empty); + + PreviewMessageComponent.ReRender(); + } + + private async Task LoadFiles(InputFileChangeEventArgs e) + { + //var file = await e.File.RequestImageFileAsync("jpeg", 256, 256); + + var file = e.File; + var maxBytes = UserSubscriptionTypes.GetMaxUploadBytes(Client.Me.SubscriptionType); + var maxSize = (int)Math.Min(maxBytes, int.MaxValue); + + var attachmentType = MessageAttachmentType.File; + + // Determine if audio or video or image + + var mime = file.ContentType; + var uploadPath = "file"; + + // We only actually need to check the first letter, + // since only 'image/' starts with i + if (mime[0] == 'i') + { + // Ensure that the mime type is supported by ImageSharp processing + if (CdnUtils.ImageSharpSupported.Contains(mime)) + { + attachmentType = MessageAttachmentType.Image; + uploadPath = "image"; + } + } + // Same thing here - only 'video/' starts with v + else if (mime[0] == 'v') + { + attachmentType = MessageAttachmentType.Video; + } + // Unfortunately 'audio/' and 'application/' both start with 'a' + else if (mime[0] == 'a' && mime[1] == 'u') + { + attachmentType = MessageAttachmentType.Audio; + } + + Console.WriteLine($"Selected file {file.Name} with size {file.Size}"); + + if (file.Size > maxSize) + { + + // Special case for images: ability to compress! + if (attachmentType == MessageAttachmentType.Image) + { + var data = new CompressComponent.ModalParams() + { + File = file, + Input = this, + }; + + ModalRoot.OpenModal(data); + + return; + } + else + { + var data = + new InfoModalComponent.ModalParams( + $"File too large!", + Client.Me.SubscriptionType is null + ? "The max upload size is 10MB. To raise this limit, consider subscribing!" + : $"Your max upload size is {maxBytes / (1024 * 1024)}MB.", + "Okay", + null + ); + + ModalRoot.OpenModal(data); + return; + } + } + + await ShowUploadMenu(file.OpenReadStream(maxSize), attachmentType, file.ContentType, file.Name, uploadPath); + + Refresh(); + } + + public async Task ShowUploadMenu(Stream data, MessageAttachmentType type, string mime, string name, string path) + { + // Convert stream to byte array + var bytes = new byte[data.Length]; + _ = await data.ReadAsync(bytes, 0, bytes.Length); + + MessageAttachment newAttachment = new(type) + { + Local = true, + MimeType = mime, + FileName = name, + }; + + var uploadUrl = Client.BaseAddress + $"upload/{path}"; + var authToken = Client.AuthService.Token; + + var modalData = new FileUploadComponent.ModalParams() + { + Bytes = bytes, + Attachment = newAttachment, + Message = PreviewMessage, + UploadUrl = uploadUrl, + AuthToken = authToken, + OnUploadSuccess = (location) => + { + newAttachment.Location = location; + AddMessageAttachment(newAttachment); + }, + OnConfirm = async () => + { + // Fallback for old upload path (no progress) + var content = new MultipartFormDataContent(); + var byteContent = new ByteArrayContent(bytes); + if (!string.IsNullOrWhiteSpace(mime)) + { + byteContent.Headers.ContentType = new MediaTypeHeaderValue(mime); + } + content.Add(byteContent, name, name); + + var result = await Client.PrimaryNode.PostMultipartDataWithResponse($"upload/{path}", content); + + if (result.Success) + { + newAttachment.Location = result.Data; + AddMessageAttachment(newAttachment); + } + else + { + Console.WriteLine(result.Message); + } + } + }; + + ModalRoot.OpenModal(modalData); + } + + public void RemoveAttachment(int id) + { + var attachments = PreviewMessage.Attachments; + if (attachments is null) + return; + + if (id == -1) + id = attachments.Count - 1; + + if (id > attachments.Count - 1) + return; + + attachments.RemoveAt(id); + + PreviewMessage.SetAttachments(attachments); + + RefreshPreviewMessage(); + Refresh(); + } + + #endregion + + public void AddReceipt(string transactionId) + { + var attachment = new MessageAttachment(MessageAttachmentType.ValourReceipt) + { + Location = $"https://app.valour.gg/api/eco/transactions/{transactionId}", + }; + + AddMessageAttachment(attachment); + } + + public void AddTenor(Media media) + { + var format = media.GetFormat(MediaFormatType.gif); + + AddMessageAttachment(new MessageAttachment(MessageAttachmentType.Image) + { + Location = format.Url, + MimeType = "image/gif", + Width = format.Dims[0], + Height = format.Dims[1], + FileName = media.Title + ".gif" + }); + } + + private static readonly Regex InviteRegex = new("https?://(?:app\\.)?valour\\.gg/[iI]/([a-zA-Z0-9]+)", RegexOptions.Compiled); + + private void InsertInviteAttachments(Message message) + { + if (string.IsNullOrWhiteSpace(message.Content)) + return; + + var matches = InviteRegex.Matches(message.Content); + if (matches.Count == 0) + return; + + var attachments = message.Attachments ?? new List(); + + foreach (Match match in matches) + { + var code = match.Groups[1].Value; + attachments.Add(new MessageAttachment(MessageAttachmentType.ValourInvite) + { + Location = $"https://app.valour.gg/i/{code}", + Inline = false + }); + } + + message.SetAttachments(attachments); + } + + public async Task UpdateMentionMenu(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + if (MentionSelector.Visible) + { + await MentionSelector.SetVisible(false); + } + + Refresh(); + return; + } + + var mode = text[0]; + var isAutocompleteToken = mode == '@' || mode == '#' || mode == ':'; + + // ":" autocomplete should wait until the user has typed at least one character. + if (mode == ':' && text.Length <= 1) + { + isAutocompleteToken = false; + } + + if (isAutocompleteToken) + { + if (!MentionSelector.Visible || MentionSelector.Mode != mode) + { + await MentionSelector.SetVisible(true, mode); + } + + await MentionSelector.SetText(text); + } + else + { + if (MentionSelector.Visible) + { + await MentionSelector.SetVisible(false); + } + } + + Refresh(); + } + + public void AddMessageAttachment(MessageAttachment attachment) + { + var attachments = PreviewMessage.Attachments; + attachments ??= new List(); + + attachments.Add(attachment); + PreviewMessage.SetAttachments(attachments); + + RefreshPreviewMessage(); + Refresh(); + } + + public void RefreshPreviewMessage() + { + //PreviewMessage.Content = string.Empty; + if (PreviewMessage is not null) + PreviewMessage.TimeSent = DateTime.UtcNow; + + if (PreviewMessageComponent is not null) + { + PreviewMessageComponent.SetLast(ChatComponent.GetLastMessage()); + PreviewMessageComponent.SetMessage(PreviewMessage); + } + } + + public async Task SetReplyMessage(Message message) + { + PreviewMessage.ReplyTo = message; + PreviewMessage.ReplyToId = message?.Id; + + RefreshPreviewMessage(); + Refresh(); + + await FocusInputAsync(); + } + + public Task RemoveReply() + { + PreviewMessage.ReplyToId = null; + PreviewMessageComponent.ParamData.Message.ReplyTo = null; + PreviewMessageComponent.ReRender(); + return Task.CompletedTask; + } + + public async Task PostMessage(Message message) + { + // New message + if (message.Id == 0) + { + await ChatComponent.AddQueuedMessage(message); + + var result = await message.PostAsync(); + + if (!result.Success) + { + ChatComponent.RemoveQueuedMessage(message.Fingerprint); + + Message errorMsg = new(Client) + { + Content = $"Hey there, friend! Your message didn't post properly.\n Reason: {result.Message}", + AuthorUserId = ISharedUser.VictorUserId, + ChannelId = ChatComponent.Channel.Id, + Id = long.MaxValue + }; + + ChatComponent.RemoveErrorMessage(); + await ChatComponent.AddMessage(errorMsg); + } + else + { + ChatComponent.RemoveErrorMessage(); + } + + PreviewMessageComponent.ParamData.Message.ReplyTo = null; + PreviewMessage.Clear(); + PreviewMessageComponent.ReRender(); + _retainedInputCache.Remove(ChatComponent.Channel.Id, out _); + } + // Editing message + else + { + var result = await message.UpdateAsync(); + if (!result.Success) + { + Message errorMsg = new(Client) + { + Content = $"Hey there, friend! Your message didn't edit properly.\n Reason: {result.Message}", + AuthorUserId = long.MaxValue, + ChannelId = ChatComponent.Channel.Id, + Id = 0 + }; + + ChatComponent.RemoveErrorMessage(); + await ChatComponent.AddMessage(errorMsg); + } + else + { + PreviewMessage = BuildNewMessage(); + PreviewMessageComponent.SetMessage(PreviewMessage); + + await OnStopEdit(); + } + } + } + + ///////////////////// + // JS Interop Zone // + ///////////////////// + + public ValueTask InjectElementAsync(string text, string coverText, string classList, string styleList) + { + return _jsCtx.InvokeVoidAsync("injectElement", text, coverText, classList, styleList); + } + + public ValueTask InjectEmojiAsync( + string emoji, + string native, + string unified, + string shortcodes, + bool deleteCurrentWord = false, + bool appendSpace = false, + bool isCustom = false, + string customToken = null, + string customSrc = null) + { + return _jsCtx.InvokeVoidAsync( + "injectEmoji", + emoji, + native, + unified, + shortcodes, + deleteCurrentWord, + appendSpace, + isCustom, + customToken, + customSrc); + } + + public async ValueTask> SearchEmojisAsync(string query, int maxResults = 10) + { + if (string.IsNullOrWhiteSpace(query)) + return new List(); + + List native = _emojis is null + ? new List() + : await _emojis.SearchAsync(query, maxResults); + + var custom = PlanetEmojiMapper.Search(ChatComponent?.Channel?.Planet, query, maxResults); + if (custom.Count == 0) + return native.Take(maxResults).ToList(); + + var merged = custom + .Concat(native) + .GroupBy(x => x.IsCustom ? $"c-{x.CustomId ?? 0}" : $"n-{x.Unified ?? x.Native ?? x.Id}") + .Select(x => x.First()) + .Take(maxResults) + .ToList(); + + return merged; + } + + public ValueTask InjectEmojiForAutocompleteAsync(EmojiClickEvent emoji) + { + return InjectEmojiAsync( + emoji.Id, + emoji.Native, + emoji.Unified, + emoji.Shortcodes, + true, + true, + emoji.IsCustom, + emoji.Token, + emoji.Src); + } + + public async Task OnSubmitClick() + { + await _jsCtx.InvokeVoidAsync("submitMessage", true); + } + + private ValueTask SelectEnd() + { + return _jsCtx.InvokeVoidAsync("moveCursorToEnd"); + } + + private ValueTask FocusInputAsync() + { + return _jsCtx.InvokeVoidAsync("focus"); + } + + // JS -> C# + [JSInvokable] + public Task OnCaretUpdate(string currentWord) + { + return UpdateMentionMenu(currentWord); + } + + /// + /// This runs every time a key is pressed when the chatbox is selected + /// + [JSInvokable] + public async Task OnChatboxUpdate(string input, string currentWord) + { + //Console.WriteLine(input); + + await UpdateMentionMenu(currentWord); + + if (input is not null) + { + // Fix for dumb formatting in HTML + input = input.Replace("\n\n«", "«").Replace("» \n\n", "»"); + } + + _retainedInputCache[ChatComponent.Channel.Id] = input; + + if (PreviewMessage is null) + { + PreviewMessage = BuildNewMessage(); + } + + PreviewMessage.Content = input; + RefreshPreviewMessage(); + + if (EditingMessageComponent is not null) + { + EditingMessageComponent.BuildMessage(); + } + + await ChatComponent.ScrollToBottom(); + await ChatComponent.Channel.SendIsTyping(); + + Refresh(); + } + + [JSInvokable] + public async Task MentionSubmit() + { + var handled = await MentionSelector.Submit(); + Refresh(); + return handled; + } + + [JSInvokable] + public void MoveMentionSelect(int n) + { + MentionSelector.MoveSelect(n); + } + + [JSInvokable] + public async Task OnUpArrowNonMention() + { + if (!string.IsNullOrEmpty(PreviewMessage.Content)) + { + return; + } + + // Get last message where author is the current user + var lastMessage = ChatComponent.RenderedMessages + .LastOrDefault(m => m.ParamData.Message.AuthorUserId == Client.Me.Id); + + if (lastMessage is null) + { + return; + } + + await lastMessage.OpenEditMode(); + await SelectEnd(); + } + + [JSInvokable] + public async Task OnEscape() + { + if (EditingMessageComponent is null) + return; + + await EditingMessageComponent.CloseEditMode(true); + } + + [JSInvokable] + public async Task OnChatboxSubmit() + { + if (PreviewMessage.Content is not null) + { + PreviewMessage.Content = + PreviewMessage.Content.TrimEnd('\n'); + + PreviewMessage.Content = + PreviewMessage.Content.Trim(); + } + + if (PreviewMessage.IsEmpty()) + { + return; + } + + var postMessage = PreviewMessage; + InsertInviteAttachments(postMessage); + + // Queue ghost message BEFORE clearing the preview so both changes + // are batched into the same render frame (no layout shift gap). + // Only for new messages — edits are already visible in the chat. + if (EditingMessageComponent is null) + { + ChatComponent.QueuedMessages.Add(postMessage); + } + + // New message for preview + PreviewMessage = BuildNewMessage(); + PreviewMessageComponent.SetMessage(PreviewMessage); + + // Queue a chat re-render so the ghost appears in the same paint + // as the preview clearing (both flush at the next yield). + _ = ChatComponent.ReRender(); + + await OnChatboxUpdate(null, ""); + + // Post message to server + await PostMessage(postMessage); + } +} diff --git a/Valour/Client/ServiceCollectionExtensions.cs b/Valour/Client/ServiceCollectionExtensions.cs index c0a3f841a..0739ee79d 100644 --- a/Valour/Client/ServiceCollectionExtensions.cs +++ b/Valour/Client/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Valour.Client.Components.Sidebar.Directory; using Valour.Client.ContextMenu; using Valour.Client.Sounds; +using Valour.Client.Utility; using Valour.Sdk.Client; using Valour.Sdk.Services; @@ -34,6 +35,7 @@ public static ValourClient AddValourClientServices(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // new services services.AddSingleton(client); diff --git a/Valour/Client/Utility/UploadService.cs b/Valour/Client/Utility/UploadService.cs new file mode 100644 index 000000000..3bc2e0f22 --- /dev/null +++ b/Valour/Client/Utility/UploadService.cs @@ -0,0 +1,190 @@ +using Microsoft.JSInterop; +using Valour.Shared.Utilities; + +namespace Valour.Client.Utility; + +/// +/// Reusable upload service for Blazor WASM that provides real wire-level +/// progress tracking and cancellation via XHR. Wrap all your uploads in this +/// and you never have to touch JS interop again. +/// +public class UploadService : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private IJSObjectReference? _module; + private IJSObjectReference? _currentUpload; + private DotNetObjectReference? _currentDotnetRef; + private CancellationTokenSource? _cts; + + public UploadService(IJSRuntime jsRuntime) + { + _js = jsRuntime; + } + + /// + /// Upload a file with real progress tracking. Returns an UploadResult + /// when the upload completes (success or failure). + /// Cancel via the CancellationToken. + /// + public async Task UploadAsync( + string url, + byte[] data, + string mimeType, + string fileName, + string? authToken = null, + Action? onProgress = null, + CancellationToken cancellationToken = default) + { + // Lazy-load the JS module on first use + _module ??= await _js.InvokeAsync("import", "./ts/UploadService.js"); + + // If there's a previous upload running, cancel it + CancelCurrent(); + + var tcs = new TaskCompletionSource(); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var callbacks = new UploadCallbacks(tcs, onProgress); + _currentDotnetRef = DotNetObjectReference.Create(callbacks); + + _currentUpload = await _module.InvokeAsync( + "start", + url, + data, + mimeType, + fileName, + _currentDotnetRef, + authToken); + + // Wire up cancellation + _cts.Token.Register(() => + { + CancelCurrent(); + if (!tcs.Task.IsCompleted) + tcs.TrySetResult(new UploadResult(false, null, "Upload cancelled")); + }); + + // Also handle the case where someone cancels the token before we even wire it + if (cancellationToken.IsCancellationRequested) + { + CancelCurrent(); + return new UploadResult(false, null, "Upload cancelled"); + } + + return await tcs.Task; + } + + /// + /// Cancel whatever upload is currently in flight. + /// + public void CancelCurrent() + { + try + { + _currentUpload?.InvokeVoidAsync("abort").AsTask().Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // Best effort - if abort fails the upload will complete or error on its own + } + + _currentUpload = null; + CleanupDotnetRef(); + } + + private void CleanupDotnetRef() + { + _currentDotnetRef?.Dispose(); + _currentDotnetRef = null; + } + + public async ValueTask DisposeAsync() + { + CancelCurrent(); + _cts?.Cancel(); + _cts?.Dispose(); + + try + { + if (_module is not null) + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) { } + catch (JSException) { } + + GC.SuppressFinalize(this); + } + + /// + /// Internal callback target for JSInvokable calls from the JS module. + /// Bridges XHR events into the TaskCompletionSource and progress callback. + /// + private sealed class UploadCallbacks + { + private readonly TaskCompletionSource _tcs; + private readonly Action? _onProgress; + + public UploadCallbacks(TaskCompletionSource tcs, Action? onProgress) + { + _tcs = tcs; + _onProgress = onProgress; + } + + [JSInvokable("NotifyUploadProgress")] + public void NotifyUploadProgress(long loaded, long total) + { + _onProgress?.Invoke(new UploadProgressInfo(loaded, total)); + } + + [JSInvokable("NotifyUploadComplete")] + public void NotifyUploadComplete(string response) + { + _tcs.TrySetResult(new UploadResult(true, response, null)); + } + + [JSInvokable("NotifyUploadMisdirect")] + public void NotifyUploadMisdirect(string responseBody, int statusCode) + { + _tcs.TrySetResult(new UploadResult(false, null, $"Server redirect ({statusCode}). Please try again.")); + } + + [JSInvokable("NotifyUploadError")] + public void NotifyUploadError(string error) + { + _tcs.TrySetResult(new UploadResult(false, null, error)); + } + + [JSInvokable("NotifyUploadCancelled")] + public void NotifyUploadCancelled() + { + _tcs.TrySetResult(new UploadResult(false, null, "Upload cancelled")); + } + } +} + +/// +/// The result of an upload operation. Clean and simple. +/// +public record UploadResult(bool Success, string? Response, string? Error) +{ + public bool IsMisdirect => !Success && Error?.Contains("redirect") == true; +} + +/// +/// Real-time progress info from the XHR upload progress event. +/// BytesUploaded / TotalBytes are the real wire-level numbers. +/// +public record UploadProgressInfo(long BytesUploaded, long TotalBytes) +{ + public int Percent => TotalBytes > 0 ? (int)Math.Round((double)BytesUploaded / TotalBytes * 100) : 0; + + public string FormattedUploaded => FormatBytes(BytesUploaded); + public string FormattedTotal => FormatBytes(TotalBytes); + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } +} diff --git a/Valour/Client/wwwroot/js/main.js b/Valour/Client/wwwroot/js/main.js index 4ee7d08d4..ca29aaa7f 100644 --- a/Valour/Client/wwwroot/js/main.js +++ b/Valour/Client/wwwroot/js/main.js @@ -1,37 +1,37 @@ -document.addEventListener('contextmenu', event => event.preventDefault()); - -window.clipboardCopy = { - copyText: function (text) { - navigator.clipboard.writeText(text).then(function () { - // alert("Copied to clipboard!"); - }) - .catch(function (error) { - alert(error); - }); - } -}; - -let mobile = false; -(function (a) { if (/(android|bb\d+|meego).+mobile|\avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) mobile = true; })(navigator.userAgent || navigator.vendor || window.opera); - -const embedded = window.location.href.includes('embedded=true'); -// Special embedded check -if (embedded) { - console.log("Enabling embedded mode."); - mobile = true; -} - -window["mobile"] = mobile; -window["embedded"] = embedded; - -function IsMobile() { - return mobile; -} - -function IsEmbedded() { - return embedded; -} - +document.addEventListener('contextmenu', event => event.preventDefault()); + +window.clipboardCopy = { + copyText: function (text) { + navigator.clipboard.writeText(text).then(function () { + // alert("Copied to clipboard!"); + }) + .catch(function (error) { + alert(error); + }); + } +}; + +let mobile = false; +(function (a) { if (/(android|bb\d+|meego).+mobile|\avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) mobile = true; })(navigator.userAgent || navigator.vendor || window.opera); + +const embedded = window.location.href.includes('embedded=true'); +// Special embedded check +if (embedded) { + console.log("Enabling embedded mode."); + mobile = true; +} + +window["mobile"] = mobile; +window["embedded"] = embedded; + +function IsMobile() { + return mobile; +} + +function IsEmbedded() { + return embedded; +} + // Web lock // The idea here is to *force* the tab to stay active @@ -47,15 +47,15 @@ if (navigator.locks && navigator.locks.request) { // Now lock will be held until either resolve() or reject() is called. }); } - -function SetDate() { - if (document.getElementById('ageVeriInput')) document.getElementById('ageVeriInput').valueAsDate = new Date() -} - -// TODO: Cleanup SW stuff -// const registerClient = async () => { -// } - + +function SetDate() { + if (document.getElementById('ageVeriInput')) document.getElementById('ageVeriInput').valueAsDate = new Date() +} + +// TODO: Cleanup SW stuff +// const registerClient = async () => { +// } + window.blazorFuncs = { registerClient: function (caller) { const updateAvailablePromise = window['updateAvailable'] || Promise.resolve(false); @@ -74,7 +74,7 @@ window.blazorFuncs = { }); } }; - + window.getBrowserOrigin = function() { return window.location.origin; }; @@ -90,11 +90,11 @@ window.getValourApiOrigin = function() { function Log(message, color) { console.log("%c" + message, 'color: ' + color); } - -/* Sound Code */ - -// Hack for IOS stupidity - + +/* Sound Code */ + +// Hack for IOS stupidity + let playedDummy = false let soundContext = null; const soundLayerGains = new Map(); @@ -112,17 +112,17 @@ document.addEventListener('pointerdown', function () { // Literally HAVE to do this so IOS works. Apple literally hates developers. const audioSources = [new Audio(), new Audio(), new Audio(), new Audio(), new Audio(), new Audio(), new Audio(), new Audio(), new Audio(), new Audio()] - -let sourceIndex = 0; - -function getAudioSource() { - const source = audioSources[sourceIndex]; - sourceIndex++; - if (sourceIndex > 9) - sourceIndex = 0; - return source; -} - + +let sourceIndex = 0; + +function getAudioSource() { + const source = audioSources[sourceIndex]; + sourceIndex++; + if (sourceIndex > 9) + sourceIndex = 0; + return source; +} + function dummySound() { for (let i = 0; i < audioSources.length; i++) { let source = getAudioSource(); @@ -263,126 +263,126 @@ async function playSound(name, volume = 0.4, layer = "effects") { if (!played) playSoundFallback(name, volume); } - -function SetCardTitle(id, name) { - document.getElementById('text-' + id).firstElementChild.firstElementChild.innerHTML = name; -} - -/* Content upload handling */ - -// Creates a blob and returns the location -function createBlob(buffer, contentType) { - const blob = new Blob([buffer], { type: contentType }); - return window.URL.createObjectURL(blob); -} - -function getImageSize(blobUrl, ref) { - const image = new Image(); - image.onload = function () { - ref.invokeMethodAsync('SetImageSize', this.width, this.height); - } - image.src = blobUrl; -} - -/* Useful functions for layout items */ -function determineFlip(elementId, safeWidth){ - const element = document.getElementById(elementId); - if (!element) - return; - - const parentWidth = element.parentElement.offsetWidth; - const selfPosition = element.offsetLeft; - - if (parentWidth - selfPosition < safeWidth) { - element.classList.add('flip'); - } else { - element.classList.remove('flip'); - } -} - -/* GDPR Compliance */ - -const EU_TIMEZONES = [ - "Europe/Vienna", - "Europe/Brussels", - "Europe/Sofia", - "Europe/Zagreb", - "Asia/Famagusta", - "Asia/Nicosia", - "Europe/Prague", - "Europe/Copenhagen", - "Europe/Tallinn", - "Europe/Helsinki", - "Europe/Paris", - "Europe/Berlin", - "Europe/Busingen", - "Europe/Athens", - "Europe/Budapest", - "Europe/Dublin", - "Europe/Rome", - "Europe/Riga", - "Europe/Vilnius", - "Europe/Luxembourg", - "Europe/Malta", - "Europe/Amsterdam", - "Europe/Warsaw", - "Atlantic/Azores", - "Atlantic/Madeira", - "Europe/Lisbon", - "Europe/Bucharest", - "Europe/Bratislava", - "Europe/Ljubljana", - "Africa/Ceuta", - "Atlantic/Canary", - "Europe/Madrid", - "Europe/Stockholm", -]; - -function isEuropeanUnion() { - try { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return EU_TIMEZONES.includes(timeZone); - } catch (e) { - // Fallback if Intl API is not supported - return false; - } -} - - -function positionRelativeTo(id, x, y, corner) { - const element = document.getElementById(id); - if (!element) - return; - - const viewRect = element.getBoundingClientRect(); - const width = viewRect.width; - const height = viewRect.height; - - if (corner === "bottomLeft") { - element.style.top = `${y - height}px`; - element.style.left = `${x}px`; - } - else if (corner === "bottomRight") { - element.style.top = `${y - height}px`; - element.style.left = `${x - width}px`; - } - - // Prevent escaping screen - const rect = element.getBoundingClientRect(); - if (rect.left < 16) { - element.style.left = `16px`; - } - if (rect.top < 16) { - element.style.top = `16px`; - } - if (rect.right > window.innerWidth - 16) { - element.style.left = `${window.innerWidth - width - 16}px`; - } - if (rect.bottom > window.innerHeight - 16) { - element.style.top = `${window.innerHeight - height - 16}px`; - } -} - + +function SetCardTitle(id, name) { + document.getElementById('text-' + id).firstElementChild.firstElementChild.innerHTML = name; +} + +/* Content upload handling */ + +// Creates a blob and returns the location +function createBlob(buffer, contentType) { + const blob = new Blob([buffer], { type: contentType }); + return window.URL.createObjectURL(blob); +} + +function getImageSize(blobUrl, ref) { + const image = new Image(); + image.onload = function () { + ref.invokeMethodAsync('SetImageSize', this.width, this.height); + } + image.src = blobUrl; +} + +/* Useful functions for layout items */ +function determineFlip(elementId, safeWidth){ + const element = document.getElementById(elementId); + if (!element) + return; + + const parentWidth = element.parentElement.offsetWidth; + const selfPosition = element.offsetLeft; + + if (parentWidth - selfPosition < safeWidth) { + element.classList.add('flip'); + } else { + element.classList.remove('flip'); + } +} + +/* GDPR Compliance */ + +const EU_TIMEZONES = [ + "Europe/Vienna", + "Europe/Brussels", + "Europe/Sofia", + "Europe/Zagreb", + "Asia/Famagusta", + "Asia/Nicosia", + "Europe/Prague", + "Europe/Copenhagen", + "Europe/Tallinn", + "Europe/Helsinki", + "Europe/Paris", + "Europe/Berlin", + "Europe/Busingen", + "Europe/Athens", + "Europe/Budapest", + "Europe/Dublin", + "Europe/Rome", + "Europe/Riga", + "Europe/Vilnius", + "Europe/Luxembourg", + "Europe/Malta", + "Europe/Amsterdam", + "Europe/Warsaw", + "Atlantic/Azores", + "Atlantic/Madeira", + "Europe/Lisbon", + "Europe/Bucharest", + "Europe/Bratislava", + "Europe/Ljubljana", + "Africa/Ceuta", + "Atlantic/Canary", + "Europe/Madrid", + "Europe/Stockholm", +]; + +function isEuropeanUnion() { + try { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return EU_TIMEZONES.includes(timeZone); + } catch (e) { + // Fallback if Intl API is not supported + return false; + } +} + + +function positionRelativeTo(id, x, y, corner) { + const element = document.getElementById(id); + if (!element) + return; + + const viewRect = element.getBoundingClientRect(); + const width = viewRect.width; + const height = viewRect.height; + + if (corner === "bottomLeft") { + element.style.top = `${y - height}px`; + element.style.left = `${x}px`; + } + else if (corner === "bottomRight") { + element.style.top = `${y - height}px`; + element.style.left = `${x - width}px`; + } + + // Prevent escaping screen + const rect = element.getBoundingClientRect(); + if (rect.left < 16) { + element.style.left = `16px`; + } + if (rect.top < 16) { + element.style.top = `16px`; + } + if (rect.right > window.innerWidth - 16) { + element.style.left = `${window.innerWidth - width - 16}px`; + } + if (rect.bottom > window.innerHeight - 16) { + element.style.top = `${window.innerHeight - height - 16}px`; + } +} + const trustedEmbedScriptHosts = new Set([ "platform.twitter.com", "embed.reddit.com", @@ -528,13 +528,13 @@ async function injectTwitter(id, data) { twitterScript.async = true; twitterScript.charset = "utf-8"; container.appendChild(twitterScript); -} - -async function injectReddit(id, data) { - const container = document.getElementById(id); - if (!container) { - return; - } +} + +async function injectReddit(id, data) { + const container = document.getElementById(id); + if (!container) { + return; + } container.setAttribute('data-embed-theme', 'dark'); container.innerHTML = sanitizeEmbedHtml(data); @@ -549,12 +549,12 @@ async function injectReddit(id, data) { redditScript.async = true; redditScript.charset = "utf-8"; container.appendChild(redditScript); -} - -// Generic embed injection function for oEmbed-based embeds -// Injects HTML content and optionally loads an external script -async function injectEmbed(id, html, scriptSrc) { - const container = document.getElementById(id); +} + +// Generic embed injection function for oEmbed-based embeds +// Injects HTML content and optionally loads an external script +async function injectEmbed(id, html, scriptSrc) { + const container = document.getElementById(id); if (!container) { return; } @@ -570,21 +570,21 @@ async function injectEmbed(id, html, scriptSrc) { // Check if script is already loaded const existingScript = document.querySelector(`script[src="${scriptSrc}"]`); if (!existingScript) { - const script = document.createElement('script'); - script.src = scriptSrc; - script.async = true; - script.charset = "utf-8"; - container.appendChild(script); - } else { - // Script already exists, try to re-process embeds if possible - // TikTok uses window.tiktokEmbed?.lib?.render() - if (scriptSrc.includes('tiktok') && window.tiktokEmbed?.lib?.render) { - window.tiktokEmbed.lib.render(); - } - } - } -} - + const script = document.createElement('script'); + script.src = scriptSrc; + script.async = true; + script.charset = "utf-8"; + container.appendChild(script); + } else { + // Script already exists, try to re-process embeds if possible + // TikTok uses window.tiktokEmbed?.lib?.render() + if (scriptSrc.includes('tiktok') && window.tiktokEmbed?.lib?.render) { + window.tiktokEmbed.lib.render(); + } + } + } +} + function playLottie(element) { element.play(); } diff --git a/Valour/Client/wwwroot/ts/UploadService.js b/Valour/Client/wwwroot/ts/UploadService.js new file mode 100644 index 000000000..063c0b8a2 --- /dev/null +++ b/Valour/Client/wwwroot/ts/UploadService.js @@ -0,0 +1,60 @@ +/** + * UploadService JS module + * Provides real XHR upload progress + cancel support for Blazor WASM. + * Each call to start() returns an upload handle with its own XHR and abort function. + */ + +/** + * @param {string} url - Upload endpoint + * @param {Uint8Array} byteArray - File data + * @param {string} mimeType - MIME type + * @param {string} fileName - File name + * @param {object} dotnetRef - DotNetObjectReference for callbacks + * @param {string} authToken - Optional Authorization header value + * @returns {object} Upload handle with an abort() method + */ +export function start(url, byteArray, mimeType, fileName, dotnetRef, authToken) { + const xhr = new XMLHttpRequest(); + + const handle = { + abort: () => xhr.abort() + }; + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + dotnetRef.invokeMethodAsync('NotifyUploadProgress', e.loaded, e.total); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + dotnetRef.invokeMethodAsync('NotifyUploadComplete', xhr.responseText); + } else if (xhr.status === 421) { + dotnetRef.invokeMethodAsync('NotifyUploadMisdirect', xhr.responseText, xhr.status); + } else { + dotnetRef.invokeMethodAsync('NotifyUploadError', `${xhr.status}: ${xhr.responseText}`); + } + }); + + xhr.addEventListener('error', () => { + dotnetRef.invokeMethodAsync('NotifyUploadError', 'Network error during upload'); + }); + + xhr.addEventListener('abort', () => { + dotnetRef.invokeMethodAsync('NotifyUploadCancelled'); + }); + + xhr.open('POST', url); + + if (authToken) { + xhr.setRequestHeader('Authorization', authToken); + } + + const blob = new Blob([byteArray], { type: mimeType || 'application/octet-stream' }); + const formData = new FormData(); + formData.append(fileName || 'file', blob, fileName || 'file'); + + xhr.send(formData); + + return handle; +}