This commit is contained in:
primetime43 2026-06-08 12:46:34 -04:00 committed by GitHub
commit e37cb748b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 817 additions and 17 deletions

View file

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exceptions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("deletemessages", Description = "Deletes messages from a channel.")]
public partial class DeleteMessagesCommand : DiscordCommandBase
{
[CommandOption(
"channel",
'c',
Description = "Channel ID. Note: You can only delete your own messages."
)]
public required Snowflake ChannelId { get; set; }
[CommandOption(
"before",
Description = "Limit to messages sent before this date (formatted using the current culture)."
)]
public DateTimeOffset? Before { get; set; }
[CommandOption(
"after",
Description = "Limit to messages sent after this date (formatted using the current culture)."
)]
public DateTimeOffset? After { get; set; }
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 (DiscordChatExporterException ex) when (!ex.IsFatal)
{
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)");
}
}
}
}

View file

@ -220,6 +220,12 @@ public class DiscordClient(
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(
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
@ -820,4 +826,96 @@ public class DiscordClient(
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.ParseOrNull(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.ParseOrNull(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}"
);
}
}

View file

@ -48,6 +48,7 @@ public partial class App : Application, IDisposable
services.AddTransient<MainViewModel>();
services.AddTransient<DashboardViewModel>();
services.AddTransient<ExportSetupViewModel>();
services.AddTransient<DeleteSetupViewModel>();
services.AddTransient<MessageBoxViewModel>();
services.AddTransient<SettingsViewModel>();

View file

@ -17,6 +17,7 @@ public partial class ViewManager
MainViewModel => new MainView(),
DashboardViewModel => new DashboardView(),
ExportSetupViewModel => new ExportSetupView(),
DeleteSetupViewModel => new DeleteSetupView(),
MessageBoxViewModel => new MessageBoxView(),
SettingsViewModel => new SettingsView(),
_ => null,

View file

@ -29,6 +29,19 @@ public class ViewModelManager(IServiceProvider services, LocalizationManager loc
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(
string title,
string message,

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)",
[nameof(SettingsTooltip)] = "Settings",
[nameof(LastMessageSentTooltip)] = "Last message sent:",
[nameof(ExportTooltip)] = "Export selected channels",
[nameof(DeleteMessagesTooltip)] = "Delete messages from selected channels",
[nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "To get the token for your personal account:",
@ -123,6 +125,16 @@ public partial class LocalizationManager
"Download assets to this directory. If not specified, the asset directory path will be derived from the output path.",
[nameof(AdvancedOptionsTooltip)] = "Toggle advanced options",
[nameof(ExportButton)] = "EXPORT",
// Delete Setup
[nameof(DeleteWarningTitle)] =
"⚠ WARNING: This will permanently delete messages from the channel.",
[nameof(DeleteWarningSubtext)] =
"You can only delete your own messages. Messages from other users will be skipped.",
[nameof(DeleteAfterDateTooltip)] = "Only delete messages sent after this date",
[nameof(DeleteBeforeDateTooltip)] = "Only delete messages sent before this date",
[nameof(DeleteAfterTimeTooltip)] = "Only delete messages sent after this time",
[nameof(DeleteBeforeTimeTooltip)] = "Only delete messages sent before this time",
[nameof(DeleteButton)] = "DELETE",
// Common buttons
[nameof(CloseButton)] = "CLOSE",
[nameof(CancelButton)] = "CANCEL",
@ -152,5 +164,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
[nameof(ErrorDeletingTitle)] = "Error deleting messages",
[nameof(SuccessfulDeletionMessage)] = "Deleted {0} message(s), {1} failed",
};
}

View file

@ -13,6 +13,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)",
[nameof(SettingsTooltip)] = "Paramètres",
[nameof(LastMessageSentTooltip)] = "Dernier message envoyé :",
[nameof(ExportTooltip)] = "Exporter les canaux sélectionnés",
[nameof(DeleteMessagesTooltip)] = "Supprimer les messages des canaux sélectionnés",
[nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Obtenir le token pour votre compte personnel :",
@ -125,6 +127,20 @@ public partial class LocalizationManager
"Télécharger les ressources dans ce dossier. Si non spécifié, le chemin sera dérivé du chemin de sortie.",
[nameof(AdvancedOptionsTooltip)] = "Basculer les options avancées",
[nameof(ExportButton)] = "EXPORTER",
// Delete Setup
[nameof(DeleteWarningTitle)] =
"⚠ AVERTISSEMENT : Cette action supprimera définitivement les messages du canal.",
[nameof(DeleteWarningSubtext)] =
"Vous ne pouvez supprimer que vos propres messages. Les messages des autres utilisateurs seront ignorés.",
[nameof(DeleteAfterDateTooltip)] =
"Supprimer uniquement les messages envoyés après cette date",
[nameof(DeleteBeforeDateTooltip)] =
"Supprimer uniquement les messages envoyés avant cette date",
[nameof(DeleteAfterTimeTooltip)] =
"Supprimer uniquement les messages envoyés après cette heure",
[nameof(DeleteBeforeTimeTooltip)] =
"Supprimer uniquement les messages envoyés avant cette heure",
[nameof(DeleteButton)] = "SUPPRIMER",
// Common buttons
[nameof(CloseButton)] = "FERMER",
[nameof(CancelButton)] = "ANNULER",
@ -154,5 +170,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux",
[nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux",
[nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès",
[nameof(ErrorDeletingTitle)] = "Erreur lors de la suppression des messages",
[nameof(SuccessfulDeletionMessage)] = "{0} message(s) supprimé(s), {1} échec(s)",
};
}

View file

@ -13,6 +13,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)",
[nameof(SettingsTooltip)] = "Einstellungen",
[nameof(LastMessageSentTooltip)] = "Letzte Nachricht gesendet:",
[nameof(ExportTooltip)] = "Ausgewählte Kanäle exportieren",
[nameof(DeleteMessagesTooltip)] = "Nachrichten aus ausgewählten Kanälen löschen",
[nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Token für Ihr persönliches Konto abrufen:",
@ -129,6 +131,20 @@ public partial class LocalizationManager
"Assets in dieses Verzeichnis herunterladen. Wenn nicht angegeben, wird der Asset-Verzeichnispfad vom Ausgabepfad abgeleitet.",
[nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten",
[nameof(ExportButton)] = "EXPORTIEREN",
// Delete Setup
[nameof(DeleteWarningTitle)] =
"⚠ WARNUNG: Dadurch werden Nachrichten dauerhaft aus dem Kanal gelöscht.",
[nameof(DeleteWarningSubtext)] =
"Sie können nur Ihre eigenen Nachrichten löschen. Nachrichten anderer Benutzer werden übersprungen.",
[nameof(DeleteAfterDateTooltip)] =
"Nur Nachrichten löschen, die nach diesem Datum gesendet wurden",
[nameof(DeleteBeforeDateTooltip)] =
"Nur Nachrichten löschen, die vor diesem Datum gesendet wurden",
[nameof(DeleteAfterTimeTooltip)] =
"Nur Nachrichten löschen, die nach dieser Uhrzeit gesendet wurden",
[nameof(DeleteBeforeTimeTooltip)] =
"Nur Nachrichten löschen, die vor dieser Uhrzeit gesendet wurden",
[nameof(DeleteButton)] = "LÖSCHEN",
// Common buttons
[nameof(CloseButton)] = "SCHLIESSEN",
[nameof(CancelButton)] = "ABBRECHEN",
@ -158,5 +174,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle",
[nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle",
[nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert",
[nameof(ErrorDeletingTitle)] = "Fehler beim Löschen der Nachrichten",
[nameof(SuccessfulDeletionMessage)] = "{0} Nachricht(en) gelöscht, {1} fehlgeschlagen",
};
}

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)",
[nameof(SettingsTooltip)] = "Ajustes",
[nameof(LastMessageSentTooltip)] = "Último mensaje enviado:",
[nameof(ExportTooltip)] = "Exportar canales seleccionados",
[nameof(DeleteMessagesTooltip)] = "Eliminar mensajes de los canales seleccionados",
[nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Cómo obtener el token para tu cuenta personal:",
@ -123,6 +125,20 @@ public partial class LocalizationManager
"Descargar recursos en este directorio. Si no se especifica, la ruta se derivará de la ruta de salida.",
[nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas",
[nameof(ExportButton)] = "EXPORTAR",
// Delete Setup
[nameof(DeleteWarningTitle)] =
"⚠ ADVERTENCIA: Esto eliminará permanentemente los mensajes del canal.",
[nameof(DeleteWarningSubtext)] =
"Solo puedes eliminar tus propios mensajes. Se omitirán los mensajes de otros usuarios.",
[nameof(DeleteAfterDateTooltip)] =
"Eliminar solo los mensajes enviados después de esta fecha",
[nameof(DeleteBeforeDateTooltip)] =
"Eliminar solo los mensajes enviados antes de esta fecha",
[nameof(DeleteAfterTimeTooltip)] =
"Eliminar solo los mensajes enviados después de esta hora",
[nameof(DeleteBeforeTimeTooltip)] =
"Eliminar solo los mensajes enviados antes de esta hora",
[nameof(DeleteButton)] = "ELIMINAR",
// Common buttons
[nameof(CloseButton)] = "CERRAR",
[nameof(CancelButton)] = "CANCELAR",
@ -152,5 +168,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales",
[nameof(ErrorExportingTitle)] = "Error al exportar canal(es)",
[nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito",
[nameof(ErrorDeletingTitle)] = "Error al eliminar mensajes",
[nameof(SuccessfulDeletionMessage)] = "{0} mensaje(s) eliminado(s), {1} fallido(s)",
};
}

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)",
[nameof(SettingsTooltip)] = "Налаштування",
[nameof(LastMessageSentTooltip)] = "Останнє повідомлення:",
[nameof(ExportTooltip)] = "Експортувати вибрані канали",
[nameof(DeleteMessagesTooltip)] = "Видалити повідомлення з вибраних каналів",
[nameof(TokenPlaceholderText)] = "Токен",
// Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:",
@ -123,6 +125,19 @@ public partial class LocalizationManager
"Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.",
[nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри",
[nameof(ExportButton)] = "ЕКСПОРТУВАТИ",
// Delete Setup
[nameof(DeleteWarningTitle)] = "⚠ УВАГА: Це назавжди видалить повідомлення з каналу.",
[nameof(DeleteWarningSubtext)] =
"Ви можете видаляти лише власні повідомлення. Повідомлення інших користувачів буде пропущено.",
[nameof(DeleteAfterDateTooltip)] =
"Видаляти лише повідомлення, надіслані після цієї дати",
[nameof(DeleteBeforeDateTooltip)] =
"Видаляти лише повідомлення, надіслані до цієї дати",
[nameof(DeleteAfterTimeTooltip)] =
"Видаляти лише повідомлення, надіслані після цього часу",
[nameof(DeleteBeforeTimeTooltip)] =
"Видаляти лише повідомлення, надіслані до цього часу",
[nameof(DeleteButton)] = "ВИДАЛИТИ",
// Common buttons
[nameof(CloseButton)] = "ЗАКРИТИ",
[nameof(CancelButton)] = "СКАСУВАТИ",
@ -151,5 +166,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
[nameof(ErrorDeletingTitle)] = "Помилка видалення повідомлень",
[nameof(SuccessfulDeletionMessage)] = "Видалено {0} повідомлення(-ь), {1} не вдалося",
};
}

View file

@ -75,6 +75,8 @@ public partial class LocalizationManager
public string PullGuildsTooltip => Get();
public string SettingsTooltip => Get();
public string LastMessageSentTooltip => Get();
public string ExportTooltip => Get();
public string DeleteMessagesTooltip => Get();
public string TokenPlaceholderText => Get();
// Token instructions (personal account)
@ -141,6 +143,16 @@ public partial class LocalizationManager
public string AdvancedOptionsTooltip => Get();
public string ExportButton => Get();
// ---- Delete Setup ----
public string DeleteWarningTitle => Get();
public string DeleteWarningSubtext => Get();
public string DeleteAfterDateTooltip => Get();
public string DeleteBeforeDateTooltip => Get();
public string DeleteAfterTimeTooltip => Get();
public string DeleteBeforeTimeTooltip => Get();
public string DeleteButton => Get();
// ---- Common buttons ----
public string CloseButton => Get();
@ -162,4 +174,6 @@ public partial class LocalizationManager
public string ErrorPullingChannelsTitle => Get();
public string ErrorExportingTitle => Get();
public string SuccessfulExportMessage => Get();
public string ErrorDeletingTitle => Get();
public string SuccessfulDeletionMessage => Get();
}

View file

@ -56,7 +56,11 @@ public partial class DashboardViewModel : ViewModelBase
),
SelectedChannels.WatchProperty(
o => o.Count,
_ => ExportCommand.NotifyCanExecuteChanged()
_ =>
{
ExportCommand.NotifyCanExecuteChanged();
DeleteMessagesCommand.NotifyCanExecuteChanged();
}
)
);
}
@ -66,6 +70,7 @@ public partial class DashboardViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteMessagesCommand))]
public partial bool IsBusy { get; set; }
public LocalizationManager LocalizationManager { get; }
@ -324,6 +329,129 @@ 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(
string.Format(
LocalizationManager.SuccessfulDeletionMessage,
totalDeletedCount,
totalFailedCount
)
);
}
catch (Exception ex)
{
var dialog = _viewModelManager.GetMessageBoxViewModel(
LocalizationManager.ErrorDeletingTitle,
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
IsBusy = false;
}
}
protected override void Dispose(bool disposing)
{
if (disposing)

View file

@ -0,0 +1,61 @@
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;
using DiscordChatExporter.Gui.Localization;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public partial class DeleteSetupViewModel(LocalizationManager localizationManager)
: DialogViewModelBase
{
public LocalizationManager LocalizationManager { get; } = localizationManager;
[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);
}
}

View file

@ -284,24 +284,47 @@
</ScrollViewer>
</Panel>
<!-- Action buttons -->
<StackPanel
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="32,24"
Orientation="Horizontal"
Spacing="16">
<!-- Delete button -->
<Button
Width="56"
Height="56"
Padding="0"
Background="#FFE74C3C"
Command="{Binding DeleteMessagesCommand}"
Foreground="White"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="{Binding LocalizationManager.DeleteMessagesTooltip}">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Delete" />
</Button>
<!-- Export button -->
<Button
Width="56"
Height="56"
Margin="32,24"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}">
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="{Binding LocalizationManager.ExportTooltip}">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
</StackPanel>
</Panel>
</DockPanel>
</UserControl>

View 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="{Binding LocalizationManager.ChannelsSelectedText}" />
</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="{Binding LocalizationManager.DeleteWarningTitle}" />
<LineBreak />
<Run FontWeight="Normal" Text="{Binding LocalizationManager.DeleteWarningSubtext}" />
</TextBlock>
</Border>
<!-- Date limits -->
<Grid ColumnDefinitions="*,*" RowDefinitions="*,*">
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
SelectedDate="{Binding AfterDate}"
ToolTip.Tip="{Binding LocalizationManager.DeleteAfterDateTooltip}">
<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="{Binding LocalizationManager.BeforeDateLabel}"
SelectedDate="{Binding BeforeDate}"
ToolTip.Tip="{Binding LocalizationManager.DeleteBeforeDateTooltip}">
<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="{Binding LocalizationManager.AfterTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime}"
ToolTip.Tip="{Binding LocalizationManager.DeleteAfterTimeTooltip}">
<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="{Binding LocalizationManager.BeforeTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime}"
ToolTip.Tip="{Binding LocalizationManager.DeleteBeforeTimeTooltip}">
<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="{Binding LocalizationManager.DeleteButton}"
IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" />
<Button
Grid.Column="2"
Margin="16,0,0,0"
Command="{Binding CancelCommand}"
Content="{Binding LocalizationManager.CancelButton}"
IsCancel="True"
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>

View file

@ -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
}
}