2025-12-28 15:50:36 +00:00
using AIStudio.Chat ;
2025-11-24 11:37:18 +00:00
using AIStudio.Dialogs ;
2026-07-15 10:53:30 +00:00
using AIStudio.Tools.Media ;
2025-12-18 14:00:27 +00:00
using AIStudio.Tools.PluginSystem ;
2025-11-24 11:37:18 +00:00
using AIStudio.Tools.Rust ;
using AIStudio.Tools.Services ;
2025-12-15 12:26:55 +00:00
using AIStudio.Tools.Validation ;
2025-11-24 11:37:18 +00:00
using Microsoft.AspNetCore.Components ;
namespace AIStudio.Components ;
2025-12-05 19:48:54 +00:00
using DialogOptions = Dialogs . DialogOptions ;
2025-11-24 11:37:18 +00:00
public partial class AttachDocuments : MSGComponentBase
{
2026-07-15 10:53:30 +00:00
private readonly MediaImportOwner fallbackMediaImportOwner = new ( MediaImportOwnerKind . CHAT , $"attachments:{Guid.NewGuid():N}" ) ;
[CascadingParameter]
private MediaImportOwner ? ImportOwner { get ; set ; }
2025-12-18 14:00:27 +00:00
private static string TB ( string fallbackEN ) = > I18N . I . T ( fallbackEN , typeof ( AttachDocuments ) . Namespace , nameof ( AttachDocuments ) ) ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
[Parameter]
public string Name { get ; set ; } = string . Empty ;
2026-06-20 15:06:43 +00:00
2026-01-01 15:47:15 +00:00
/// <summary>
/// On which layer to register the drop area. Higher layers have priority over lower layers.
/// </summary>
[Parameter]
public int Layer { get ; set ; }
2026-06-20 15:06:43 +00:00
2026-01-01 15:47:15 +00:00
/// <summary>
/// When true, pause catching dropped files. Default is false.
/// </summary>
[Parameter]
public bool PauseCatchingDrops { get ; set ; }
2025-12-28 15:50:36 +00:00
2025-11-24 11:37:18 +00:00
[Parameter]
2025-12-28 15:50:36 +00:00
public HashSet < FileAttachment > DocumentPaths { get ; set ; } = [ ] ;
2025-11-24 11:37:18 +00:00
[Parameter]
2025-12-28 15:50:36 +00:00
public EventCallback < HashSet < FileAttachment > > DocumentPathsChanged { get ; set ; }
2025-11-24 11:37:18 +00:00
[Parameter]
2025-12-28 15:50:36 +00:00
public Func < HashSet < FileAttachment > , Task > OnChange { get ; set ; } = _ = > Task . CompletedTask ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
/// <summary>
2026-06-20 15:06:43 +00:00
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
2025-11-24 11:37:18 +00:00
/// </summary>
2026-06-20 15:06:43 +00:00
[Parameter]
2025-11-24 11:37:18 +00:00
public bool CatchAllDocuments { get ; set ; }
2026-06-20 15:06:43 +00:00
2025-12-10 12:48:13 +00:00
[Parameter]
public bool UseSmallForm { get ; set ; }
2026-06-20 15:06:43 +00:00
2026-07-15 10:53:30 +00:00
/// <summary>Whether this control renders its own media status.</summary>
[Parameter]
public bool ShowMediaStatus { get ; set ; } = true ;
2026-06-20 15:06:43 +00:00
[Parameter]
public bool Disabled { get ; set ; }
2026-01-01 15:47:15 +00:00
/// <summary>
/// When true, validate media file types before attaching. Default is true. That means that
/// the user cannot attach unsupported media file types when the provider or model does not
/// support them. Set it to false in order to disable this validation. This is useful for places
/// where the user might want to prepare a template.
/// </summary>
[Parameter]
public bool ValidateMediaFileTypes { get ; set ; } = true ;
2026-06-20 15:06:43 +00:00
2025-12-30 17:30:32 +00:00
[Parameter]
public AIStudio . Settings . Provider ? Provider { get ; set ; }
2026-06-20 15:06:43 +00:00
2026-07-15 10:53:30 +00:00
/// <summary>Optional persisted chat that can own transcript files immediately.</summary>
[Parameter]
public ChatThread ? OwnerChat { get ; set ; }
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
[Parameter]
public Func < string , Task < ChatThread ? > > EnsureOwnerChatAsync { get ; set ; } = _ = > Task . FromResult < ChatThread ? > ( null ) ;
2025-11-24 11:37:18 +00:00
[Inject]
private ILogger < AttachDocuments > Logger { get ; set ; } = null ! ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
[Inject]
private RustService RustService { get ; init ; } = null ! ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
[Inject]
private IDialogService DialogService { get ; init ; } = null ! ;
2025-12-11 19:44:27 +00:00
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get ; init ; } = null ! ;
2026-07-15 10:53:30 +00:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null ! ;
2025-12-10 12:48:13 +00:00
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement . Top ;
2025-12-18 14:00:27 +00:00
private static readonly string DROP_FILES_HERE_TEXT = TB ( "Drop files here to attach them." ) ;
2026-06-20 15:06:43 +00:00
2026-01-01 15:47:15 +00:00
private uint numDropAreasAboveThis ;
2025-12-18 14:00:27 +00:00
private bool isComponentHovered ;
private bool isDraggingOver ;
2026-07-15 10:53:30 +00:00
private MediaImportOwner EffectiveImportOwner = > this . OwnerChat is not null
? MediaImportOwner . ForChat ( this . OwnerChat . ChatId )
: this . ImportOwner ? ? this . fallbackMediaImportOwner ;
private MediaImportTarget EffectiveMediaImportTarget = > new ( this . EffectiveImportOwner , string . IsNullOrWhiteSpace ( this . Name ) ? "attachments" : this . Name ) ;
private bool IsUnavailable = > this . Disabled | | this . MediaTranscriptionService . IsBusy ( this . EffectiveImportOwner ) ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync ( )
{
2026-07-15 10:53:30 +00:00
this . MediaTranscriptionService . StateChanged + = this . OnMediaImportStateChanged ;
2026-01-01 15:47:15 +00:00
this . ApplyFilters ( [ ] , [ Event . TAURI_EVENT_RECEIVED , Event . REGISTER_FILE_DROP_AREA , Event . UNREGISTER_FILE_DROP_AREA ] ) ;
2026-06-20 15:06:43 +00:00
2026-01-01 15:47:15 +00:00
// Register this drop area:
await this . MessageBus . SendMessage ( this , Event . REGISTER_FILE_DROP_AREA , this . Layer ) ;
2025-11-24 11:37:18 +00:00
await base . OnInitializedAsync ( ) ;
}
2026-07-15 10:53:30 +00:00
/// <summary>Rehydrates results after the component is assigned another chat or target.</summary>
protected override async Task OnParametersSetAsync ( )
{
await base . OnParametersSetAsync ( ) ;
await this . SyncCompletedMediaAttachmentsAsync ( ) ;
}
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
private void OnMediaImportStateChanged ( MediaImportOwner owner )
{
if ( owner = = this . EffectiveImportOwner )
_ = this . InvokeAsync ( async ( ) = >
{
await this . SyncCompletedMediaAttachmentsAsync ( ) ;
await this . ConsumeStandaloneMediaOutcomeAsync ( ) ;
this . StateHasChanged ( ) ;
} ) ;
}
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
private async Task ConsumeStandaloneMediaOutcomeAsync ( )
{
if ( this . ImportOwner is not null | | this . OwnerChat is not null )
return ;
var outcome = this . MediaTranscriptionService . TryConsumeOutcome ( this . EffectiveImportOwner ) ;
if ( outcome is null )
return ;
if ( outcome . Failures . Count > 0 )
{
var message = string . Join ( Environment . NewLine , outcome . Failures . Select ( failure = > $"{failure.FileName}: {failure.UserMessage}" ) ) ;
await this . MessageBus . SendError ( new ( Icons . Material . Filled . VoiceChat , message ) ) ;
}
else if ( outcome . Status is MediaImportStatus . FAILED )
{
await this . MessageBus . SendError ( new ( Icons . Material . Filled . VoiceChat , this . T ( "The media file could not be transcribed." ) ) ) ;
}
if ( outcome . Warnings . Count > 0 )
{
var message = string . Join ( Environment . NewLine , outcome . Warnings . Select ( warning = > $"{warning.FileName}: {warning.UserMessage}" ) ) ;
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . VoiceChat , message ) ) ;
}
if ( outcome . Status is MediaImportStatus . CANCELLED )
{
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . VoiceChat , this . T ( "The media transcription was canceled." ) ) ) ;
}
}
/// <summary>Reattaches completed owner results after progress updates or navigation.</summary>
private async Task SyncCompletedMediaAttachmentsAsync ( )
{
var delivery = this . MediaTranscriptionService . GetPendingDelivery ( this . EffectiveMediaImportTarget ) ;
var completed = delivery ? . Attachments ? ? [ ] ;
var pending = this . OwnerChat ? . PendingMediaTranscripts ? ? [ ] ;
var changed = false ;
var ownerPendingChanged = false ;
foreach ( var attachment in completed . Concat ( pending ) )
changed | = this . DocumentPaths . Add ( attachment ) ;
if ( this . OwnerChat is not null )
{
foreach ( var attachment in completed . OfType < ManagedTranscriptAttachment > ( ) )
{
if ( this . OwnerChat . PendingMediaTranscripts . All ( existing = > existing . FilePath ! = attachment . FilePath ) )
{
this . OwnerChat . PendingMediaTranscripts . Add ( attachment ) ;
ownerPendingChanged = true ;
}
}
}
if ( changed | | ownerPendingChanged )
{
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths ) ;
await this . OnChange ( this . DocumentPaths ) ;
}
if ( delivery is not null )
this . MediaTranscriptionService . AcknowledgeDelivery ( delivery ) ;
}
/// <summary>Unsubscribes from the singleton media service.</summary>
protected override void DisposeResources ( )
{
this . MediaTranscriptionService . StateChanged - = this . OnMediaImportStateChanged ;
base . DisposeResources ( ) ;
}
2025-11-24 11:37:18 +00:00
protected override async Task ProcessIncomingMessage < T > ( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable & & triggeredEvent = = Event . TAURI_EVENT_RECEIVED )
2026-06-20 15:06:43 +00:00
return ;
2025-11-24 11:37:18 +00:00
switch ( triggeredEvent )
{
2026-01-01 15:47:15 +00:00
case Event . REGISTER_FILE_DROP_AREA when sendingComponent ! = this :
{
if ( data is int layer & & layer > this . Layer )
{
this . numDropAreasAboveThis + + ;
this . PauseCatchingDrops = true ;
}
break ;
}
case Event . UNREGISTER_FILE_DROP_AREA when sendingComponent ! = this :
{
if ( data is int layer & & layer > this . Layer )
{
if ( this . numDropAreasAboveThis > 0 )
this . numDropAreasAboveThis - - ;
2026-06-20 15:06:43 +00:00
2026-01-01 15:47:15 +00:00
if ( this . numDropAreasAboveThis is 0 )
this . PauseCatchingDrops = false ;
}
break ;
}
2025-11-24 11:37:18 +00:00
case Event . TAURI_EVENT_RECEIVED when data is TauriEvent { EventType : TauriEventType . FILE_DROP_HOVERED } :
2026-01-01 15:47:15 +00:00
if ( this . PauseCatchingDrops )
return ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
if ( ! this . isComponentHovered & & ! this . CatchAllDocuments )
{
this . Logger . LogDebug ( "Attach documents component '{Name}' is not hovered, ignoring file drop hovered event." , this . Name ) ;
return ;
}
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
this . isDraggingOver = true ;
2025-11-24 11:37:18 +00:00
this . SetDragClass ( ) ;
this . StateHasChanged ( ) ;
break ;
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
case Event . TAURI_EVENT_RECEIVED when data is TauriEvent { EventType : TauriEventType . FILE_DROP_CANCELED } :
2026-01-01 15:47:15 +00:00
if ( this . PauseCatchingDrops )
return ;
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
this . isDraggingOver = false ;
this . StateHasChanged ( ) ;
break ;
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
case Event . TAURI_EVENT_RECEIVED when data is TauriEvent { EventType : TauriEventType . WINDOW_NOT_FOCUSED } :
2026-01-01 15:47:15 +00:00
if ( this . PauseCatchingDrops )
return ;
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
this . isDraggingOver = false ;
this . isComponentHovered = false ;
this . ClearDragClass ( ) ;
this . StateHasChanged ( ) ;
break ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
case Event . TAURI_EVENT_RECEIVED when data is TauriEvent { EventType : TauriEventType . FILE_DROP_DROPPED , Payload : var paths } :
2026-01-01 15:47:15 +00:00
if ( this . PauseCatchingDrops )
return ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
if ( ! this . isComponentHovered & & ! this . CatchAllDocuments )
{
this . Logger . LogDebug ( "Attach documents component '{Name}' is not hovered, ignoring file drop dropped event." , this . Name ) ;
return ;
}
2025-12-11 19:44:27 +00:00
2026-07-15 10:53:30 +00:00
await this . AddFileBatchAsync ( paths ) ;
2025-11-24 11:37:18 +00:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths ) ;
await this . OnChange ( this . DocumentPaths ) ;
2025-12-18 14:00:27 +00:00
this . isDraggingOver = false ;
this . ClearDragClass ( ) ;
2025-11-24 11:37:18 +00:00
this . StateHasChanged ( ) ;
break ;
}
}
#endregion
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-4 mt-4 mud-width-full mud-height-full" ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
private string dragClass = DEFAULT_DRAG_CLASS ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
private async Task AddFilesManually ( )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable )
2026-06-20 15:06:43 +00:00
return ;
2025-12-18 14:00:27 +00:00
var selectFiles = await this . RustService . SelectFiles ( T ( "Select files to attach" ) ) ;
2025-12-16 18:14:27 +00:00
if ( selectFiles . UserCancelled )
2025-11-24 11:37:18 +00:00
return ;
2026-07-15 10:53:30 +00:00
await this . AddFileBatchAsync ( selectFiles . SelectedFilePaths ) ;
2025-11-24 11:37:18 +00:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths ) ;
await this . OnChange ( this . DocumentPaths ) ;
}
2026-06-20 15:06:43 +00:00
2025-12-18 14:00:27 +00:00
private async Task OpenAttachmentsDialog ( )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable )
2026-06-20 15:06:43 +00:00
return ;
2026-07-15 10:53:30 +00:00
var previousAttachments = this . DocumentPaths . ToHashSet ( ) ;
2025-12-18 14:00:27 +00:00
this . DocumentPaths = await ReviewAttachmentsDialog . OpenDialogAsync ( this . DialogService , this . DocumentPaths ) ;
2026-07-15 10:53:30 +00:00
foreach ( var removedAttachment in previousAttachments . Except ( this . DocumentPaths ) )
ManagedTranscriptAttachment . TryDeleteOwnedFile ( removedAttachment ) ;
this . ReconcileOwnerPendingTranscripts ( ) ;
2025-12-18 14:00:27 +00:00
}
2025-11-24 11:37:18 +00:00
private async Task ClearAllFiles ( )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable )
2026-06-20 15:06:43 +00:00
return ;
2026-07-15 10:53:30 +00:00
foreach ( var attachment in this . DocumentPaths )
ManagedTranscriptAttachment . TryDeleteOwnedFile ( attachment ) ;
2025-11-24 11:37:18 +00:00
this . DocumentPaths . Clear ( ) ;
2026-07-15 10:53:30 +00:00
this . ReconcileOwnerPendingTranscripts ( ) ;
2025-11-24 11:37:18 +00:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths ) ;
await this . OnChange ( this . DocumentPaths ) ;
}
private void SetDragClass ( ) = > this . dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-4" ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
private void ClearDragClass ( ) = > this . dragClass = DEFAULT_DRAG_CLASS ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
private void OnMouseEnter ( EventArgs _ )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable | | this . PauseCatchingDrops )
2026-01-01 15:47:15 +00:00
return ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
this . Logger . LogDebug ( "Attach documents component '{Name}' is hovered." , this . Name ) ;
this . isComponentHovered = true ;
this . SetDragClass ( ) ;
this . StateHasChanged ( ) ;
}
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
private void OnMouseLeave ( EventArgs _ )
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable | | this . PauseCatchingDrops )
2026-01-01 15:47:15 +00:00
return ;
2026-06-20 15:06:43 +00:00
2025-11-24 11:37:18 +00:00
this . Logger . LogDebug ( "Attach documents component '{Name}' is no longer hovered." , this . Name ) ;
this . isComponentHovered = false ;
this . ClearDragClass ( ) ;
this . StateHasChanged ( ) ;
}
2025-12-28 15:50:36 +00:00
private async Task RemoveDocument ( FileAttachment fileAttachment )
2025-11-24 11:37:18 +00:00
{
2026-07-15 10:53:30 +00:00
if ( this . IsUnavailable )
2026-06-20 15:06:43 +00:00
return ;
2025-12-28 15:50:36 +00:00
this . DocumentPaths . Remove ( fileAttachment ) ;
2026-07-15 10:53:30 +00:00
ManagedTranscriptAttachment . TryDeleteOwnedFile ( fileAttachment ) ;
this . ReconcileOwnerPendingTranscripts ( ) ;
2025-12-28 15:50:36 +00:00
2025-11-24 11:37:18 +00:00
await this . DocumentPathsChanged . InvokeAsync ( this . DocumentPaths ) ;
await this . OnChange ( this . DocumentPaths ) ;
}
2026-07-15 10:53:30 +00:00
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
private void ReconcileOwnerPendingTranscripts ( )
{
if ( this . OwnerChat is null )
return ;
var retainedPaths = this . DocumentPaths . Select ( attachment = > attachment . FilePath ) . ToHashSet ( StringComparer . Ordinal ) ;
this . OwnerChat . PendingMediaTranscripts . RemoveAll ( attachment = > ! retainedPaths . Contains ( attachment . FilePath ) ) ;
}
private async Task AddFileBatchAsync ( IEnumerable < string > paths )
{
var existingPaths = paths . Where ( File . Exists ) . ToList ( ) ;
var mediaPaths = existingPaths . Where ( IsTranscribableMedia ) . ToList ( ) ;
var regularPaths = existingPaths . Except ( mediaPaths ) . ToList ( ) ;
var canAddRegularFiles = true ;
if ( regularPaths . Count > 0 )
{
var pandocState = await this . PandocAvailabilityService . EnsureAvailabilityAsync (
showSuccessMessage : false ,
showDialog : true ) ;
canAddRegularFiles = pandocState . IsAvailable ;
}
foreach ( var path in regularPaths )
{
if ( ! canAddRegularFiles )
break ;
if ( ! await FileExtensionValidation . IsExtensionValidWithNotifyAsync (
FileExtensionValidation . UseCase . ATTACHING_CONTENT ,
path ,
this . ValidateMediaFileTypes ,
this . Provider ) )
continue ;
this . DocumentPaths . Add ( FileAttachment . FromPath ( path ) ) ;
}
if ( mediaPaths . Count is 0 )
return ;
if ( string . IsNullOrWhiteSpace ( this . SettingsManager . ConfigurationData . App . UseTranscriptionProvider ) )
{
await this . MessageBus . SendWarning ( new (
Icons . Material . Filled . VoiceChat ,
this . T ( "Media files require a configured transcription provider. Configure one in the transcription settings." ) ) ) ;
return ;
}
var names = string . Join ( '\n' , mediaPaths . Select ( path = > $"- {Markdown.EscapeInlineText(Path.GetFileName(path))}" ) ) ;
var message = this . T ( "The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider." ) ;
var dialogParameters = new DialogParameters < ConfirmDialog >
{
{
x = > x . MarkdownBody ,
$"" "
{ message }
{ names }
"" "
} ,
} ;
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog > (
this . T ( "Transcribe media files" ) ,
dialogParameters ,
DialogOptions . FULLSCREEN ) ;
var dialogResult = await dialogReference . Result ;
if ( dialogResult is null | | dialogResult . Canceled )
return ;
if ( this . OwnerChat is null )
this . OwnerChat = await this . EnsureOwnerChatAsync ( mediaPaths [ 0 ] ) ;
this . MediaTranscriptionService . TryStartAttachmentBatch ( mediaPaths , this . EffectiveMediaImportTarget , this . OwnerChat ) ;
}
private static bool IsTranscribableMedia ( string path ) = > FileTypes . IsAllowedPath ( path , FileTypes . AUDIO ) | | FileTypes . IsAllowedPath ( path , FileTypes . VIDEO ) ;
2025-11-24 11:37:18 +00:00
/// <summary>
2025-12-28 15:50:36 +00:00
/// The user might want to check what we actually extract from his file and therefore give the LLM as an input.
2025-11-24 11:37:18 +00:00
/// </summary>
2025-12-28 15:50:36 +00:00
/// <param name="fileAttachment">The file to check.</param>
private async Task InvestigateFile ( FileAttachment fileAttachment )
2025-11-24 11:37:18 +00:00
{
2025-12-05 19:48:54 +00:00
var dialogParameters = new DialogParameters < DocumentCheckDialog >
{
2025-12-30 17:30:32 +00:00
{ x = > x . Document , fileAttachment } ,
2025-12-05 19:48:54 +00:00
} ;
await this . DialogService . ShowAsync < DocumentCheckDialog > ( T ( "Document Preview" ) , dialogParameters , DialogOptions . FULLSCREEN ) ;
2025-11-24 11:37:18 +00:00
}
}