mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-06-10 00:02:37 -06:00
Add message deletion feature (CLI & GUI)
Introduce a message-deletion feature across CLI and GUI. Adds a new CLI command (deletemessages) with channel/before/after options and console progress. Extends DiscordClient with GetCurrentUserAsync and DeleteMessageAsync (including rate-limit handling) to perform deletions and surface authorization outcomes. GUI additions include DeleteSetup dialog, its ViewModel, view, and wiring: App registration, View/ViewModel managers, DashboardViewModel command, and a Delete button in the dashboard; deletion runs per-channel (parallel, with progress) and reports success/failure summaries.
This commit is contained in:
parent
6258394fc0
commit
c38953d868
175
DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs
Normal file
175
DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("deletemessages", Description = "Deletes messages from a channel.")]
|
||||||
|
public class DeleteMessagesCommand : DiscordCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption(
|
||||||
|
"channel",
|
||||||
|
'c',
|
||||||
|
Description = "Channel ID. Note: You can only delete your own messages in DMs."
|
||||||
|
)]
|
||||||
|
public required Snowflake ChannelId { get; init; }
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"before",
|
||||||
|
Description = "Limit to messages sent before this date (formatted using the current culture)."
|
||||||
|
)]
|
||||||
|
public DateTimeOffset? Before { get; init; }
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"after",
|
||||||
|
Description = "Limit to messages sent after this date (formatted using the current culture)."
|
||||||
|
)]
|
||||||
|
public DateTimeOffset? After { get; init; }
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
await console.Output.WriteLineAsync("Getting current user...");
|
||||||
|
var currentUser = await Discord.GetCurrentUserAsync(cancellationToken);
|
||||||
|
await console.Output.WriteLineAsync($"Authenticated as: {currentUser.FullName}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
// Resolve the channel
|
||||||
|
await console.Output.WriteLineAsync("Resolving channel...");
|
||||||
|
var channel = await Discord.GetChannelAsync(ChannelId, cancellationToken);
|
||||||
|
|
||||||
|
// Warning message
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
"WARNING: This will delete your messages from the channel."
|
||||||
|
);
|
||||||
|
await console.Output.WriteLineAsync("Messages from other users will be skipped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync($"Channel: {channel.Name}");
|
||||||
|
|
||||||
|
if (After is not null)
|
||||||
|
await console.Output.WriteLineAsync($"After: {After:g}");
|
||||||
|
|
||||||
|
if (Before is not null)
|
||||||
|
await console.Output.WriteLineAsync($"Before: {Before:g}");
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
// Count user's messages
|
||||||
|
await console.Output.WriteLineAsync("Counting your messages...");
|
||||||
|
|
||||||
|
var beforeSnowflake = Before?.Pipe(Snowflake.FromDate);
|
||||||
|
var afterSnowflake = After?.Pipe(Snowflake.FromDate);
|
||||||
|
|
||||||
|
var userMessageIds = new List<Snowflake>();
|
||||||
|
await foreach (
|
||||||
|
var message in Discord.GetMessagesAsync(
|
||||||
|
ChannelId,
|
||||||
|
afterSnowflake,
|
||||||
|
beforeSnowflake,
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (message.Author.Id == currentUser.Id)
|
||||||
|
{
|
||||||
|
userMessageIds.Add(message.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUserMessages = userMessageIds.Count;
|
||||||
|
await console.Output.WriteLineAsync($"Found {totalUserMessages} of your messages");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
if (totalUserMessages == 0)
|
||||||
|
{
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync("No messages to delete.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete messages
|
||||||
|
await console.Output.WriteLineAsync("Deleting messages...");
|
||||||
|
|
||||||
|
var successCount = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
foreach (var messageId in userMessageIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deleted = await Discord.DeleteMessageAsync(
|
||||||
|
ChannelId,
|
||||||
|
messageId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted)
|
||||||
|
{
|
||||||
|
successCount++;
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Green))
|
||||||
|
{
|
||||||
|
await console.Output.WriteAsync("\r");
|
||||||
|
await console.Output.WriteAsync(
|
||||||
|
$"✓ Deleted {successCount} / {totalUserMessages} messages "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord's rate limit headers are handled automatically by DeleteMessageAsync
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
|
{
|
||||||
|
await console.Output.WriteAsync("\r");
|
||||||
|
await console.Output.WriteAsync(
|
||||||
|
$"✗ Error | Deleted: {successCount} / {totalUserMessages}, Failed: {failedCount} "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the progress line
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
// Report results
|
||||||
|
await console.Output.WriteLineAsync("Deletion complete!");
|
||||||
|
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Green))
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Successfully deleted: {successCount} / {totalUserMessages} message(s)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedCount > 0)
|
||||||
|
{
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync($"Failed to delete: {failedCount} message(s)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -220,6 +220,12 @@ public class DiscordClient(
|
||||||
return response?.Pipe(User.Parse);
|
return response?.Pipe(User.Parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask<User> GetCurrentUserAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await GetJsonResponseAsync("users/@me", cancellationToken);
|
||||||
|
return User.Parse(response);
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
|
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
|
|
@ -820,4 +826,96 @@ public class DiscordClient(
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask<HttpResponseMessage> DeleteResponseAsync(
|
||||||
|
string url,
|
||||||
|
TokenKind tokenKind,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await Http.ResponseResiliencePipeline.ExecuteAsync(
|
||||||
|
async innerCancellationToken =>
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
new Uri(_baseUri, url)
|
||||||
|
);
|
||||||
|
|
||||||
|
request.Headers.TryAddWithoutValidation(
|
||||||
|
"Authorization",
|
||||||
|
tokenKind == TokenKind.Bot ? $"Bot {token}" : token
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await Http.Client.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
innerCancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||||
|
{
|
||||||
|
var remainingRequestCount = response
|
||||||
|
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||||
|
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
var resetAfterDelay = response
|
||||||
|
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||||
|
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||||
|
.Pipe(TimeSpan.FromSeconds);
|
||||||
|
|
||||||
|
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||||
|
{
|
||||||
|
var delay = (resetAfterDelay.Value + TimeSpan.FromSeconds(1)).Clamp(
|
||||||
|
TimeSpan.Zero,
|
||||||
|
TimeSpan.FromSeconds(60)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Task.Delay(delay, innerCancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<HttpResponseMessage> DeleteResponseAsync(
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
) =>
|
||||||
|
await DeleteResponseAsync(
|
||||||
|
url,
|
||||||
|
await ResolveTokenKindAsync(cancellationToken),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public async ValueTask<bool> DeleteMessageAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake messageId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
using var response = await DeleteResponseAsync(
|
||||||
|
$"channels/{channelId}/messages/{messageId}",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// 204 No Content = successfully deleted
|
||||||
|
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 403 Forbidden = not authorized to delete (e.g., someone else's message)
|
||||||
|
// 404 Not Found = message already deleted or doesn't exist
|
||||||
|
if (
|
||||||
|
response.StatusCode == HttpStatusCode.Forbidden
|
||||||
|
|| response.StatusCode == HttpStatusCode.NotFound
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// For other errors, throw an exception
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
$"Failed to delete message #{messageId} in channel #{channelId}: {response.StatusCode}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ public class App : Application, IDisposable
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddTransient<MainViewModel>();
|
||||||
services.AddTransient<DashboardViewModel>();
|
services.AddTransient<DashboardViewModel>();
|
||||||
services.AddTransient<ExportSetupViewModel>();
|
services.AddTransient<ExportSetupViewModel>();
|
||||||
|
services.AddTransient<DeleteSetupViewModel>();
|
||||||
services.AddTransient<MessageBoxViewModel>();
|
services.AddTransient<MessageBoxViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ public partial class ViewManager
|
||||||
MainViewModel => new MainView(),
|
MainViewModel => new MainView(),
|
||||||
DashboardViewModel => new DashboardView(),
|
DashboardViewModel => new DashboardView(),
|
||||||
ExportSetupViewModel => new ExportSetupView(),
|
ExportSetupViewModel => new ExportSetupView(),
|
||||||
|
DeleteSetupViewModel => new DeleteSetupView(),
|
||||||
MessageBoxViewModel => new MessageBoxView(),
|
MessageBoxViewModel => new MessageBoxView(),
|
||||||
SettingsViewModel => new SettingsView(),
|
SettingsViewModel => new SettingsView(),
|
||||||
_ => null,
|
_ => null,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ public class ViewModelManager(IServiceProvider services)
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DeleteSetupViewModel GetDeleteSetupViewModel(
|
||||||
|
Guild guild,
|
||||||
|
IReadOnlyList<Channel> channels
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var viewModel = services.GetRequiredService<DeleteSetupViewModel>();
|
||||||
|
|
||||||
|
viewModel.Guild = guild;
|
||||||
|
viewModel.Channels = channels;
|
||||||
|
|
||||||
|
return viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
public MessageBoxViewModel GetMessageBoxViewModel(
|
public MessageBoxViewModel GetMessageBoxViewModel(
|
||||||
string title,
|
string title,
|
||||||
string message,
|
string message,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,11 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
),
|
),
|
||||||
SelectedChannels.WatchProperty(
|
SelectedChannels.WatchProperty(
|
||||||
o => o.Count,
|
o => o.Count,
|
||||||
_ => ExportCommand.NotifyCanExecuteChanged()
|
_ =>
|
||||||
|
{
|
||||||
|
ExportCommand.NotifyCanExecuteChanged();
|
||||||
|
DeleteMessagesCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +70,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(DeleteMessagesCommand))]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
public LocalizationManager LocalizationManager { get; }
|
public LocalizationManager LocalizationManager { get; }
|
||||||
|
|
@ -324,6 +329,125 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanDeleteMessages() =>
|
||||||
|
!IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any();
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanDeleteMessages))]
|
||||||
|
private async Task DeleteMessagesAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentUser = await _discord.GetCurrentUserAsync();
|
||||||
|
|
||||||
|
var dialog = _viewModelManager.GetDeleteSetupViewModel(
|
||||||
|
SelectedGuild,
|
||||||
|
SelectedChannels.Select(c => c.Channel).ToArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channelProgressPairs = dialog
|
||||||
|
.Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() })
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var totalDeletedCount = 0;
|
||||||
|
var totalFailedCount = 0;
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(
|
||||||
|
channelProgressPairs,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit),
|
||||||
|
},
|
||||||
|
async (pair, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var channel = pair.Channel;
|
||||||
|
var progress = pair.Progress;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userMessageIds = new List<Snowflake>();
|
||||||
|
await foreach (
|
||||||
|
var message in _discord.GetMessagesAsync(
|
||||||
|
channel.Id,
|
||||||
|
dialog.After?.Pipe(Snowflake.FromDate),
|
||||||
|
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||||
|
progress,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (message.Author.Id == currentUser.Id)
|
||||||
|
userMessageIds.Add(message.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMessageIds.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var successCount = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
foreach (var messageId in userMessageIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deleted = await _discord.DeleteMessageAsync(
|
||||||
|
channel.Id,
|
||||||
|
messageId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted)
|
||||||
|
successCount++;
|
||||||
|
else
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Add(ref totalDeletedCount, successCount);
|
||||||
|
Interlocked.Add(ref totalFailedCount, failedCount);
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
_snackbarManager.Notify(
|
||||||
|
$"Deleted {totalDeletedCount} message(s), {totalFailedCount} failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var dialog = _viewModelManager.GetMessageBoxViewModel(
|
||||||
|
"Error deleting messages",
|
||||||
|
ex.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
public partial class DeleteSetupViewModel : DialogViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial Guild? Guild { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
||||||
|
public partial IReadOnlyList<Channel>? Channels { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(After))]
|
||||||
|
public partial DateTimeOffset? AfterDate { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial TimeSpan? AfterTime { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Before))]
|
||||||
|
public partial DateTimeOffset? BeforeDate { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial TimeSpan? BeforeTime { get; set; }
|
||||||
|
|
||||||
|
public bool IsSingleChannel => Channels?.Count == 1;
|
||||||
|
|
||||||
|
public bool IsAfterDateSet => AfterDate is not null;
|
||||||
|
|
||||||
|
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
|
public bool IsBeforeDateSet => BeforeDate is not null;
|
||||||
|
|
||||||
|
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
Close(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -284,24 +284,47 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Export button -->
|
<!-- Action buttons -->
|
||||||
<Button
|
<StackPanel
|
||||||
Width="56"
|
|
||||||
Height="56"
|
|
||||||
Margin="32,24"
|
|
||||||
Padding="0"
|
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
Margin="32,24"
|
||||||
Command="{Binding ExportCommand}"
|
Orientation="Horizontal"
|
||||||
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
Spacing="16">
|
||||||
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
<!-- Delete button -->
|
||||||
Theme="{DynamicResource MaterialIconButton}">
|
<Button
|
||||||
<materialIcons:MaterialIcon
|
Width="56"
|
||||||
Width="32"
|
Height="56"
|
||||||
Height="32"
|
Padding="0"
|
||||||
Kind="Download" />
|
Background="#FFE74C3C"
|
||||||
</Button>
|
Command="{Binding DeleteMessagesCommand}"
|
||||||
|
Foreground="White"
|
||||||
|
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
||||||
|
Theme="{DynamicResource MaterialIconButton}"
|
||||||
|
ToolTip.Tip="Delete messages from selected channels">
|
||||||
|
<materialIcons:MaterialIcon
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Kind="Delete" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Export button -->
|
||||||
|
<Button
|
||||||
|
Width="56"
|
||||||
|
Height="56"
|
||||||
|
Padding="0"
|
||||||
|
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
||||||
|
Command="{Binding ExportCommand}"
|
||||||
|
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
||||||
|
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
||||||
|
Theme="{DynamicResource MaterialIconButton}"
|
||||||
|
ToolTip.Tip="Export selected channels">
|
||||||
|
<materialIcons:MaterialIcon
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Kind="Download" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
180
DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml
Normal file
180
DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.Dialogs.DeleteSetupView"
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
|
||||||
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
|
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||||
|
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
|
||||||
|
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
||||||
|
x:Name="UserControl"
|
||||||
|
Width="380"
|
||||||
|
x:DataType="dialogs:DeleteSetupViewModel"
|
||||||
|
Loaded="UserControl_OnLoaded">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
<!-- Guild/channel info -->
|
||||||
|
<Grid
|
||||||
|
Grid.Row="0"
|
||||||
|
Margin="16"
|
||||||
|
ColumnDefinitions="Auto,*">
|
||||||
|
<!-- Guild icon -->
|
||||||
|
<Ellipse
|
||||||
|
Grid.Column="0"
|
||||||
|
Width="32"
|
||||||
|
Height="32">
|
||||||
|
<Ellipse.Fill>
|
||||||
|
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
|
||||||
|
</Ellipse.Fill>
|
||||||
|
</Ellipse>
|
||||||
|
|
||||||
|
<!-- Channel count (for multiple channels) -->
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
|
IsVisible="{Binding !IsSingleChannel}"
|
||||||
|
TextTrimming="CharacterEllipsis">
|
||||||
|
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
|
||||||
|
<Run Text="channels selected" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<!-- Category and channel name (for single channel) -->
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
|
IsVisible="{Binding IsSingleChannel}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
|
||||||
|
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
|
||||||
|
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
|
||||||
|
<Run Text="/" />
|
||||||
|
</TextBlock>
|
||||||
|
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border
|
||||||
|
Grid.Row="1"
|
||||||
|
Padding="0,8"
|
||||||
|
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||||
|
BorderThickness="0,1">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Orientation="Vertical">
|
||||||
|
<!-- Warning message -->
|
||||||
|
<Border
|
||||||
|
Margin="16,8"
|
||||||
|
Padding="12"
|
||||||
|
Background="#33FFA500"
|
||||||
|
BorderBrush="#FFFFA500"
|
||||||
|
BorderThickness="2"
|
||||||
|
CornerRadius="4">
|
||||||
|
<TextBlock
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#FFFFA500"
|
||||||
|
TextWrapping="Wrap">
|
||||||
|
<Run Text="⚠ WARNING: This will permanently delete messages from the channel." />
|
||||||
|
<LineBreak />
|
||||||
|
<Run FontWeight="Normal" Text="You can only delete your own messages. Messages from other users will be skipped." />
|
||||||
|
</TextBlock>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Date limits -->
|
||||||
|
<Grid ColumnDefinitions="*,*" RowDefinitions="*,*">
|
||||||
|
<DatePicker
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,8,8,8"
|
||||||
|
materialAssists:TextFieldAssist.Label="After (date)"
|
||||||
|
SelectedDate="{Binding AfterDate}"
|
||||||
|
ToolTip.Tip="Only delete messages sent after this date">
|
||||||
|
<DatePicker.Styles>
|
||||||
|
<Style Selector="DatePicker">
|
||||||
|
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||||
|
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</DatePicker.Styles>
|
||||||
|
</DatePicker>
|
||||||
|
<DatePicker
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="8,8,16,8"
|
||||||
|
materialAssists:TextFieldAssist.Label="Before (date)"
|
||||||
|
SelectedDate="{Binding BeforeDate}"
|
||||||
|
ToolTip.Tip="Only delete messages sent before this date">
|
||||||
|
<DatePicker.Styles>
|
||||||
|
<Style Selector="DatePicker">
|
||||||
|
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||||
|
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</DatePicker.Styles>
|
||||||
|
</DatePicker>
|
||||||
|
|
||||||
|
<!-- Time limits -->
|
||||||
|
<TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,8,8,8"
|
||||||
|
materialAssists:TextFieldAssist.Label="After (time)"
|
||||||
|
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||||
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
|
SelectedTime="{Binding AfterTime}"
|
||||||
|
ToolTip.Tip="Only delete messages sent after this time">
|
||||||
|
<TimePicker.Styles>
|
||||||
|
<Style Selector="TimePicker">
|
||||||
|
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||||
|
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</TimePicker.Styles>
|
||||||
|
</TimePicker>
|
||||||
|
<TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="8,8,16,8"
|
||||||
|
materialAssists:TextFieldAssist.Label="Before (time)"
|
||||||
|
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||||
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
|
SelectedTime="{Binding BeforeTime}"
|
||||||
|
ToolTip.Tip="Only delete messages sent before this time">
|
||||||
|
<TimePicker.Styles>
|
||||||
|
<Style Selector="TimePicker">
|
||||||
|
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||||
|
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</TimePicker.Styles>
|
||||||
|
</TimePicker>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Margin="16"
|
||||||
|
ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
Command="{Binding ConfirmCommand}"
|
||||||
|
Content="DELETE"
|
||||||
|
IsDefault="True"
|
||||||
|
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||||
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="16,0,0,0"
|
||||||
|
Command="{Binding CancelCommand}"
|
||||||
|
Content="CANCEL"
|
||||||
|
IsCancel="True"
|
||||||
|
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class DeleteSetupView : UserControl
|
||||||
|
{
|
||||||
|
public DeleteSetupView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
// Nothing to do on load for now
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue