From 82223dc6368727e94aeb4ee8c779a75c77b50ba7 Mon Sep 17 00:00:00 2001 From: David Bond Date: Mon, 20 Jul 2026 14:17:35 +0100 Subject: [PATCH] PDChat: allow disabling the message input with an optional explanatory message (fixes #82) Add IsInputPermitted (default true) and InputDisabledMessage (default null) as default-implemented IChatService members so existing implementers are unaffected. When IsInputPermitted is false, PDMessages renders no input; if InputDisabledMessage is set, it is shown in the input's place, otherwise the chat becomes a pure notifications-only ("toast-only") surface. Plumbed through PDChat into PDMessages, with CanSend/CanSendLocal gated accordingly, scoped CSS for the disabled-input message (light + dark), and a concrete DumbChatService implementation plus live demo controls. Note: this commit also carries the in-progress toast-API work that was already present in the working tree (richer Toast* members on IChatService, toast stack rendering in PDChat, ChatToastOptions/PDChatToastAnimation models, demo docs). Co-Authored-By: Claude Opus 4.8 (1M context) --- ComponentDocumentation.md | 2 + .../Pages/PDChatDocumentation.razor | 42 +++ .../Pages/PDChatPage.razor | 125 +++++++- .../Pages/PDChatPage.razor.cs | 59 +++- .../Interfaces/IChatService.cs | 148 +++++++++ PanoramicData.Blazor/Models/ChatMessage.cs | 8 + .../Models/ChatToastOptions.cs | 40 +++ .../Models/PDChatToastAnimation.cs | 24 ++ PanoramicData.Blazor/PDChat.razor | 58 +++- PanoramicData.Blazor/PDChat.razor.cs | 303 +++++++++++++++--- PanoramicData.Blazor/PDChat.razor.css | 207 +++++++++++- PanoramicData.Blazor/PDMessages.razor | 29 +- PanoramicData.Blazor/PDMessages.razor.cs | 16 +- PanoramicData.Blazor/PDMessages.razor.css | 20 ++ .../Services/DumbChatService.cs | 229 ++++++++++++- 15 files changed, 1207 insertions(+), 103 deletions(-) create mode 100644 PanoramicData.Blazor/Models/ChatToastOptions.cs create mode 100644 PanoramicData.Blazor/Models/PDChatToastAnimation.cs diff --git a/ComponentDocumentation.md b/ComponentDocumentation.md index 9b8abe92f..1bd2649d0 100644 --- a/ComponentDocumentation.md +++ b/ComponentDocumentation.md @@ -225,6 +225,8 @@ This component has no public parameters. | OnMessageReceivedEvent | EventCallback | An event callback that is invoked when a message is received. | | OnAutoRestored | EventCallback | An event callback that is invoked when the chat window is automatically restored. | +**Toast notifications:** PDChat can double as a toast surface. When a message arrives while the chat is closed (minimized, or fully hidden when `MinimizedButtonPosition` is `None`), it animates into view and, if auto-dismiss is enabled, animates out after a configurable time. Toasts stack (oldest at the top) and each runs its own independent dismiss timer. Defaults are set on `IChatService` via the `Toast*` members (`ToastEnabled`, `ToastEntryAnimation`, `ToastExitAnimation`, `ToastAnimationDurationMs`, `ToastAutoDismiss`, `ToastDisplayDurationSeconds`, `ToastShowTitle`, `ToastMinWidth`/`ToastMaxWidth`/`ToastMinHeight`/`ToastMaxHeight`, `ToastMaxVisible`, `ToastAnchor`) and can be overridden per message via `ChatMessage.ToastOptions`. The legacy `ShowLastMessage` / `ShowLastMessageDurationSeconds` members are obsolete and map onto `ToastEnabled` / `ToastDisplayDurationSeconds`. + --- ## PDChatContainer diff --git a/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor index a2338eaa9..b0513c3d1 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDChatDocumentation.razor @@ -13,4 +13,46 @@ +
+

Toast notifications

+

+ PDChat can double as a toast surface. When a message arrives while the chat is + closed — minimized to its button, or fully hidden when + MinimizedButtonPosition is None (a headless toast) — the message + animates into view. It stays for a configurable time and then, if auto-dismiss is enabled, + animates out again. +

+

+ Multiple toasts stack (oldest at the top, newest at the bottom) and each runs + its own independent dismiss timer, so a longer-lived toast can outlast a later, shorter one. + While more than one toast is present, a dismissing toast leaves via a fixed de-stack animation; + the last remaining toast uses its own configured exit animation. Clicking a toast opens the + chat; hovering pauses its dismiss timer. +

+

+ Defaults are configured on IChatService via the Toast* members and can + be overridden per message with ChatMessage.ToastOptions (any unset override falls + back to the service default). +

+ + + + + + + + + + + + + + + +
IChatService memberTypeDefaultDescription
ToastEnabledbooltrueMaster switch (supersedes ShowLastMessage).
ToastEntryAnimationPDChatToastAnimationGrowAnimation used when a toast appears.
ToastExitAnimationPDChatToastAnimationShrinkAnimation used when a toast is dismissed.
ToastAnimationDurationMsdouble250Entry / exit transition time in milliseconds.
ToastAutoDismissbooltrueWhether toasts auto-dismiss after the display duration.
ToastDisplayDurationSecondsdouble5Dwell time before auto-dismiss (supersedes ShowLastMessageDurationSeconds).
ToastShowTitlebooltrueWhether the message title is shown in the toast.
ToastMinWidth / ToastMaxWidthstring200px / 300pxToast width constraints (any CSS length).
ToastMinHeight / ToastMaxHeightstring(auto)Toast height constraints (any CSS length).
ToastMaxVisibleint5Max toasts shown at once; the oldest is dismissed to make room.
ToastAnchorPDChatButtonPositionBottomRightCorner the stack anchors to when the button is hidden (otherwise it follows the button).
+

+ ShowLastMessage and ShowLastMessageDurationSeconds are obsolete and map onto + ToastEnabled / ToastDisplayDurationSeconds for backward compatibility. +

+
diff --git a/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor b/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor index 9a90fdda1..1cc1fb120 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor @@ -133,12 +133,99 @@
Examples: HH:mm:ss, yyyy-MM-dd HH:mm, dd/MM/yyyy HH:mm:ss
-
- - -
How long the message preview shows when the chat is minimized (1-30 seconds)
+
+
Toast Notifications
+
PDChat can double as a toast surface: messages arriving while the chat is closed animate in (and, when the chat button is hidden, appear headless). Minimize the chat, then use the buttons on the right.
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
@@ -160,20 +247,20 @@
- - + +
- - + +
- - + +
@@ -195,6 +282,12 @@
+ +
+ + +
Uncheck "Enable Message Input" above to see this. Leave blank for pure toast-only mode (the input simply disappears with no message).
+
@@ -250,7 +343,17 @@
+
+
+ +
+
+
diff --git a/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor.cs b/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor.cs index 440e1973e..dab8bac23 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor.cs +++ b/PanoramicData.Blazor.Demo/Pages/PDChatPage.razor.cs @@ -201,11 +201,11 @@ private async Task TestMessagePreview() // Force chat to minimized state first ChatService.PreferredDockMode = PDChatDockMode.Minimized; await Task.Delay(100); - + var message = new ChatMessage { Id = Guid.NewGuid(), - Message = "👀 This message demonstrates the message preview feature! If enabled, you should see this message slide out from the chat button for a few seconds.", + Message = "👀 This message demonstrates the toast feature! With the chat closed, it animates in using the configured entry animation and auto-dismisses after the display duration.", Sender = Bot, Type = MessageType.Normal, Timestamp = DateTime.UtcNow @@ -213,6 +213,61 @@ private async Task TestMessagePreview() ChatService.SendMessage(message); } + // Sends three toasts in quick succession with different display durations to demonstrate the + // stacking behaviour: oldest at the top, each dismissing on its own independent timer. + private async Task TestToastStack() + { + ChatService.PreferredDockMode = PDChatDockMode.Minimized; + await Task.Delay(100); + + var specs = new (string Text, MessageType Type, double Seconds)[] + { + ("🥇 First toast — stays 12 seconds (oldest, at the top).", MessageType.Normal, 12), + ("🥈 Second toast — stays 6 seconds.", MessageType.Warning, 6), + ("🥉 Third toast — stays 2 seconds (dismisses first, de-stacking the others).", MessageType.Success, 2), + }; + + foreach (var spec in specs) + { + ChatService.SendMessage(new ChatMessage + { + Id = Guid.NewGuid(), + Message = spec.Text, + Sender = Bot, + Type = spec.Type, + Timestamp = DateTime.UtcNow, + ToastOptions = new ChatToastOptions { DisplayDurationSeconds = spec.Seconds } + }); + + await Task.Delay(300); + } + } + + // Sends a single toast that overrides the service defaults on a per-message basis. + private async Task TestToastOverride() + { + ChatService.PreferredDockMode = PDChatDockMode.Minimized; + await Task.Delay(100); + + ChatService.SendMessage(new ChatMessage + { + Id = Guid.NewGuid(), + Title = "Per-message override", + Message = "This toast overrides the defaults: it slides in and out, stays for 12 seconds, and uses a wider max-width — regardless of the service-level toast settings.", + Sender = Bot, + Type = MessageType.Normal, + Timestamp = DateTime.UtcNow, + ToastOptions = new ChatToastOptions + { + EntryAnimation = PDChatToastAnimation.Slide, + ExitAnimation = PDChatToastAnimation.Slide, + DisplayDurationSeconds = 12, + AnimationDurationMs = 350, + MaxWidth = "360px" + } + }); + } + private void SendHtmlMessage() { var message = new ChatMessage diff --git a/PanoramicData.Blazor/Interfaces/IChatService.cs b/PanoramicData.Blazor/Interfaces/IChatService.cs index 6a2789652..e42bef8be 100644 --- a/PanoramicData.Blazor/Interfaces/IChatService.cs +++ b/PanoramicData.Blazor/Interfaces/IChatService.cs @@ -61,6 +61,29 @@ public interface IChatService /// bool IsClearPermitted { get; set; } + /// + /// Gets or sets whether the user may type and send messages. + /// When false, the message input (text area and send button) is not rendered and + /// the chat acts as a notifications-only ("toast-only") surface. Defaults to true. + /// + bool IsInputPermitted + { + get => true; + set { } + } + + /// + /// Gets or sets an optional message shown where the input would normally appear when + /// is false, e.g. to explain why sending is unavailable. + /// When null or empty (the default), nothing is rendered in place of the input, giving a + /// pure notifications-only surface. The message is rendered as plain text. + /// + string? InputDisabledMessage + { + get => null; + set { } + } + /// /// Gets or sets whether the chat should auto-restore when new messages arrive. /// @@ -99,14 +122,139 @@ public interface IChatService /// /// Gets or sets whether the last message should be shown as a preview when the chat is minimized and a new message arrives. /// + [Obsolete("Superseded by the richer toast API. Use " + nameof(ToastEnabled) + " instead. This member maps onto " + nameof(ToastEnabled) + " for backward compatibility.")] bool ShowLastMessage { get; set; } /// /// Gets or sets the duration in seconds for which the last message preview is shown before automatically hiding. /// Default is 5 seconds. /// + [Obsolete("Superseded by the richer toast API. Use " + nameof(ToastDisplayDurationSeconds) + " instead. This member maps onto " + nameof(ToastDisplayDurationSeconds) + " for backward compatibility.")] double ShowLastMessageDurationSeconds { get; set; } + // ========================================================================================== + // Toast notification API + // + // PDChat can double as a toast surface: when a message arrives while the chat is "closed" + // (minimized to its button, or fully hidden when MinimizedButtonPosition is None) the chat + // animates the message into view, optionally auto-dismissing after a configurable time. + // Toasts stack (oldest at the top) and each runs its own independent dismiss timer. + // + // All members below carry default interface implementations so existing IChatService + // implementations continue to compile without change. ToastEnabled / ToastDisplayDurationSeconds + // default to the legacy ShowLastMessage / ShowLastMessageDurationSeconds members so that the + // previous behaviour is preserved for consumers that have not yet migrated. + // ========================================================================================== + + /// + /// Gets or sets whether PDChat shows arriving messages as animated toasts while it is closed. + /// Supersedes ; defaults to that member's value for backward compatibility. + /// + bool ToastEnabled + { +#pragma warning disable CS0618 // Legacy member intentionally used as the default backing value. + get => ShowLastMessage; + set => ShowLastMessage = value; +#pragma warning restore CS0618 + } + + /// Gets or sets the default animation used when a toast appears. Defaults to . + PDChatToastAnimation ToastEntryAnimation + { + get => PDChatToastAnimation.Grow; + set { } + } + + /// Gets or sets the default animation used when a toast is dismissed. Defaults to . + PDChatToastAnimation ToastExitAnimation + { + get => PDChatToastAnimation.Shrink; + set { } + } + + /// Gets or sets the default duration, in milliseconds, of the toast entry / exit transitions. Defaults to 250ms. + double ToastAnimationDurationMs + { + get => 250d; + set { } + } + + /// Gets or sets whether toasts auto-dismiss after . Defaults to true. + bool ToastAutoDismiss + { + get => true; + set { } + } + + /// + /// Gets or sets how long, in seconds, a toast stays on screen before auto-dismissing. + /// Supersedes ; defaults to that member's value for backward compatibility. + /// + double ToastDisplayDurationSeconds + { +#pragma warning disable CS0618 // Legacy member intentionally used as the default backing value. + get => ShowLastMessageDurationSeconds; + set => ShowLastMessageDurationSeconds = value; +#pragma warning restore CS0618 + } + + /// Gets or sets whether the message title is shown in toasts by default. Defaults to true. + bool ToastShowTitle + { + get => true; + set { } + } + + /// Gets or sets the default toast minimum width (any valid CSS length). Defaults to "200px". + string ToastMinWidth + { + get => "200px"; + set { } + } + + /// Gets or sets the default toast maximum width (any valid CSS length). Defaults to "300px". + string ToastMaxWidth + { + get => "300px"; + set { } + } + + /// Gets or sets the default toast minimum height (any valid CSS length). Empty means unconstrained. + string ToastMinHeight + { + get => string.Empty; + set { } + } + + /// Gets or sets the default toast maximum height (any valid CSS length). Empty means unconstrained. + string ToastMaxHeight + { + get => string.Empty; + set { } + } + + /// + /// Gets or sets the maximum number of toasts visible at once. When a new toast would exceed this, + /// the oldest is dismissed to make room. Defaults to 5. + /// + int ToastMaxVisible + { + get => 5; + set { } + } + + /// + /// Gets or sets the corner the toast stack anchors to when the chat is fully hidden + /// ( is ). + /// When the minimized button is visible, the stack follows the button's position instead. + /// Defaults to . + /// + PDChatButtonPosition ToastAnchor + { + get => PDChatButtonPosition.BottomRight; + set { } + } + /// /// Gets the current list of chat messages. /// diff --git a/PanoramicData.Blazor/Models/ChatMessage.cs b/PanoramicData.Blazor/Models/ChatMessage.cs index 7f999912a..7785c2128 100644 --- a/PanoramicData.Blazor/Models/ChatMessage.cs +++ b/PanoramicData.Blazor/Models/ChatMessage.cs @@ -30,4 +30,12 @@ public class ChatMessage() /// Gets or sets the UTC timestamp when the message was created. Defaults to . public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Gets or sets optional per-message overrides for the toast shown when this message arrives while the + /// chat is closed. When null, the service-level Toast* defaults on + /// are used. Individual override properties that are left null + /// also fall back to the service defaults. + /// + public ChatToastOptions? ToastOptions { get; set; } } diff --git a/PanoramicData.Blazor/Models/ChatToastOptions.cs b/PanoramicData.Blazor/Models/ChatToastOptions.cs new file mode 100644 index 000000000..f7b4f0e5e --- /dev/null +++ b/PanoramicData.Blazor/Models/ChatToastOptions.cs @@ -0,0 +1,40 @@ +namespace PanoramicData.Blazor.Models; + +/// +/// Per-message overrides for the toast behaviour. +/// Every property is nullable: a null value means "fall back to the service default" +/// (the corresponding Toast* property on ). +/// Attach an instance to to override the toast shown for that message. +/// +public class ChatToastOptions +{ + /// Overrides the animation used when the toast appears. Default entry is . + public PDChatToastAnimation? EntryAnimation { get; set; } + + /// Overrides the animation used when the toast is dismissed. Default exit is . + public PDChatToastAnimation? ExitAnimation { get; set; } + + /// Overrides the duration, in milliseconds, of the entry / exit transitions. + public double? AnimationDurationMs { get; set; } + + /// Overrides whether the toast automatically dismisses after . + public bool? AutoDismiss { get; set; } + + /// Overrides how long, in seconds, the toast stays on screen before auto-dismissing. + public double? DisplayDurationSeconds { get; set; } + + /// Overrides whether the message title is shown in the toast. + public bool? ShowTitle { get; set; } + + /// Overrides the toast minimum width (any valid CSS length, e.g. "200px"). + public string? MinWidth { get; set; } + + /// Overrides the toast maximum width (any valid CSS length, e.g. "300px"). + public string? MaxWidth { get; set; } + + /// Overrides the toast minimum height (any valid CSS length). + public string? MinHeight { get; set; } + + /// Overrides the toast maximum height (any valid CSS length). + public string? MaxHeight { get; set; } +} diff --git a/PanoramicData.Blazor/Models/PDChatToastAnimation.cs b/PanoramicData.Blazor/Models/PDChatToastAnimation.cs new file mode 100644 index 000000000..515f602cb --- /dev/null +++ b/PanoramicData.Blazor/Models/PDChatToastAnimation.cs @@ -0,0 +1,24 @@ +namespace PanoramicData.Blazor.Models; + +/// +/// Identifies the animation used when a toast enters or leaves the screen. +/// The same catalogue is used for both the entry and exit phase; the meaning is direction-aware +/// (for example, grows on entry and the mirror shrink on exit). +/// +public enum PDChatToastAnimation +{ + /// No animation; the toast appears and disappears instantly. + None = 0, + + /// The toast fades in / out via opacity only. + Fade = 1, + + /// The toast grows from a small scale to full size (default entry animation). + Grow = 2, + + /// The toast shrinks to a small scale (default exit animation). + Shrink = 3, + + /// The toast slides in from / out towards the anchored edge. + Slide = 4 +} diff --git a/PanoramicData.Blazor/PDChat.razor b/PanoramicData.Blazor/PDChat.razor index 6ea9cbba2..757e57ce0 100644 --- a/PanoramicData.Blazor/PDChat.razor +++ b/PanoramicData.Blazor/PDChat.razor @@ -17,36 +17,54 @@ @GetPriorityIndicator() } - - @if (_showMessagePreview && _lastMessage != null && ChatService.ShowLastMessage) +
+ } + + @* Toast stack: shown whenever there are live toasts, whether or not the minimized button + is visible. When the button is hidden (MinimizedButtonPosition.None) this is a headless toast. *@ + @if (_toasts.Count > 0) + { +
+ @foreach (var toast in _toasts) { -
+
+
- @_lastMessage.Sender.Name - @_lastMessage.Timestamp.ToString("HH:mm") + @toast.Message.Sender.Name + @toast.Message.Timestamp.ToString("HH:mm")
- @if (!string.IsNullOrEmpty(_lastMessage.Title)) + @if (toast.ShowTitle && !string.IsNullOrEmpty(toast.Message.Title)) {
- @if (_lastMessage.IsTitleHtml) + @if (toast.Message.IsTitleHtml) { - @((MarkupString)_lastMessage.Title) + @((MarkupString)toast.Message.Title) } else { - @_lastMessage.Title + @toast.Message.Title }
}
- @if (_lastMessage.IsMessageHtml) - { - @((MarkupString)_lastMessage.Message) - } - else - { - @_lastMessage.Message - } + @if (toast.Message.IsMessageHtml) + { + @((MarkupString)toast.Message.Message) + } + else + { + @toast.Message.Message + }
} @@ -103,6 +121,8 @@ CurrentInputChanged="@((string v) => _currentInput = v)" IsLive="@ChatService.IsLive" CanSend="@CanSend" + IsInputPermitted="@ChatService.IsInputPermitted" + InputDisabledMessage="@ChatService.InputDisabledMessage" OnSendClicked="SendCurrentMessageAsync" UserIconSelector="UserIconSelector" UseFullWidthMessages="@ChatService.UseFullWidthMessages" @@ -142,6 +162,8 @@ CurrentInputChanged="@((string v) => _currentInput = v)" IsLive="@ChatService.IsLive" CanSend="@CanSend" + IsInputPermitted="@ChatService.IsInputPermitted" + InputDisabledMessage="@ChatService.InputDisabledMessage" OnSendClicked="SendCurrentMessageAsync" UserIconSelector="UserIconSelector" UseFullWidthMessages="@ChatService.UseFullWidthMessages" @@ -162,6 +184,8 @@ CurrentInputChanged="@((string v) => _currentInput = v)" IsLive="@ChatService.IsLive" CanSend="@CanSend" + IsInputPermitted="@ChatService.IsInputPermitted" + InputDisabledMessage="@ChatService.InputDisabledMessage" OnSendClicked="SendCurrentMessageAsync" UserIconSelector="UserIconSelector" UseFullWidthMessages="@ChatService.UseFullWidthMessages" diff --git a/PanoramicData.Blazor/PDChat.razor.cs b/PanoramicData.Blazor/PDChat.razor.cs index 21ac65caa..0f62bd7cc 100644 --- a/PanoramicData.Blazor/PDChat.razor.cs +++ b/PanoramicData.Blazor/PDChat.razor.cs @@ -109,9 +109,13 @@ public partial class PDChat : JSModuleComponentBase private MessageType _highestPriorityUnreadMessage = MessageType.Normal; private DateTimeOffset _lastReadTimestamp = DateTimeOffset.UtcNow; private string _currentInput = ""; - private bool _showMessagePreview; - private ChatMessage? _lastMessage; - private Timer? _messagePreviewTimer; + + // Fixed duration, in milliseconds, of the "de-stack" animation used when a toast leaves a stack + // that still contains other toasts. Deliberately uniform and independent of any per-message + // animation configuration (see the toast stacking rules). + private const double DeStackDurationMs = 250d; + + private readonly List _toasts = []; private readonly List _messages = []; private PDTabSet? _tabSetRef; @@ -236,15 +240,13 @@ private async Task OnMessageReceivedAsync(ChatMessage message) UpdateHighestPriorityUnreadMessage(); } - // Show message preview if enabled and it's a new non-typing message - if (ChatService.ShowLastMessage && isNewMessage && message.Type != MessageType.Typing) - { - await ShowMessagePreviewAsync(message); - } + var isToastable = isNewMessage && message.Type != MessageType.Typing; - // Optionally auto-restore the chat when new messages arrive - if (ChatService.AutoRestoreOnNewMessage && isNewMessage && message.Type != MessageType.Typing) + // Auto-restore takes priority over toasts: if the chat is going to open anyway there is no + // point showing a toast for the same message. + if (ChatService.AutoRestoreOnNewMessage && isToastable) { + ClearToasts(); await ChangeDockModeAsync(ChatService.RestoreMode); _unreadMessages = false; _highestPriorityUnreadMessage = MessageType.Normal; @@ -255,6 +257,12 @@ private async Task OnMessageReceivedAsync(ChatMessage message) await OnAutoRestored.InvokeAsync(); } } + else if (ChatService.ToastEnabled && isToastable) + { + // Show the message as an animated toast. This works whether or not the minimized + // button is visible (MinimizedButtonPosition.None => headless toast). + await ShowToastAsync(message); + } } // Get the sound to play, if any @@ -276,40 +284,157 @@ private async Task OnMessageReceivedAsync(ChatMessage message) await InvokeAsync(StateHasChanged); } - private async Task ShowMessagePreviewAsync(ChatMessage message) + /// + /// Adds a new toast for the supplied message, resolving per-message overrides against the + /// service-level defaults, enforcing the visible-toast cap, and starting its auto-dismiss timer. + /// + private async Task ShowToastAsync(ChatMessage message) { - // Cancel any existing timer - if (_messagePreviewTimer is not null) + var o = message.ToastOptions; + + var item = new ToastItem + { + Message = message, + EntryAnimation = o?.EntryAnimation ?? ChatService.ToastEntryAnimation, + ExitAnimation = o?.ExitAnimation ?? ChatService.ToastExitAnimation, + AnimationDurationMs = o?.AnimationDurationMs ?? ChatService.ToastAnimationDurationMs, + AutoDismiss = o?.AutoDismiss ?? ChatService.ToastAutoDismiss, + DisplayDurationSeconds = o?.DisplayDurationSeconds ?? ChatService.ToastDisplayDurationSeconds, + ShowTitle = o?.ShowTitle ?? ChatService.ToastShowTitle, + MinWidth = o?.MinWidth ?? ChatService.ToastMinWidth, + MaxWidth = o?.MaxWidth ?? ChatService.ToastMaxWidth, + MinHeight = o?.MinHeight ?? ChatService.ToastMinHeight, + MaxHeight = o?.MaxHeight ?? ChatService.ToastMaxHeight, + }; + + // Enforce the visible-toast cap by dismissing the oldest still-present toasts. + var maxVisible = Math.Max(1, ChatService.ToastMaxVisible); + var overflow = _toasts.Count(t => !t.IsExiting) - (maxVisible - 1); + for (var i = 0; overflow > 0 && i < _toasts.Count; i++) { - await _messagePreviewTimer.DisposeAsync(); + if (!_toasts[i].IsExiting) + { + BeginDismiss(_toasts[i]); + overflow--; + } } - // Set the message to show - _lastMessage = message; - _showMessagePreview = true; + // Newest is added at the bottom of the stack (oldest remains at the top). + _toasts.Add(item); await InvokeAsync(StateHasChanged); - // Set a timer to hide the message after the configured duration - _messagePreviewTimer = new Timer(_ => + if (item.AutoDismiss && item.DisplayDurationSeconds > 0) + { + StartDismissTimer(item, item.DisplayDurationSeconds * 1000d); + } + } + + // (Re)starts the auto-dismiss timer for a toast, tracking the start time so hover-pause can + // compute the remaining time accurately. + private void StartDismissTimer(ToastItem item, double remainingMs) + { + item.DismissTimer?.Dispose(); + item.RemainingMs = remainingMs; + item.StartedTicks = Environment.TickCount64; + item.Paused = false; + item.DismissTimer = new Timer( + _ => _ = InvokeAsync(() => BeginDismiss(item)), + null, + TimeSpan.FromMilliseconds(remainingMs), + Timeout.InfiniteTimeSpan); + } + + // Pauses a toast's auto-dismiss countdown (e.g. while the pointer hovers over it). + private static void PauseToast(ToastItem item) + { + if (item.IsExiting || item.Paused || item.DismissTimer is null) { - // Use InvokeAsync to ensure we're on the UI thread - _ = InvokeAsync(() => + return; + } + + var elapsed = Environment.TickCount64 - item.StartedTicks; + item.RemainingMs = Math.Max(0, item.RemainingMs - elapsed); + item.DismissTimer.Dispose(); + item.DismissTimer = null; + item.Paused = true; + } + + // Resumes a paused toast's auto-dismiss countdown from where it left off. + private void ResumeToast(ToastItem item) + { + if (item.IsExiting || !item.Paused) + { + return; + } + + if (item.RemainingMs <= 0) + { + BeginDismiss(item); + return; + } + + StartDismissTimer(item, item.RemainingMs); + } + + // Begins the exit of a toast. If other toasts remain in the stack it leaves via the fixed + // de-stack animation; if it is the last one it uses its own configured exit animation. + private void BeginDismiss(ToastItem item) + { + if (item.IsExiting) + { + return; + } + + item.DismissTimer?.Dispose(); + item.DismissTimer = null; + + var othersRemain = _toasts.Any(t => t != item && !t.IsExiting); + item.IsExiting = true; + item.IsDeStacking = othersRemain; + + var exitMs = othersRemain ? DeStackDurationMs : item.AnimationDurationMs; + + item.RemovalTimer?.Dispose(); + item.RemovalTimer = new Timer( + _ => _ = InvokeAsync(() => { - _showMessagePreview = false; - _lastMessage = null; + item.RemovalTimer?.Dispose(); + item.RemovalTimer = null; + _toasts.Remove(item); StateHasChanged(); - }); - }, null, TimeSpan.FromSeconds(ChatService.ShowLastMessageDurationSeconds), Timeout.InfiniteTimeSpan); + }), + null, + TimeSpan.FromMilliseconds(Math.Max(1, exitMs)), + Timeout.InfiniteTimeSpan); + + _ = InvokeAsync(StateHasChanged); + } + + // Disposes every toast timer and clears the stack immediately (no exit animation). + private void ClearToasts() + { + foreach (var t in _toasts) + { + t.DismissTimer?.Dispose(); + t.RemovalTimer?.Dispose(); + } + + _toasts.Clear(); + } + + // Invoked when a toast is clicked: opens the chat and clears the stack. + private async Task OnToastClickedAsync() + { + ClearToasts(); + await ToggleChatAsync(); } private async Task ToggleChatAsync() { if (ChatService.DockMode == PDChatDockMode.Minimized) { - // Hide message preview when opening chat - _showMessagePreview = false; - _lastMessage = null; - _messagePreviewTimer?.Dispose(); + // Dismiss any toasts when opening chat + ClearToasts(); // Restore to last normal state await ChangeDockModeAsync(ChatService.RestoreMode); @@ -443,7 +568,7 @@ private async Task SendCurrentMessageAsync() _messagesComponent?.ClearInput(); } - private bool CanSend => ChatService.IsLive && !string.IsNullOrWhiteSpace(_currentInput); + private bool CanSend => ChatService.IsInputPermitted && ChatService.IsLive && !string.IsNullOrWhiteSpace(_currentInput); private void OnTabAdded() { @@ -631,40 +756,116 @@ public override async ValueTask DisposeAsync() ChatService.OnMuteStatusChanged -= OnServiceMuteStatusChanged; ChatService.OnConfigurationChanged -= OnServiceConfigurationChanged; - // Clean up timer - if (_messagePreviewTimer is not null) - { - await _messagePreviewTimer.DisposeAsync(); - } + // Clean up toast timers + ClearToasts(); await base.DisposeAsync(); GC.SuppressFinalize(this); } - // Helper method to determine if position is on the right side - private bool IsPositionOnRight() + // Maps a message type to its toast colour-scheme class (shared with the legacy preview styles). + private static string GetToastTypeClass(MessageType type) => type switch + { + MessageType.Warning => "preview-warning", + MessageType.Error => "preview-error", + MessageType.Critical => "preview-critical", + MessageType.Success => "preview-success", + _ => "preview-normal" + }; + + // Builds the full class list for a toast card: colour scheme, anchor side, and the current + // entry / exit / de-stack animation state. + private static string GetToastClasses(ToastItem item) + { + var animationClass = item.IsDeStacking + ? "toast-destack" + : item.IsExiting + ? $"toast-exit-{GetAnimationName(item.ExitAnimation)}" + : $"toast-enter-{GetAnimationName(item.EntryAnimation)}"; + + return $"{GetToastTypeClass(item.Message.Type)} {animationClass}"; + } + + // Inline style carrying the resolved dimensions and the per-toast animation duration. + private static string GetToastStyle(ToastItem item) { - return ChatService.MinimizedButtonPosition is - PDChatButtonPosition.BottomRight or - PDChatButtonPosition.TopRight; + var durationMs = item.IsDeStacking ? DeStackDurationMs : item.AnimationDurationMs; + var sb = new System.Text.StringBuilder(); + sb.Append("--pdchat-toast-anim-ms:").Append(durationMs.ToString(System.Globalization.CultureInfo.InvariantCulture)).Append("ms;"); + AppendStyle(sb, "min-width", item.MinWidth); + AppendStyle(sb, "max-width", item.MaxWidth); + AppendStyle(sb, "min-height", item.MinHeight); + AppendStyle(sb, "max-height", item.MaxHeight); + return sb.ToString(); } - // Helper method to get preview animation class based on priority - private string GetPreviewAnimationClass() + private static void AppendStyle(System.Text.StringBuilder sb, string name, string? value) { - if (_lastMessage == null) + if (!string.IsNullOrWhiteSpace(value)) { - return string.Empty; + sb.Append(name).Append(':').Append(value).Append(';'); } + } - return _lastMessage.Type switch - { - MessageType.Warning => "preview-warning", - MessageType.Error => "preview-error", - MessageType.Critical => "preview-critical", - MessageType.Success => "preview-success", - _ => "preview-normal" + private static string GetAnimationName(PDChatToastAnimation animation) => animation switch + { + PDChatToastAnimation.None => "none", + PDChatToastAnimation.Fade => "fade", + PDChatToastAnimation.Grow => "grow", + PDChatToastAnimation.Shrink => "shrink", + PDChatToastAnimation.Slide => "slide", + _ => "grow" + }; + + // Resolves the corner the toast stack anchors to: follow the minimized button when it is shown, + // otherwise fall back to the configured headless anchor. + private string GetToastAnchorClass() + { + var anchor = ChatService.MinimizedButtonPosition == PDChatButtonPosition.None + ? ChatService.ToastAnchor + : ChatService.MinimizedButtonPosition; + + return anchor switch + { + PDChatButtonPosition.TopLeft => "toast-anchor-top-left", + PDChatButtonPosition.TopRight => "toast-anchor-top-right", + PDChatButtonPosition.BottomLeft => "toast-anchor-bottom-left", + PDChatButtonPosition.BottomRight => "toast-anchor-bottom-right", + PDChatButtonPosition.None => "toast-anchor-bottom-right", + _ => "toast-anchor-bottom-right" }; } + + // aria-live politeness: escalate to assertive for the most severe message types. + private static string GetToastAriaLive(MessageType type) + => type is MessageType.Error or MessageType.Critical ? "assertive" : "polite"; + + /// + /// Represents a single live toast instance in the stack, together with its resolved presentation + /// options and auto-dismiss timers. + /// + private sealed class ToastItem + { + public Guid Key { get; } = Guid.NewGuid(); + public required ChatMessage Message { get; init; } + public PDChatToastAnimation EntryAnimation { get; init; } + public PDChatToastAnimation ExitAnimation { get; init; } + public double AnimationDurationMs { get; init; } + public bool AutoDismiss { get; init; } + public double DisplayDurationSeconds { get; init; } + public bool ShowTitle { get; init; } + public string? MinWidth { get; init; } + public string? MaxWidth { get; init; } + public string? MinHeight { get; init; } + public string? MaxHeight { get; init; } + + public bool IsExiting { get; set; } + public bool IsDeStacking { get; set; } + public Timer? DismissTimer { get; set; } + public Timer? RemovalTimer { get; set; } + public bool Paused { get; set; } + public double RemainingMs { get; set; } + public long StartedTicks { get; set; } + } } diff --git a/PanoramicData.Blazor/PDChat.razor.css b/PanoramicData.Blazor/PDChat.razor.css index a631f1c5e..30cc38ed3 100644 --- a/PanoramicData.Blazor/PDChat.razor.css +++ b/PanoramicData.Blazor/PDChat.razor.css @@ -821,4 +821,209 @@ body:has(.pdchat-container.dock-fullscreen) { background-color: #1e3a5f; border-color: #5ba4fc; } -} \ No newline at end of file +} +/* ============================================== + Toast Stack (PDChat as a toast surface) + ============================================== */ + +/* The stack is anchored to a viewport corner independently of the minimized button, so toasts + work even when the button is hidden (MinimizedButtonPosition.None => headless toasts). + The container itself is click-through; individual toasts re-enable pointer events. */ +.pdchat-toast-stack { + position: fixed; + z-index: 10002; + display: flex; + flex-direction: column; /* oldest first (top), newest last (bottom) */ + gap: 8px; + max-width: 90vw; + pointer-events: none; +} + +.pdchat-toast-stack.toast-anchor-bottom-right { + bottom: 80px; /* leave room for the minimized button when shown */ + right: 20px; + align-items: flex-end; +} + +.pdchat-toast-stack.toast-anchor-bottom-left { + bottom: 80px; + left: 20px; + align-items: flex-start; +} + +.pdchat-toast-stack.toast-anchor-top-right { + top: 80px; + right: 20px; + align-items: flex-end; +} + +.pdchat-toast-stack.toast-anchor-top-left { + top: 80px; + left: 20px; + align-items: flex-start; +} + +/* Toast card: reuse the preview look but neutralise the legacy absolute positioning / slide-in + animation so the card flows inside the flex stack and is driven by the toast-* animation classes. */ +.pdchat-message-preview.pdchat-toast { + position: relative; + top: auto; + bottom: auto; + left: auto; + right: auto; + transform: none; + margin: 0; + opacity: 1; + cursor: pointer; + pointer-events: auto; + animation-duration: var(--pdchat-toast-anim-ms, 250ms); + animation-timing-function: ease-out; + animation-fill-mode: both; + will-change: transform, opacity; +} + +.pdchat-toast-close { + position: absolute; + top: 2px; + right: 4px; + background: none; + border: none; + color: inherit; + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0.5; + padding: 2px 4px; +} + +.pdchat-toast-close:hover { + opacity: 1; +} + +/* ---- Entry animations ---- */ +.pdchat-message-preview.pdchat-toast.toast-enter-grow { + animation-name: pdchat-toast-grow-in; +} + +.pdchat-message-preview.pdchat-toast.toast-enter-shrink { + animation-name: pdchat-toast-shrink-in; +} + +.pdchat-message-preview.pdchat-toast.toast-enter-fade { + animation-name: pdchat-toast-fade-in; +} + +.pdchat-message-preview.pdchat-toast.toast-enter-slide { + animation-name: pdchat-toast-slide-in-right; +} + +.pdchat-toast-stack.toast-anchor-bottom-left .pdchat-message-preview.pdchat-toast.toast-enter-slide, +.pdchat-toast-stack.toast-anchor-top-left .pdchat-message-preview.pdchat-toast.toast-enter-slide { + animation-name: pdchat-toast-slide-in-left; +} + +.pdchat-message-preview.pdchat-toast.toast-enter-none { + animation-name: none; + opacity: 1; +} + +/* ---- Exit animations ---- */ +.pdchat-message-preview.pdchat-toast.toast-exit-shrink { + animation-name: pdchat-toast-shrink-out; +} + +.pdchat-message-preview.pdchat-toast.toast-exit-grow { + animation-name: pdchat-toast-grow-out; +} + +.pdchat-message-preview.pdchat-toast.toast-exit-fade { + animation-name: pdchat-toast-fade-out; +} + +.pdchat-message-preview.pdchat-toast.toast-exit-slide { + animation-name: pdchat-toast-slide-out-right; +} + +.pdchat-toast-stack.toast-anchor-bottom-left .pdchat-message-preview.pdchat-toast.toast-exit-slide, +.pdchat-toast-stack.toast-anchor-top-left .pdchat-message-preview.pdchat-toast.toast-exit-slide { + animation-name: pdchat-toast-slide-out-left; +} + +.pdchat-message-preview.pdchat-toast.toast-exit-none { + animation-name: none; + opacity: 0; +} + +/* ---- Fixed de-stack animation (uniform, independent of per-message config) ---- */ +.pdchat-message-preview.pdchat-toast.toast-destack { + animation-name: pdchat-toast-destack; + animation-duration: 250ms; /* fixed */ + animation-timing-function: ease-in; +} + +@keyframes pdchat-toast-grow-in { + from { opacity: 0; transform: scale(0.6); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes pdchat-toast-grow-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(1.3); } +} + +@keyframes pdchat-toast-shrink-in { + from { opacity: 0; transform: scale(1.3); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes pdchat-toast-shrink-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.6); } +} + +@keyframes pdchat-toast-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes pdchat-toast-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes pdchat-toast-slide-in-right { + from { opacity: 0; transform: translateX(30px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes pdchat-toast-slide-out-right { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(30px); } +} + +@keyframes pdchat-toast-slide-in-left { + from { opacity: 0; transform: translateX(-30px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes pdchat-toast-slide-out-left { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(-30px); } +} + +@keyframes pdchat-toast-destack { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.85) translateY(-6px); } +} + +/* Respect reduced-motion: collapse all toast animations to a simple fade. */ +@media (prefers-reduced-motion: reduce) { + .pdchat-message-preview.pdchat-toast[class*="toast-enter-"] { + animation-name: pdchat-toast-fade-in; + } + + .pdchat-message-preview.pdchat-toast[class*="toast-exit-"], + .pdchat-message-preview.pdchat-toast.toast-destack { + animation-name: pdchat-toast-fade-out; + } +} diff --git a/PanoramicData.Blazor/PDMessages.razor b/PanoramicData.Blazor/PDMessages.razor index bee184331..01685bade 100644 --- a/PanoramicData.Blazor/PDMessages.razor +++ b/PanoramicData.Blazor/PDMessages.razor @@ -17,17 +17,24 @@ } }
-
- - -
+ @if (IsInputPermitted) + { +
+ + +
+ } + else if (!string.IsNullOrWhiteSpace(InputDisabledMessage)) + { +
@InputDisabledMessage
+ }
diff --git a/PanoramicData.Blazor/PDMessages.razor.cs b/PanoramicData.Blazor/PDMessages.razor.cs index 09681264c..b78a01fcc 100644 --- a/PanoramicData.Blazor/PDMessages.razor.cs +++ b/PanoramicData.Blazor/PDMessages.razor.cs @@ -35,6 +35,20 @@ public partial class PDMessages : IAsyncDisposable /// [Parameter] public bool CanSend { get; set; } + /// + /// Gets or sets whether the message input (text area and send button) is rendered. + /// When false, the input is hidden and, if is set, + /// that message is shown in its place. Defaults to true. + /// + [Parameter] public bool IsInputPermitted { get; set; } = true; + + /// + /// Gets or sets an optional message shown where the input would normally appear when + /// is false. When null or empty, nothing is rendered in + /// place of the input. Rendered as plain text. + /// + [Parameter] public string? InputDisabledMessage { get; set; } + /// /// An event callback that is invoked when the send button is clicked. /// @@ -83,7 +97,7 @@ public partial class PDMessages : IAsyncDisposable private DotNetObjectReference? _dotNetRef; private bool _enterHandlerAttached; - private bool CanSendLocal => IsLive && !string.IsNullOrWhiteSpace(_localInput); + private bool CanSendLocal => IsInputPermitted && IsLive && !string.IsNullOrWhiteSpace(_localInput); /// /// Clears the textarea. Called by the parent after a message is sent. diff --git a/PanoramicData.Blazor/PDMessages.razor.css b/PanoramicData.Blazor/PDMessages.razor.css index 486204976..924f3eb1b 100644 --- a/PanoramicData.Blazor/PDMessages.razor.css +++ b/PanoramicData.Blazor/PDMessages.razor.css @@ -116,6 +116,20 @@ background-color: #6c757d; } +/* Message shown in place of the input when input is not permitted (IsInputPermitted = false) */ +.chat-input-disabled { + flex-shrink: 0; + margin-top: auto; + padding: 12px 16px; + font-size: 0.85rem; + font-style: italic; + line-height: 1.35; + text-align: center; + color: #6c757d; + background: #f5f7fa; + border-top: 1px solid rgb(0 0 0 / 0.1); +} + /* ============================================== DARK MODE - Messages Area and Input Styling ============================================== */ @@ -192,4 +206,10 @@ .submit-button-disabled:hover { background-color: #343a40 !important; } + + .chat-input-disabled { + color: #adb5bd; + background: #373b3e; + border-top-color: rgb(255 255 255 / 0.1); + } } \ No newline at end of file diff --git a/PanoramicData.Blazor/Services/DumbChatService.cs b/PanoramicData.Blazor/Services/DumbChatService.cs index f8c27a3d2..de5c6ea53 100644 --- a/PanoramicData.Blazor/Services/DumbChatService.cs +++ b/PanoramicData.Blazor/Services/DumbChatService.cs @@ -15,6 +15,8 @@ public class DumbChatService : IChatService, IDisposable private bool _isMaximizePermitted = true; private bool _isCanvasUsePermitted = true; private bool _isClearPermitted = true; + private bool _isInputPermitted = true; + private string? _inputDisabledMessage; private bool _autoRestoreOnNewMessage; private bool _useFullWidthMessages = true; private MessageMetadataDisplayMode _messageMetadataDisplayMode = MessageMetadataDisplayMode.UserOnlyOnRightOthersOnLeft; @@ -25,8 +27,19 @@ public class DumbChatService : IChatService, IDisposable private string _title = "Demo Chat"; private PDChatDockMode _restoreMode = PDChatDockMode.BottomRight; private PDChatButtonPosition _minimizedButtonPosition = PDChatButtonPosition.BottomRight; - private bool _showLastMessage = true; - private double _showLastMessageDurationSeconds = 5.0; + private bool _toastEnabled = true; + private double _toastDisplayDurationSeconds = 5.0; + private PDChatToastAnimation _toastEntryAnimation = PDChatToastAnimation.Grow; + private PDChatToastAnimation _toastExitAnimation = PDChatToastAnimation.Shrink; + private double _toastAnimationDurationMs = 250d; + private bool _toastAutoDismiss = true; + private bool _toastShowTitle = true; + private string _toastMinWidth = "200px"; + private string _toastMaxWidth = "300px"; + private string _toastMinHeight = string.Empty; + private string _toastMaxHeight = string.Empty; + private int _toastMaxVisible = 5; + private PDChatButtonPosition _toastAnchor = PDChatButtonPosition.BottomRight; private readonly List _messages = []; @@ -156,6 +169,34 @@ public bool IsClearPermitted } } + /// + public bool IsInputPermitted + { + get => _isInputPermitted; + set + { + if (_isInputPermitted != value) + { + _isInputPermitted = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public string? InputDisabledMessage + { + get => _inputDisabledMessage; + set + { + if (_inputDisabledMessage != value) + { + _inputDisabledMessage = value; + OnConfigurationChanged?.Invoke(); + } + } + } + /// public bool AutoRestoreOnNewMessage { @@ -255,28 +296,198 @@ public string MessageTimestampFormat } /// + [Obsolete("Superseded by ToastEnabled.")] public bool ShowLastMessage { - get => _showLastMessage; + get => ToastEnabled; + set => ToastEnabled = value; + } + + /// + [Obsolete("Superseded by ToastDisplayDurationSeconds.")] + public double ShowLastMessageDurationSeconds + { + get => ToastDisplayDurationSeconds; + set => ToastDisplayDurationSeconds = value; + } + + /// + public bool ToastEnabled + { + get => _toastEnabled; set { - if (_showLastMessage != value) + if (_toastEnabled != value) { - _showLastMessage = value; + _toastEnabled = value; OnConfigurationChanged?.Invoke(); } } } /// - public double ShowLastMessageDurationSeconds + public double ToastDisplayDurationSeconds + { + get => _toastDisplayDurationSeconds; + set + { + if (Math.Abs(_toastDisplayDurationSeconds - value) > 0.001) + { + _toastDisplayDurationSeconds = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public PDChatToastAnimation ToastEntryAnimation + { + get => _toastEntryAnimation; + set + { + if (_toastEntryAnimation != value) + { + _toastEntryAnimation = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public PDChatToastAnimation ToastExitAnimation + { + get => _toastExitAnimation; + set + { + if (_toastExitAnimation != value) + { + _toastExitAnimation = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public double ToastAnimationDurationMs + { + get => _toastAnimationDurationMs; + set + { + if (Math.Abs(_toastAnimationDurationMs - value) > 0.001) + { + _toastAnimationDurationMs = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public bool ToastAutoDismiss + { + get => _toastAutoDismiss; + set + { + if (_toastAutoDismiss != value) + { + _toastAutoDismiss = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public bool ToastShowTitle + { + get => _toastShowTitle; + set + { + if (_toastShowTitle != value) + { + _toastShowTitle = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public string ToastMinWidth + { + get => _toastMinWidth; + set + { + if (_toastMinWidth != value) + { + _toastMinWidth = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public string ToastMaxWidth + { + get => _toastMaxWidth; + set + { + if (_toastMaxWidth != value) + { + _toastMaxWidth = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public string ToastMinHeight + { + get => _toastMinHeight; + set + { + if (_toastMinHeight != value) + { + _toastMinHeight = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public string ToastMaxHeight + { + get => _toastMaxHeight; + set + { + if (_toastMaxHeight != value) + { + _toastMaxHeight = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public int ToastMaxVisible + { + get => _toastMaxVisible; + set + { + if (_toastMaxVisible != value) + { + _toastMaxVisible = value; + OnConfigurationChanged?.Invoke(); + } + } + } + + /// + public PDChatButtonPosition ToastAnchor { - get => _showLastMessageDurationSeconds; + get => _toastAnchor; set { - if (Math.Abs(_showLastMessageDurationSeconds - value) > 0.001) + if (_toastAnchor != value) { - _showLastMessageDurationSeconds = value; + _toastAnchor = value; OnConfigurationChanged?.Invoke(); } }