diff --git a/README.md b/README.md
index 2731104..6d3ec65 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,14 @@
[](https://github.com/thorstenalpers/CleanMyPosts/actions/workflows/ci.yml)
[](https://github.com/thorstenalpers/CleanMyPosts)
-**CleanMyPosts** is a lightweight Windows desktop app that securely deletes all tweets, likes, and followings from your X (formerly Twitter) account in bulk using browser automation.
+**CleanMyPosts** is a lightweight Windows desktop app that securely deletes all posts, reposts, replies, likes, and followings from your X (formerly Twitter) account in bulk using browser automation.
## 🚀 Features
-- Bulk delete all tweets from your X (Twitter) account
+- Bulk delete all posts
+- Bulk delete all reposts
+- Bulk delete all replies
- Remove all likes with a single click
- Unfollow all accounts in one go
- Secure browser automation — no credentials stored
@@ -48,15 +50,27 @@ Please keep in mind:
Here’s a quick look at how CleanMyPosts works:
- Clean posts
+ Delete posts
-
+
+
+
+
+ Delete reposts
+
+
+
+
+
+ Delete replies
+
+
Clean likes
-
+
@@ -76,6 +90,10 @@ Click on your profile to find your username, then use these links (replace USERN
* **Delete posts:** https://x.com/search?q=from%3AUSERNAME → click ... on each post → Delete.
+* **Delete reposts:** https://x.com/USERNAME → click ... on each repost icon → Delete.
+
+* **Delete replies:** https://x.com/USERNAME/with_replies → click ... on each post → Delete.
+
* **Unlike posts:** https://x.com/USERNAME/likes → click the heart to remove the like.
* **Unfollow accounts:** https://x.com/USERNAME/following → click unfollow.
diff --git a/assets/clean-following.gif b/assets/delete-following.gif
similarity index 100%
rename from assets/clean-following.gif
rename to assets/delete-following.gif
diff --git a/assets/clean-likes.gif b/assets/delete-likes.gif
similarity index 100%
rename from assets/clean-likes.gif
rename to assets/delete-likes.gif
diff --git a/assets/clean-posts.gif b/assets/delete-posts.gif
similarity index 100%
rename from assets/clean-posts.gif
rename to assets/delete-posts.gif
diff --git a/release-notes/v2.0.0.md b/release-notes/v2.0.0.md
new file mode 100644
index 0000000..8d5c91d
--- /dev/null
+++ b/release-notes/v2.0.0.md
@@ -0,0 +1,5 @@
+### What's Changed
+
+- **Bulk Delete Reposts**: Quickly remove multiple reposts in one action to maintain a clean and streamlined profile.
+- **Bulk Delete Replies**: Effortlessly delete multiple replies at once, helping you manage and organize your interactions more efficiently.
+- **Improved Log View**: The log display now uses HTML formatting, offering clearer and more structured output for better readability.
\ No newline at end of file
diff --git a/src/Core/Contracts/Services/IFileService.cs b/src/Core/Contracts/Services/IFileService.cs
index 3835410..3cc3352 100644
--- a/src/Core/Contracts/Services/IFileService.cs
+++ b/src/Core/Contracts/Services/IFileService.cs
@@ -7,4 +7,5 @@ public interface IFileService
void Save(string folderPath, string fileName, T content);
void Delete(string folderPath, string fileName);
+ string ReadFile(string filePath);
}
diff --git a/src/Core/Services/FileService.cs b/src/Core/Services/FileService.cs
index e970c5f..6c46cfa 100644
--- a/src/Core/Services/FileService.cs
+++ b/src/Core/Services/FileService.cs
@@ -17,6 +17,11 @@ public T Read(string folderPath, string fileName)
return default;
}
+ public string ReadFile(string filePath)
+ {
+ return File.ReadAllText(filePath);
+ }
+
public void Save(string folderPath, string fileName, T content)
{
if (!Directory.Exists(folderPath))
diff --git a/src/Tests/Services/XScriptServiceTests.cs b/src/Tests/Services/XScriptServiceTests.cs
index ee17325..15b2e88 100644
--- a/src/Tests/Services/XScriptServiceTests.cs
+++ b/src/Tests/Services/XScriptServiceTests.cs
@@ -1,3 +1,4 @@
+using CleanMyPosts.Core.Contracts.Services;
using CleanMyPosts.UI.Contracts.Services;
using CleanMyPosts.UI.Models;
using CleanMyPosts.UI.Services;
@@ -12,97 +13,153 @@ public class XScriptServiceTests
private readonly Mock> _loggerMock = new();
private readonly Mock _webViewHostServiceMock = new();
private readonly Mock _userSettingsServiceMock = new();
+ private readonly Mock _fileServiceMock = new();
private readonly XScriptService _service;
public XScriptServiceTests()
{
_userSettingsServiceMock.Setup(x => x.GetTimeoutSettings()).Returns(new TimeoutSettings
{
- WaitAfterDocumentLoad = 500,
+ WaitAfterDocumentLoad = 10,
WaitAfterDelete = 1,
WaitBetweenRetryDeleteAttempts = 0
});
- _service = new XScriptService(_loggerMock.Object, _webViewHostServiceMock.Object, _userSettingsServiceMock.Object);
- // Set _userName via reflection for tests that require it
+
+ _webViewHostServiceMock.SetupProperty(x => x.Source);
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync("document.readyState"))
+ .ReturnsAsync("\"complete\"");
+
+ // Setup ExecuteScriptAsync for username retrieval (default)
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is(s => s.Contains("AppTabBar_Profile_Link"))))
+ .ReturnsAsync("\"testuser\"");
+
+ // Setup dummy for Reload (do nothing)
+ _webViewHostServiceMock.Setup(x => x.Reload());
+
+ _fileServiceMock.Setup(x => x.Read(It.IsAny(), It.IsAny()))
+ .Returns("// dummy js script");
+
+ _service = new XScriptService(
+ _loggerMock.Object,
+ _webViewHostServiceMock.Object,
+ _userSettingsServiceMock.Object,
+ _fileServiceMock.Object
+ );
+
+ // Pre-set _userName to "testuser" to avoid fetching it from JS during most tests
typeof(XScriptService).GetField("_userName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.SetValue(_service, "testuser");
}
[Fact]
- public async Task ShowPostsAsync_NavigatesToCorrectUrl()
+ public async Task ShowRepostsAsync_NavigatesToCorrectUrl()
{
- _webViewHostServiceMock.SetupProperty(x => x.Source);
- _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.IsAny())).ReturnsAsync("\"complete\"");
- _webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny>());
+ await _service.ShowRepostsAsync();
- var navigationCompletedArgs = new NavigationCompletedEventArgs { IsSuccess = true };
- _webViewHostServiceMock.Raise(x => x.NavigationCompleted += null, this, navigationCompletedArgs);
+ Assert.NotNull(_webViewHostServiceMock.Object.Source);
+ Assert.Contains("https://x.com/testuser", _webViewHostServiceMock.Object.Source.ToString());
+ }
- var task = _service.ShowPostsAsync();
- await Task.Delay(10); // Let async code run
+ [Fact]
+ public async Task ShowRepliesAsync_NavigatesToCorrectUrl()
+ {
+ await _service.ShowRepliesAsync();
- Assert.Contains("x.com/search", _webViewHostServiceMock.Object.Source.ToString());
+ Assert.NotNull(_webViewHostServiceMock.Object.Source);
+ Assert.Contains("https://x.com/testuser/with_replies", _webViewHostServiceMock.Object.Source.ToString());
}
[Fact]
- public async Task ShowLikesAsync_NavigatesToCorrectUrl()
+ public async Task GetUserNameAsync_ReturnsCleanUserName()
{
- _webViewHostServiceMock.SetupProperty(x => x.Source);
- _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.IsAny())).ReturnsAsync("\"complete\"");
- _webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny>());
+ var fakeJsResult = "\"\\\"testuser\\\"\""; // double quoted JSON string
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is(s => s.Contains("AppTabBar_Profile_Link"))))
+ .ReturnsAsync(fakeJsResult);
- var navigationCompletedArgs = new NavigationCompletedEventArgs { IsSuccess = true };
- _webViewHostServiceMock.Raise(x => x.NavigationCompleted += null, this, navigationCompletedArgs);
+ var username = await _service.GetUserNameAsync();
- var task = _service.ShowLikesAsync();
- await Task.Delay(10);
+ Assert.Equal("testuser", username);
+ }
- Assert.Contains("/likes", _webViewHostServiceMock.Object.Source.ToString());
+ [Fact]
+ public async Task NavigateAsync_SetsSourceWhenUrlIsDifferent()
+ {
+ var url = new Uri("https://x.com/differentuser");
+ _webViewHostServiceMock.Object.Source = new Uri("https://x.com/testuser");
+
+ var navigateMethod = _service.GetType()
+ .GetMethod("NavigateAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var result = await (Task)navigateMethod.Invoke(_service, new object[] { url });
+
+ Assert.Equal(url, _webViewHostServiceMock.Object.Source);
+ Assert.True(result);
}
[Fact]
- public async Task ShowFollowingAsync_NavigatesToCorrectUrl()
+ public async Task ShowLikesAsync_NavigatesToLikesUrl()
{
- _webViewHostServiceMock.SetupProperty(x => x.Source);
- _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.IsAny())).ReturnsAsync("\"complete\"");
- _webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny>());
+ await _service.ShowLikesAsync();
+
+ var source = _webViewHostServiceMock.Object.Source?.ToString();
+ Assert.NotNull(source);
+ Assert.Contains("https://x.com/testuser/likes", source);
+ }
+
+ [Fact]
+ public async Task ShowPostsAsync_NavigatesToCorrectSearchUrl()
+ {
+ await _service.ShowPostsAsync();
+
+ var source = _webViewHostServiceMock.Object.Source?.ToString();
+ Assert.NotNull(source);
+ Assert.Contains("https://x.com/search?q=from%3Atestuser", source);
+ }
+
- var navigationCompletedArgs = new NavigationCompletedEventArgs { IsSuccess = true };
- _webViewHostServiceMock.Raise(x => x.NavigationCompleted += null, this, navigationCompletedArgs);
- var task = _service.ShowFollowingAsync();
- await Task.Delay(10);
+ [Fact]
+ public async Task EnsureUserNameAsync_UpdatesUserNameIfChanged()
+ {
+ typeof(XScriptService).GetField("_userName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
+ ?.SetValue(_service, "olduser");
+
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is(s => s.Contains("AppTabBar_Profile_Link"))))
+ .ReturnsAsync("\"newuser\"");
+
+ var method = _service.GetType().GetMethod("EnsureUserNameAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ await (Task)method.Invoke(_service, null);
- Assert.Contains("following", _webViewHostServiceMock.Object.Source.ToString());
+ var updatedUserName = typeof(XScriptService).GetField("_userName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
+ ?.GetValue(_service);
+
+ Assert.Equal("newuser", updatedUserName);
}
+
[Fact]
- public async Task PostsExistAsync_ReturnsTrueIfPostExists()
+ public async Task IsEmptyMessagePresentAsync_ReturnsTrueWhenEmptyStateExists()
{
- _webViewHostServiceMock.SetupSequence(x => x.ExecuteScriptAsync(It.IsAny()))
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is(s => s.Contains("emptyState"))))
.ReturnsAsync("true");
- var result = await (Task)typeof(XScriptService)
- .GetMethod("PostsExistAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
- .Invoke(_service, null)!;
+ var method = _service.GetType().GetMethod("IsEmptyMessagePresentAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var result = await (Task)method.Invoke(_service, null);
Assert.True(result);
}
+
[Fact]
- public async Task PostsExistAsync_ReturnsFalseIfNoPost()
+ public async Task IsAnArticlePresentAsync_ReturnsFalseWhenNoArticle()
{
- _webViewHostServiceMock.SetupSequence(x => x.ExecuteScriptAsync(It.IsAny()))
- .ReturnsAsync("false")
- .ReturnsAsync("false")
- .ReturnsAsync("false")
- .ReturnsAsync("false")
+ _webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is(s => s.Contains("article"))))
.ReturnsAsync("false");
- var result = await (Task)typeof(XScriptService)
- .GetMethod("PostsExistAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
- .Invoke(_service, null)!;
+ var method = _service.GetType().GetMethod("IsAnArticlePresentAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var result = await (Task)method.Invoke(_service, null);
Assert.False(result);
}
+
}
diff --git a/src/Tests/ViewModels/XViewModelTests.cs b/src/Tests/ViewModels/XViewModelTests.cs
index f86790f..cf4c647 100644
--- a/src/Tests/ViewModels/XViewModelTests.cs
+++ b/src/Tests/ViewModels/XViewModelTests.cs
@@ -47,20 +47,6 @@ private static void InvokePrivateMethod(object obj, string methodName, params ob
method.Invoke(obj, parameters);
}
- [StaFact]
- public async Task InitializeAsync_InitializesWebViewAndSetsSource()
- {
- var viewModel = CreateViewModel();
- var webView = new WebView2();
- var appConfig = new AppConfig();
-
- await viewModel.InitializeAsync(webView);
-
- _webViewHostServiceMock.Verify(s => s.InitializeAsync(webView), Times.Once);
- _webViewHostServiceMock.VerifySet(s => s.Source = new Uri(appConfig.XBaseUrl), Times.Once);
- _webViewHostServiceMock.Verify(s => s.ExecuteScriptAsync(It.IsAny()), Times.Once);
- }
-
[StaFact]
public async Task OnNavigationCompleted_UserLoggedIn_EnablesButtons()
{
diff --git a/src/UI/Contracts/Services/IXScriptService.cs b/src/UI/Contracts/Services/IXScriptService.cs
index 640f57e..407f01f 100644
--- a/src/UI/Contracts/Services/IXScriptService.cs
+++ b/src/UI/Contracts/Services/IXScriptService.cs
@@ -12,4 +12,8 @@ public interface IXScriptService
Task DeleteFollowingAsync();
Task GetUserNameAsync();
+ Task ShowRepostsAsync();
+ Task ShowRepliesAsync();
+ Task DeleteRepliesAsync();
+ Task DeleteRepostsAsync();
}
\ No newline at end of file
diff --git a/src/UI/Models/TimeoutSettings.cs b/src/UI/Models/TimeoutSettings.cs
index 2baf22d..d3a596b 100644
--- a/src/UI/Models/TimeoutSettings.cs
+++ b/src/UI/Models/TimeoutSettings.cs
@@ -2,7 +2,7 @@
public class TimeoutSettings
{
- public int WaitAfterDelete { get; set; } = 100;
- public int WaitBetweenRetryDeleteAttempts { get; set; } = 1000;
- public int WaitAfterDocumentLoad { get; set; } = 500;
+ public int WaitAfterDelete { get; set; } = 500;
+ public int WaitBetweenRetryDeleteAttempts { get; set; } = 500;
+ public int WaitAfterDocumentLoad { get; set; } = 3000;
}
\ No newline at end of file
diff --git a/src/UI/Properties/Resources.Designer.cs b/src/UI/Properties/Resources.Designer.cs
index d769c67..90c6332 100644
--- a/src/UI/Properties/Resources.Designer.cs
+++ b/src/UI/Properties/Resources.Designer.cs
@@ -79,7 +79,7 @@ public static string LogPage {
}
///
- /// Looks up a localized string similar to CleanMyPosts is a lightweight Windows desktop app that securely deletes all tweets, likes, and followings from your X (formerly Twitter) account in bulk using browser automation..
+ /// Looks up a localized string similar to CleanMyPosts is a lightweight Windows desktop app that securely deletes all posts, reposts, replies, likes, and followings from your X (formerly Twitter) account in bulk using browser automation..
///
public static string SettingsPageAboutText {
get {
diff --git a/src/UI/Properties/Resources.resx b/src/UI/Properties/Resources.resx
index 3bbe567..51a6c81 100644
--- a/src/UI/Properties/Resources.resx
+++ b/src/UI/Properties/Resources.resx
@@ -124,7 +124,7 @@
Settings
- CleanMyPosts is a lightweight Windows desktop app that securely deletes all tweets, likes, and followings from your X (formerly Twitter) account in bulk using browser automation.
+ CleanMyPosts is a lightweight Windows desktop app that securely deletes all posts, reposts, replies, likes, and followings from your X (formerly Twitter) account in bulk using browser automation.
About
diff --git a/src/UI/Scripts/delete-all-following.js b/src/UI/Scripts/delete-all-following.js
new file mode 100644
index 0000000..23d24e7
--- /dev/null
+++ b/src/UI/Scripts/delete-all-following.js
@@ -0,0 +1,104 @@
+async function DeleteAllFollowing(waitBeforeTryClickDelete, waitBetweenTryClickDeleteAttempts, maxConfirmAttempts = 5) {
+ function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ function isVisible(elem) {
+ return elem && elem.offsetParent !== null && !elem.disabled;
+ }
+
+ async function waitForUnfollowButton(selector, maxTotalTime = 5000, interval = 200) {
+ const start = Date.now();
+ while (true) {
+ const btn = document.querySelector(selector);
+ if (btn && isVisible(btn)) {
+ console.log("[waitForUnfollowButton] Found visible unfollow button");
+ return true;
+ }
+
+ const elapsed = Date.now() - start;
+ if (elapsed >= maxTotalTime) {
+ console.log(`[waitForUnfollowButton] Timeout reached (${elapsed}ms)`);
+ return false;
+ }
+
+ window.scrollBy(0, 500);
+ console.log("[waitForUnfollowButton] Scrolling...");
+ await delay(interval);
+ }
+ }
+
+ async function clickUnfollowButtonWithConfirm() {
+ const btn = document.querySelector('button[data-testid$="-unfollow"]');
+ if (!btn || !isVisible(btn)) {
+ console.log("[clickUnfollowButtonWithConfirm] No visible unfollow button found");
+ return false;
+ }
+
+ console.log("[clickUnfollowButtonWithConfirm] Clicking unfollow button");
+ btn.click();
+ await delay(waitBeforeTryClickDelete);
+
+ for (let attempt = 0; attempt < maxConfirmAttempts; attempt++) {
+ const confirmBtn = document.querySelector('button[data-testid="confirmationSheetConfirm"]');
+ if (confirmBtn && isVisible(confirmBtn)) {
+ console.log(`[clickUnfollowButtonWithConfirm] Clicking confirm on attempt #${attempt + 1}`);
+ confirmBtn.click();
+ return true;
+ }
+
+ const delayTime = waitBetweenTryClickDeleteAttempts * (attempt + 1);
+ console.log(`[clickUnfollowButtonWithConfirm] Confirm button not ready (attempt ${attempt + 1}), retrying in ${delayTime}ms...`);
+ await delay(delayTime);
+ }
+
+ console.log("[clickUnfollowButtonWithConfirm] Failed to confirm unfollow after retries");
+ return false;
+ }
+
+ async function tryUnfollow(maxTries) {
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
+ console.log(`[tryUnfollow] Attempt #${attempt}`);
+ const success = await clickUnfollowButtonWithConfirm();
+ if (success) {
+ console.log(`[tryUnfollow] Successfully unfollowed on attempt #${attempt}`);
+ return true;
+ }
+
+ if (attempt < maxTries) {
+ console.log(`[tryUnfollow] Will retry after delay...`);
+ await delay(waitBetweenTryClickDeleteAttempts);
+ }
+ }
+ return false;
+ }
+
+ let unfollowCount = 1;
+ window.followingDeletionDone = false;
+ window.deletedFollowing = 0;
+
+ console.log("[DeleteAllFollowing] Starting unfollow loop...");
+
+ while (true) {
+ const found = await waitForUnfollowButton('button[data-testid$="-unfollow"]', 5000, 200);
+ if (!found) {
+ console.log("[DeleteAllFollowing] No unfollow buttons found after timeout.");
+ break;
+ }
+
+ const success = await tryUnfollow(5);
+ if (success) {
+ console.log(`[DeleteAllFollowing] Unfollowed #${unfollowCount}`);
+ unfollowCount++;
+ window.deletedFollowing++;
+ } else {
+ console.log(`[DeleteAllFollowing] Could not unfollow #${unfollowCount}, aborting.`);
+ break;
+ }
+
+ await delay(waitBeforeTryClickDelete);
+ }
+
+ window.followingDeletionDone = true;
+ console.log(`[DeleteAllFollowing] Done. Total unfollowed: ${window.deletedFollowing}`);
+}
diff --git a/src/UI/Scripts/delete-all-likes.js b/src/UI/Scripts/delete-all-likes.js
new file mode 100644
index 0000000..23b452a
--- /dev/null
+++ b/src/UI/Scripts/delete-all-likes.js
@@ -0,0 +1,96 @@
+async function DeleteAllLikes(waitTime) {
+ function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ function isVisible(el) {
+ return el && el.offsetParent !== null;
+ }
+
+ async function waitForUnlikeButton(selector, maxTotalTime = 5000, interval = 200) {
+ const start = Date.now();
+ while (true) {
+ const btn = document.querySelector(selector);
+ if (btn && isVisible(btn)) {
+ console.log("[waitForUnlikeButton] Found visible unlike button");
+ return true;
+ }
+
+ const elapsed = Date.now() - start;
+ if (elapsed >= maxTotalTime) {
+ console.log(`[waitForUnlikeButton] Timeout reached (${elapsed}ms)`);
+ return false;
+ }
+
+ console.log("[waitForUnlikeButton] Scrolling down...");
+ window.scrollBy(0, 500);
+ await delay(interval);
+ }
+ }
+
+ async function clickUnlikeButton() {
+ const btn = document.querySelector('button[data-testid="unlike"]');
+ if (!btn || !isVisible(btn)) {
+ console.log("[clickUnlikeButton] No visible unlike button found");
+ return false;
+ }
+
+ console.log("[clickUnlikeButton] Clicking unlike button");
+ btn.click();
+ await delay(waitTime);
+
+ const stillPresent = document.querySelector('button[data-testid="unlike"]');
+ const success = !stillPresent;
+ console.log(`[clickUnlikeButton] Unlike button still present after delay? ${!!stillPresent}`);
+ return success;
+ }
+
+ async function tryUnlike(maxTries) {
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
+ console.log(`[tryUnlike] Attempt #${attempt}`);
+ const success = await clickUnlikeButton();
+ if (success) {
+ console.log(`[tryUnlike] Success on attempt #${attempt}`);
+ return true;
+ }
+
+ if (attempt < maxTries) {
+ const backoff = 500 + 500 * attempt;
+ console.log(`[tryUnlike] Failed attempt #${attempt}, retrying in ${backoff}ms...`);
+ await delay(backoff);
+ }
+ }
+
+ console.log(`[tryUnlike] Failed after ${maxTries} attempts`);
+ return false;
+ }
+
+ let postNumber = 1;
+ window.likesDeletionDone = false;
+ window.deletedLikes = 0;
+
+ console.log("[DeleteAllLikes] Starting unlike loop...");
+
+ while (true) {
+ const found = await waitForUnlikeButton('button[data-testid="unlike"]', 5000, 200);
+ if (!found) {
+ console.log("[DeleteAllLikes] No unlike buttons found. Ending.");
+ break;
+ }
+
+ const success = await tryUnlike(5);
+ if (success) {
+ console.log(`[DeleteAllLikes] Deleted like #${postNumber}`);
+ postNumber++;
+ window.deletedLikes++;
+ } else {
+ console.log(`[DeleteAllLikes] Failed to delete like #${postNumber}, stopping.`);
+ break;
+ }
+
+ await delay(waitTime);
+ }
+
+ window.likesDeletionDone = true;
+ console.log(`[DeleteAllLikes] Completed. Total likes removed: ${window.deletedLikes}`);
+}
diff --git a/src/UI/Scripts/delete-all-posts.js b/src/UI/Scripts/delete-all-posts.js
new file mode 100644
index 0000000..784b115
--- /dev/null
+++ b/src/UI/Scripts/delete-all-posts.js
@@ -0,0 +1,153 @@
+async function DeleteAllReplies(userName, waitAfterDelete, waitBetweenDeleteAttempts) {
+ function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ function isVisible(elem) {
+ return elem && elem.offsetParent !== null && !elem.disabled;
+ }
+
+ async function findVisibleCaretWithRetry(article, maxRetries = 7, delayMs = 300) {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ const caret = article.querySelector("button[data-testid='caret']");
+ if (caret && isVisible(caret)) {
+ return caret;
+ }
+ await delay(delayMs);
+ }
+ return null;
+ }
+
+ async function clickCaretWithScrollRetry() {
+ const maxScrollAttempts = 6;
+ const scrollDelay = 1000; // ms
+ for (let scrollAttempt = 0; scrollAttempt < maxScrollAttempts; scrollAttempt++) {
+ const articles = Array.from(document.querySelectorAll("article[data-testid='tweet']"));
+ const target = articles.find(article => article.querySelector(`a[href*="/${userName}"]`));
+
+ if (target) {
+ const caret = await findVisibleCaretWithRetry(target, 7, 300);
+ if (!caret) {
+ console.log("[clickCaretWithScrollRetry] Caret found but not visible after retries.");
+ return false;
+ }
+ caret.click();
+ return true;
+ }
+
+ // Not found yet: scroll to load more replies and wait
+ console.log(`[clickCaretWithScrollRetry] Caret not found, scrolling attempt ${scrollAttempt + 1}...`);
+ window.scrollBy(0, 1500);
+ await delay(scrollDelay);
+ }
+ console.log("[clickCaretWithScrollRetry] Caret not found after scrolling attempts.");
+ return false;
+ }
+
+ async function tryClickDelete(attempt = 0) {
+ const delays = [
+ waitBetweenDeleteAttempts,
+ waitBetweenDeleteAttempts * 2,
+ waitBetweenDeleteAttempts * 3,
+ waitBetweenDeleteAttempts * 4,
+ waitBetweenDeleteAttempts * 5,
+ ];
+
+ if (attempt >= delays.length) return false;
+
+ await delay(delays[attempt]);
+
+ const menu = document.querySelector("[role='menu']");
+ if (menu && menu.style.display !== "none") {
+ const items = document.querySelectorAll("[role='menuitem']");
+ for (const item of items) {
+ const span = item.querySelector("span");
+ if (!span) continue;
+
+ const color = getComputedStyle(span).color;
+ const rgb = color.match(/\d+/g).map(Number);
+ const [r, g, b] = rgb;
+
+ if (r > 180 && g < 100 && b < 100) {
+ span.click();
+ return true;
+ }
+ }
+ return tryClickDelete(attempt + 1);
+ } else {
+ return tryClickDelete(attempt + 1);
+ }
+ }
+
+ async function confirmDelete(attempt = 0) {
+ const delays = [
+ waitBetweenDeleteAttempts,
+ waitBetweenDeleteAttempts * 2,
+ waitBetweenDeleteAttempts * 3,
+ waitBetweenDeleteAttempts * 4,
+ waitBetweenDeleteAttempts * 5,
+ ];
+
+ if (attempt >= delays.length) return false;
+
+ await delay(delays[attempt]);
+
+ const confirmBtn = document.querySelector("button[data-testid='confirmationSheetConfirm']");
+ if (confirmBtn && confirmBtn.offsetParent !== null) {
+ confirmBtn.click();
+ return true;
+ } else {
+ return confirmDelete(attempt + 1);
+ }
+ }
+
+ async function waitForReplyCaret(maxWait, interval) {
+ const start = Date.now();
+ while (true) {
+ const caret = document.querySelector("article[data-testid='tweet'] button[data-testid='caret']");
+ if (caret) return true;
+
+ if ((Date.now() - start) > maxWait) return false;
+
+ window.scrollBy(0, 500);
+ await delay(interval);
+ }
+ }
+
+ window.repliesDeletionDone = false;
+ window.deletedReplies = 0;
+
+ while (true) {
+ const found = await waitForReplyCaret(5000, 200);
+ if (!found) {
+ console.log("[DeleteAllReplies] No more replies found.");
+ break;
+ }
+
+ const caretClicked = await clickCaretWithScrollRetry();
+ if (!caretClicked) {
+ console.log("[DeleteAllReplies] Failed to find and click caret.");
+ break;
+ }
+
+ await delay(waitBetweenDeleteAttempts);
+
+ const clickedDelete = await tryClickDelete();
+ if (!clickedDelete) {
+ console.log("[DeleteAllReplies] Failed to click delete option.");
+ break;
+ }
+
+ const confirmed = await confirmDelete();
+ if (!confirmed) {
+ console.log("[DeleteAllReplies] Failed to confirm delete.");
+ break;
+ }
+
+ window.deletedReplies++;
+ await delay(waitAfterDelete);
+ }
+
+ window.repliesDeletionDone = true;
+ console.log(`[DeleteAllReplies] Completed. Total replies deleted: ${window.deletedReplies}`);
+}
diff --git a/src/UI/Scripts/delete-all-replies.js b/src/UI/Scripts/delete-all-replies.js
new file mode 100644
index 0000000..f4ad91b
--- /dev/null
+++ b/src/UI/Scripts/delete-all-replies.js
@@ -0,0 +1,117 @@
+async function DeleteAllReplies(userName, waitAfterDelete, waitBetweenDeleteAttempts) {
+ function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ function isVisible(el) {
+ return el && el.offsetParent !== null;
+ }
+
+ async function waitForReplyCaret(maxWait = 5000, interval = 200) {
+ const start = Date.now();
+ while (Date.now() - start < maxWait) {
+ const caret = document.querySelector("article[data-testid='tweet'] button[data-testid='caret']");
+ if (caret && isVisible(caret)) return true;
+
+ window.scrollBy(0, 500);
+ await delay(interval);
+ }
+ return false;
+ }
+
+ async function findCaretWithRetry(article, maxRetries = 5, delayMs = 300) {
+ for (let i = 0; i < maxRetries; i++) {
+ const caret = article.querySelector("button[data-testid='caret']");
+ if (caret && isVisible(caret)) return caret;
+ await delay(delayMs);
+ }
+ return null;
+ }
+
+ async function tryClickDeleteMenuItem(attempts, baseDelay) {
+ for (let i = 0; i < attempts; i++) {
+ await delay(baseDelay * (i + 1));
+ const menuItems = document.querySelectorAll("[role='menuitem']");
+ for (const item of menuItems) {
+ const span = item.querySelector("span");
+ if (!span) continue;
+
+ const color = getComputedStyle(span).color;
+ const [r, g, b] = color.match(/\d+/g).map(Number);
+ if (r > 180 && g < 100 && b < 100) {
+ span.click();
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ async function tryConfirmDelete(attempts, baseDelay) {
+ for (let i = 0; i < attempts; i++) {
+ await delay(baseDelay * (i + 1));
+ const confirmBtn = document.querySelector("button[data-testid='confirmationSheetConfirm']");
+ if (confirmBtn && isVisible(confirmBtn)) {
+ confirmBtn.click();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ async function clickDeleteOnReply() {
+ const articles = Array.from(document.querySelectorAll("article[data-testid='tweet']"));
+ const replyArticle = articles.find(article => article.querySelector(`a[href*="/${userName}"]`));
+ if (!replyArticle) {
+ console.log("[clickDeleteOnReply] No matching reply article found.");
+ return false;
+ }
+
+ const caret = await findCaretWithRetry(replyArticle, 6, 300);
+ if (!caret) {
+ console.log("[clickDeleteOnReply] Caret not found in reply article.");
+ return false;
+ }
+
+ caret.click();
+ await delay(waitBetweenDeleteAttempts);
+
+ const deleteClicked = await tryClickDeleteMenuItem(5, waitBetweenDeleteAttempts);
+ if (!deleteClicked) {
+ console.log("[clickDeleteOnReply] Failed to click delete menu item.");
+ return false;
+ }
+
+ const confirmed = await tryConfirmDelete(5, waitBetweenDeleteAttempts);
+ if (!confirmed) {
+ console.log("[clickDeleteOnReply] Failed to confirm deletion.");
+ return false;
+ }
+
+ return true;
+ }
+
+ window.repliesDeletionDone = false;
+ window.deletedReplies = 0;
+
+ while (true) {
+ const found = await waitForReplyCaret(5000, 200);
+ if (!found) {
+ console.log("[DeleteAllReplies] No more visible replies.");
+ break;
+ }
+
+ const deleted = await clickDeleteOnReply();
+ if (!deleted) {
+ console.log("[DeleteAllReplies] Failed to delete a reply. Stopping.");
+ break;
+ }
+
+ window.deletedReplies++;
+ console.log(`[DeleteAllReplies] Deleted reply #${window.deletedReplies}`);
+ await delay(waitAfterDelete);
+ }
+
+ window.repliesDeletionDone = true;
+ console.log(`[DeleteAllReplies] Finished. Total deleted replies: ${window.deletedReplies}`);
+}
diff --git a/src/UI/Scripts/delete-all-reposts.js b/src/UI/Scripts/delete-all-reposts.js
new file mode 100644
index 0000000..009676c
--- /dev/null
+++ b/src/UI/Scripts/delete-all-reposts.js
@@ -0,0 +1,102 @@
+async function DeleteAllRepost(waitTime) {
+ function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ function isVisible(elem) {
+ return elem && elem.offsetParent !== null && !elem.disabled;
+ }
+
+ async function waitForUnretweetButton(selector, maxTotalTime, interval) {
+ const start = Date.now();
+ while (true) {
+ const btn = document.querySelector(selector);
+ if (btn && isVisible(btn)) {
+ console.log("[waitForUnretweetButton] Found unretweet button");
+ return true;
+ }
+
+ const elapsed = Date.now() - start;
+ if (elapsed >= maxTotalTime) {
+ console.log(`[waitForUnretweetButton] Timeout reached (${elapsed}ms)`);
+ return false;
+ }
+
+ console.log("[waitForUnretweetButton] Scrolling to find button...");
+ window.scrollBy(0, 500);
+ await delay(interval);
+ }
+ }
+
+ async function clickUnretweetButtonWithRetry(maxTries = 5) {
+ for (let attempt = 1; attempt <= maxTries; attempt++) {
+ const btn = document.querySelector('button[data-testid="unretweet"]');
+ if (btn && isVisible(btn)) {
+ console.log(`[clickUnretweetButton] Clicking unretweet (attempt ${attempt})`);
+ btn.click();
+ await delay(500);
+
+ const confirmResult = await confirmUnretweet(waitTime);
+ if (confirmResult) return true;
+ } else {
+ console.log(`[clickUnretweetButton] Button not found or not visible (attempt ${attempt})`);
+ }
+
+ await delay(500);
+ }
+
+ console.log("[clickUnretweetButtonWithRetry] Failed to unretweet after retries.");
+ return false;
+ }
+
+ async function confirmUnretweet(waitTime, maxRetries = 5) {
+ const delays = Array.from({ length: maxRetries }, (_, i) => waitTime * (i + 1));
+ for (let i = 0; i < delays.length; i++) {
+ await delay(delays[i]);
+
+ const menuItem = document.querySelector('div[role="menuitem"][data-testid="unretweetConfirm"]');
+ if (menuItem && isVisible(menuItem)) {
+ console.log(`[confirmUnretweet] Confirming unretweet (attempt ${i + 1})`);
+ menuItem.click();
+ await delay(waitTime);
+ return true;
+ } else {
+ console.log(`[confirmUnretweet] Confirm button not ready (attempt ${i + 1})`);
+ }
+ }
+
+ console.log("[confirmUnretweet] Failed to confirm after retries.");
+ return false;
+ }
+
+ // MAIN LOOP
+ let postNumber = 1;
+ let deletedCount = 0;
+ window.repostsDeletionDone = false;
+
+ console.log("[DeleteAllRepost] Starting repost deletion loop...");
+
+ while (true) {
+ const found = await waitForUnretweetButton('button[data-testid="unretweet"]', 5000, 200);
+ if (!found) {
+ console.log("[DeleteAllRepost] No more unretweet buttons found.");
+ break;
+ }
+
+ const deleted = await clickUnretweetButtonWithRetry(5);
+ if (deleted) {
+ console.log(`[DeleteAllRepost] Deleted repost #${postNumber}`);
+ postNumber++;
+ deletedCount++;
+ } else {
+ console.log(`[DeleteAllRepost] Failed to delete repost #${postNumber}, stopping.`);
+ break;
+ }
+
+ await delay(waitTime);
+ }
+
+ window.deletedReposts = deletedCount;
+ window.repostsDeletionDone = true;
+ console.log(`[DeleteAllRepost] Done. Total reposts deleted: ${deletedCount}`);
+}
diff --git a/src/UI/Services/WebViewHostService.cs b/src/UI/Services/WebViewHostService.cs
index 4376506..fceb65a 100644
--- a/src/UI/Services/WebViewHostService.cs
+++ b/src/UI/Services/WebViewHostService.cs
@@ -2,14 +2,16 @@
using System.Windows;
using CleanMyPosts.UI.Contracts.Services;
using CleanMyPosts.UI.Models;
+using Microsoft.Extensions.Logging;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
namespace CleanMyPosts.UI.Services;
-public class WebViewHostService : IWebViewHostService
+public class WebViewHostService(ILogger logger) : IWebViewHostService
{
private WebView2 _webView;
+ private readonly ILogger _logger = logger;
public event EventHandler NavigationCompleted;
public event EventHandler WebMessageReceived;
@@ -52,6 +54,7 @@ public Task ExecuteScriptAsync(string script)
public void Reload()
{
_webView.Reload();
+ _logger.LogInformation("Page reloaded");
}
public void Hide(bool hide)
diff --git a/src/UI/Services/XScriptService.cs b/src/UI/Services/XScriptService.cs
index f618f1b..1cd272d 100644
--- a/src/UI/Services/XScriptService.cs
+++ b/src/UI/Services/XScriptService.cs
@@ -1,190 +1,60 @@
-using System.Net;
+using System.IO;
+using System.Net;
using Ardalis.GuardClauses;
+using CleanMyPosts.Core.Contracts.Services;
using CleanMyPosts.UI.Contracts.Services;
using CleanMyPosts.UI.Helpers;
using Microsoft.Extensions.Logging;
namespace CleanMyPosts.UI.Services;
-public class XScriptService(ILogger logger, IWebViewHostService webViewHostService, IUserSettingsService userSettingsService) : IXScriptService
+public class XScriptService(ILogger logger, IWebViewHostService webViewHostService, IUserSettingsService userSettingsService, IFileService fileService) : IXScriptService
{
private readonly ILogger _logger = logger;
private readonly IWebViewHostService _webViewHostService = webViewHostService;
private readonly IUserSettingsService _userSettingsService = userSettingsService;
+ private readonly IFileService _fileService = fileService;
private string _userName;
- public async Task ShowPostsAsync()
+ public async Task ShowRepostsAsync()
{
await EnsureUserNameAsync();
Guard.Against.Null(_userName);
- var searchQuery = $"from:{_userName}";
- var encodedQuery = WebUtility.UrlEncode(searchQuery);
- var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
+ var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}");
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to {Url} failed.", url);
- }
-
- _logger.LogInformation("Navigated to {Url}", url);
+ await NavigateAsync(url);
}
- public async Task DeletePostsAsync()
+ public async Task ShowPostsAsync()
{
await EnsureUserNameAsync();
Guard.Against.Null(_userName);
-
var searchQuery = $"from:{_userName}";
var encodedQuery = WebUtility.UrlEncode(searchQuery);
var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to search page failed.");
- return 0;
- }
- var postNumber = 1;
- var deletedItems = 0;
- var retryCount = 0;
- while (await PostsExistAsync() || retryCount < 3)
- {
- var countBefore = await GetPostsCountAsync();
-
- _logger.LogInformation("Found {Count} posts before deletion.", countBefore);
-
- try
- {
- _logger.LogInformation("Deleting post #{Number}...", postNumber);
- await DeleteSinglePostAsync();
-
- if (await WaitForPostDeletedAsync(countBefore))
- {
- _logger.LogInformation("Post #{Number} cleaned successfully.", postNumber);
- deletedItems++;
- retryCount = 0;
- }
- else
- {
- _logger.LogWarning("Post #{Number} was not deleted (DOM unchanged).", postNumber);
- retryCount++;
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting post #{Number}.", postNumber);
- retryCount++;
- }
- postNumber++;
- }
- _webViewHostService.Reload();
- await WaitForDocumentReadyAsync();
-
- _logger.LogInformation("Deleted {TotalPosts} posts.", postNumber);
- return deletedItems;
+ await NavigateAsync(url);
}
- public async Task ShowLikesAsync()
+ public async Task ShowRepliesAsync()
{
await EnsureUserNameAsync();
Guard.Against.Null(_userName);
- var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
+ var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/with_replies");
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
-
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to {Url} failed.", url);
- }
-
- _logger.LogInformation("Navigated to {Url}", url);
+ await NavigateAsync(url);
}
- public async Task DeleteLikesAsync()
+ public async Task ShowLikesAsync()
{
await EnsureUserNameAsync();
Guard.Against.Null(_userName);
var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to search page failed.");
- return 0;
- }
-
- var postNumber = 1;
- var deletedItems = 0;
- var retryCount = 0;
-
- while (await LikesExistAsync() || retryCount < 3)
- {
- var countBefore = await GetLikesCountAsync();
-
- _logger.LogInformation("Found {Count} likes before deletion.", countBefore);
-
- try
- {
- _logger.LogInformation("Deleting like #{Number}...", postNumber);
- await DeleteSingleLikeAsync();
-
- if (await WaitForLikeDeletedAsync(countBefore))
- {
- _logger.LogInformation("Like #{Number} cleaned successfully.", postNumber);
- deletedItems++;
- retryCount = 0;
- }
- else
- {
- _logger.LogWarning("Like #{Number} was not cleaned (DOM unchanged).", postNumber);
- retryCount++;
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting like #{Number}.", postNumber);
- retryCount++;
- }
- postNumber++;
- }
-
- _webViewHostService.Reload();
- await WaitForDocumentReadyAsync();
-
- _logger.LogInformation("Deleted {TotalLikes} Likes.", postNumber);
- return deletedItems;
+ await NavigateAsync(url);
}
public async Task ShowFollowingAsync()
@@ -194,369 +64,132 @@ public async Task ShowFollowingAsync()
var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
-
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to {Url} failed.", url);
- }
-
- _logger.LogInformation("Navigated to {Url}", url);
+ await NavigateAsync(url);
}
-
- public async Task DeleteFollowingAsync()
+ public async Task DeletePostsAsync()
{
await EnsureUserNameAsync();
Guard.Against.Null(_userName);
- var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
-
- if (_webViewHostService.Source == url)
- {
- _webViewHostService.Reload();
- }
- else
- {
- _webViewHostService.Source = url;
- }
- if (!await WaitForDocumentReadyAsync())
- {
- _logger.LogWarning("Navigation to search page failed.");
- return 0;
- }
-
- var postNumber = 1;
- var deletedItems = 0;
- var retryCount = 0;
- while (await FollowingExistAsync() || retryCount < 3)
- {
- var countBefore = await GetFollowingCountAsync();
-
- _logger.LogInformation("Found {Count} following before deletion.", countBefore);
-
- try
- {
- _logger.LogInformation("Deleting following #{Number}...", postNumber);
- await DeleteSingleFollowingAsync();
-
- if (await WaitForFollowingDeletedAsync(countBefore))
- {
- _logger.LogInformation("Following #{Number} cleaned successfully.", postNumber);
- deletedItems++;
- retryCount = 0;
- }
- else
- {
- _logger.LogWarning("Following #{Number} was not cleaned (DOM unchanged).", postNumber);
- retryCount++;
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting following #{Number}.", postNumber);
- retryCount++;
- }
- postNumber++;
- }
-
- _webViewHostService.Reload();
- await WaitForDocumentReadyAsync();
+ var query = $"from:{_userName}";
+ var url = $"https://x.com/search?q={WebUtility.UrlEncode(query)}&src=typed_query";
+ var timeout = _userSettingsService.GetTimeoutSettings();
- _logger.LogInformation("Deleted {DeletedItems} Followings.", deletedItems);
- return deletedItems;
+ return await RunDeleteScriptAsync(
+ url,
+ isArticle: true,
+ scriptName: "delete-all-posts.js",
+ scriptDoneVar: "postsDeletionDone",
+ deletedVar: "deletedPosts",
+ functionName: "DeleteAllPosts",
+ timeout.WaitAfterDelete, timeout.WaitBetweenRetryDeleteAttempts
+ );
}
- private async Task PostsExistAsync()
- {
- const string js = "document.querySelector('div[data-testid=\"primaryColumn\"] section button[data-testid=\"caret\"]') !== null";
- var timeout = _userSettingsService.GetTimeoutSettings();
- var waitAfterDocumentLoad = timeout.WaitAfterDocumentLoad;
- for (var i = 0; i < 5; i++)
- {
- await Task.Delay(waitAfterDocumentLoad);
- if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
- {
- return true;
- }
- }
- return false;
- }
- private async Task FollowingExistAsync()
+ public async Task DeleteRepostsAsync()
{
- const string js = "document.querySelector('button[data-testid$=\"unfollow\"]') !== null";
- var timeout = _userSettingsService.GetTimeoutSettings();
- var waitAfterDocumentLoad = timeout.WaitAfterDocumentLoad;
- for (var i = 0; i < 5; i++)
- {
- await Task.Delay(waitAfterDocumentLoad);
- if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
- {
- return true;
- }
- }
- return false;
- }
+ await EnsureUserNameAsync();
+ Guard.Against.Null(_userName);
- private async Task LikesExistAsync()
- {
- const string js = "document.querySelector('button[data-testid=\"unlike\"]') !== null";
+ var url = $"https://x.com/{WebUtility.UrlEncode(_userName)}";
var timeout = _userSettingsService.GetTimeoutSettings();
- var waitAfterDocumentLoad = timeout.WaitAfterDocumentLoad;
- await Task.Delay(500);
- for (var i = 0; i < 5; i++)
- {
- await Task.Delay(waitAfterDocumentLoad);
- if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
- {
- return true;
- }
- }
- return false;
- }
- private async Task DeleteSinglePostAsync()
- {
- var timeout = _userSettingsService.GetTimeoutSettings();
- var waitAfterDelete = timeout.WaitAfterDelete;
- var waitBetweenDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
-
- var js = $@"
- (() => {{
- const caret = document.querySelector(""div[data-testid='primaryColumn'] section button[data-testid='caret']"");
- if (!caret) return;
-
- caret.click();
-
- setTimeout(() => {{
- const delays = [ {waitBetweenDeleteAttempts},
- {waitBetweenDeleteAttempts * 2},
- {waitBetweenDeleteAttempts * 3},
- {waitBetweenDeleteAttempts * 4},
- {waitBetweenDeleteAttempts * 5} ];
-
- function tryClickDelete(attempt = 0) {{
- if (attempt >= delays.length) return;
- setTimeout(() => {{
- const menu = document.querySelector(""[role='menu']"");
- if (menu && menu.style.display !== ""none"") {{
- const items = document.querySelectorAll(""[role='menuitem']"");
- for (const item of items) {{
- const span = item.querySelector(""span"");
- if (!span) continue;
-
- const color = getComputedStyle(span).color;
- const rgb = color.match(/\d+/g).map(Number);
- const [r, g, b] = rgb;
- if (r > 180 && g < 100 && b < 100) {{
- span.click();
- confirmDelete();
- return;
- }}
- }}
- tryClickDelete(attempt + 1);
- }} else {{
- tryClickDelete(attempt + 1);
- }}
- }}, delays[attempt]);
- }}
-
- function confirmDelete(attempt = 0) {{
- if (attempt >= delays.length) return;
- setTimeout(() => {{
- const confirmBtn = document.querySelector(""button[data-testid='confirmationSheetConfirm']"");
- if (confirmBtn && confirmBtn.offsetParent !== null) {{
- confirmBtn.click();
- window.scrollBy(0, 3000);
- }} else {{
- confirmDelete(attempt + 1);
- }}
- }}, delays[attempt]);
- }}
-
- tryClickDelete();
- }}, {waitAfterDelete});
- }})();";
-
- await _webViewHostService.ExecuteScriptAsync(js);
+ return await RunDeleteScriptAsync(
+ url,
+ isArticle: true,
+ scriptName: "delete-all-reposts.js",
+ scriptDoneVar: "repostsDeletionDone",
+ deletedVar: "deletedReposts",
+ functionName: "DeleteAllRepost",
+ timeout.WaitBetweenRetryDeleteAttempts
+ );
}
- private async Task DeleteSingleLikeAsync()
- {
- var waitBeforeTryClickDelete = _userSettingsService.GetTimeoutSettings().WaitAfterDelete;
-
- var js = $@"
-(() => {{
- console.log('Attempting to find an unlike button...');
- const delay = ms => new Promise(res => setTimeout(res, ms));
- async function unlike() {{
- const unlikeButton = document.querySelector('button[data-testid=""unlike""]');
- if (!unlikeButton) {{
- console.log('No unlike button found.');
- return false;
- }}
- console.log('Found unlike button, clicking...');
- unlikeButton.click();
- // Wait a bit to allow the UI to update
- await delay({waitBeforeTryClickDelete});
-
- // Check if the button disappeared (like removed)
- const stillExists = document.querySelector('button[data-testid=""unlike""]');
- if (stillExists) {{
- console.log('Unlike button still present, retrying...');
- return false;
- }}
-
- console.log('Unlike successful! Scrolling down...');
- window.scrollBy(0, 3000);
- return true;
- }}
-
- // Retry logic: try up to 5 times with 2 sec delay between attempts
- async function tryUnlike(attempts = 5) {{
- for (let i = 1; i <= attempts; i++) {{
- const result = await unlike();
- if (result) {{
- console.log(`Unlike succeeded on attempt #${{i}}`);
- return true;
- }}
- console.log(`Attempt #${{i}} failed, waiting before retry...`);
- await delay(2000);
- }}
- console.log('Failed to unlike after max attempts.');
- return false;
- }}
+ public async Task DeleteRepliesAsync()
+ {
+ await EnsureUserNameAsync();
+ Guard.Against.Null(_userName);
- return tryUnlike();
-}})();";
+ var url = $"https://x.com/{WebUtility.UrlEncode(_userName)}/with_replies";
+ var timeout = _userSettingsService.GetTimeoutSettings();
- await _webViewHostService.ExecuteScriptAsync(js);
+ return await RunDeleteScriptAsync(
+ url,
+ isArticle: true,
+ scriptName: "delete-all-replies.js",
+ scriptDoneVar: "repliesDeletionDone",
+ deletedVar: "deletedReplies",
+ functionName: "DeleteAllReplies",
+ _userName, timeout.WaitAfterDelete, timeout.WaitBetweenRetryDeleteAttempts
+ );
}
-
- private async Task DeleteSingleFollowingAsync()
+ public async Task DeleteLikesAsync()
{
- var timeout = _userSettingsService.GetTimeoutSettings();
- var waitBeforeTryClickDelete = timeout.WaitAfterDelete;
- var waitBetweenTryClickDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
-
- var js = $@"
- (() => {{
- const unfollowingButton = document.querySelector('button[data-testid$=""-unfollow""]');
- if (!unfollowingButton) return;
-
- unfollowingButton.click();
-
- const delays = [
- {waitBetweenTryClickDeleteAttempts},
- {waitBetweenTryClickDeleteAttempts * 2},
- {waitBetweenTryClickDeleteAttempts * 3},
- {waitBetweenTryClickDeleteAttempts * 4},
- {waitBetweenTryClickDeleteAttempts * 5}
- ];
-
- function tryClickConfirm(attempt = 0) {{
- if (attempt >= delays.length) return;
- setTimeout(() => {{
- const confirmBtn = document.querySelector('button[data-testid=""confirmationSheetConfirm""]');
- if (confirmBtn && confirmBtn.offsetParent !== null) {{
- confirmBtn.click();
- window.scrollBy(0, 3000);
- }} else {{
- tryClickConfirm(attempt + 1);
- }}
- }}, delays[attempt]);
- }}
-
- setTimeout(() => {{
- tryClickConfirm();
- }}, {waitBeforeTryClickDelete});
- }})();";
-
- await _webViewHostService.ExecuteScriptAsync(js);
- }
-
+ await EnsureUserNameAsync();
+ Guard.Against.Null(_userName);
- private async Task GetPostsCountAsync()
- {
- const string js = """
- (() => document.querySelectorAll('div[data-testid="primaryColumn"] section button[data-testid="caret"]').length)()
- """;
+ var url = $"https://x.com/{WebUtility.UrlEncode(_userName)}/likes";
+ var timeout = _userSettingsService.GetTimeoutSettings();
- var result = await _webViewHostService.ExecuteScriptAsync(js);
- return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
+ return await RunDeleteScriptAsync(
+ url,
+ isArticle: false,
+ scriptName: "delete-all-likes.js",
+ scriptDoneVar: "likesDeletionDone",
+ deletedVar: "deletedLikes",
+ functionName: "DeleteAllLikes",
+ timeout.WaitBetweenRetryDeleteAttempts
+ );
}
-
- private async Task GetFollowingCountAsync()
+ public async Task DeleteFollowingAsync()
{
- const string js = """
- (() => document.querySelectorAll('button[data-testid$="-unfollow"]').length)()
- """;
-
- var result = await _webViewHostService.ExecuteScriptAsync(js);
- return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
- }
+ await EnsureUserNameAsync();
+ Guard.Against.Null(_userName);
- private async Task GetLikesCountAsync()
- {
- const string js = """
- (() => document.querySelectorAll('button[data-testid="unlike"]').length)()
- """;
+ var url = $"https://x.com/{WebUtility.UrlEncode(_userName)}/following";
+ var timeout = _userSettingsService.GetTimeoutSettings();
- var result = await _webViewHostService.ExecuteScriptAsync(js);
- return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
+ return await RunDeleteScriptAsync(
+ url,
+ isArticle: false,
+ scriptName: "delete-all-following.js",
+ scriptDoneVar: "followingDeletionDone",
+ deletedVar: "deletedFollowing",
+ functionName: "DeleteAllFollowing",
+ timeout.WaitAfterDelete, timeout.WaitBetweenRetryDeleteAttempts
+ );
}
- private async Task WaitForPostDeletedAsync(int beforeCount)
+ private async Task IsEmptyMessagePresentAsync()
{
- int elapsed = 0, interval = 200, timeout = 5000;
- while (elapsed < timeout)
- {
- if (await GetPostsCountAsync() < beforeCount)
- {
- return true;
- }
- await Task.Delay(interval);
- elapsed += interval;
- }
- return false;
- }
+ var script = @"
+ (function() {
+ return document.querySelector('[data-testid=""emptyState""]') !== null;
+ })();";
- private async Task WaitForFollowingDeletedAsync(int beforeCount)
- {
- int elapsed = 0, interval = 200, timeout = 5000;
- while (elapsed < timeout)
+ var result = await _webViewHostService.ExecuteScriptAsync(script) == "true";
+ if (result)
{
- if (await GetFollowingCountAsync() < beforeCount)
- {
- return true;
- }
- await Task.Delay(interval);
- elapsed += interval;
+ _logger.LogInformation("Empty state present, nothing more to delete.");
}
- return false;
+ return result;
}
- private async Task WaitForLikeDeletedAsync(int beforeCount)
+ private async Task IsAnArticlePresentAsync()
{
- int elapsed = 0, interval = 200, timeout = 5000;
- while (elapsed < timeout)
+ var script = @"
+ (function() {
+ return document.querySelector('article') !== null;
+ })();";
+
+ var isAnArticlePresent = await _webViewHostService.ExecuteScriptAsync(script) == "true";
+ if (!isAnArticlePresent)
{
- if (await GetLikesCountAsync() < beforeCount)
- {
- return true;
- }
- await Task.Delay(interval);
- elapsed += interval;
+ _logger.LogInformation("No article present, nothing more to delete.");
}
- return false;
+ return isAnArticlePresent;
}
public async Task GetUserNameAsync()
@@ -626,10 +259,116 @@ private async Task EnsureUserNameAsync()
return;
}
var newUserName = Helper.CleanJsonResult(userName);
- if (userName != newUserName)
+ if (_userName != newUserName)
{
- _logger.LogInformation("Username has changed to {Username}", userName);
+ _logger.LogInformation("Username has changed from {OldUserName} to {NewUserName}", _userName, userName);
_userName = newUserName;
}
}
+
+ private async Task NavigateAsync(Uri url)
+ {
+ bool urlChanged = false;
+ if (_webViewHostService.Source != url)
+ {
+ _webViewHostService.Source = url;
+ urlChanged = true;
+ }
+
+ var ready = await WaitForDocumentReadyAsync();
+ if (!ready)
+ {
+ _logger.LogWarning("Navigation to {Url} failed.", url);
+ return false;
+ }
+
+ if (urlChanged)
+ {
+ _logger.LogInformation("Navigated to {Url}", url);
+ }
+ return true;
+ }
+
+ private async Task RunDeleteScriptAsync(string url, bool isArticle, string scriptName, string scriptDoneVar, string deletedVar, string functionName, params object[] args)
+ {
+ var waitBetweenRetryDeleteAttempts = _userSettingsService.GetTimeoutSettings().WaitBetweenRetryDeleteAttempts;
+ if (!await NavigateAsync(new Uri(url)))
+ {
+ return 0;
+ }
+
+ var timeout = _userSettingsService.GetTimeoutSettings();
+ int retryCount = 0, deletedItems = 0;
+
+ while ((isArticle ? await IsAnArticlePresentAsync() : !await IsEmptyMessagePresentAsync()) && retryCount++ < 5)
+ {
+ try
+ {
+ string scriptPath = Path.Combine(AppContext.BaseDirectory, "Scripts", scriptName);
+ string jsCode = _fileService.ReadFile(scriptPath);
+
+ string argsList = string.Join(", ", args.Select(arg =>
+ {
+ if (arg == null)
+ {
+ return "null";
+ }
+ if (arg is string s)
+ {
+ return $"\"{s.Replace("\"", "\\\"")}\"";
+ }
+ if (arg is bool b)
+ {
+ return b ? "true" : "false";
+ }
+
+ return arg.ToString();
+ }));
+
+ string wrappedScript = $@"
+ (function() {{
+ window.{scriptDoneVar} = false;
+ {jsCode}
+ if (typeof {functionName} === 'function') {{
+ {functionName}({argsList});
+ }} else {{
+ console.log('{functionName} not defined');
+ window.{scriptDoneVar} = true;
+ }}
+ }})();";
+
+ await _webViewHostService.ExecuteScriptAsync(wrappedScript);
+
+ for (int i = 0; i < TimeSpan.FromHours(10).TotalSeconds; i++)
+ {
+ var result = await _webViewHostService.ExecuteScriptAsync($"window.{scriptDoneVar};");
+ if (string.Equals(result?.ToLower(), "true"))
+ {
+ var deletedResult = await _webViewHostService.ExecuteScriptAsync($"window.{deletedVar};");
+ if (int.TryParse(deletedResult, out var deleted))
+ {
+ if (deleted > 0)
+ {
+ retryCount = 0;
+ deletedItems += deleted;
+ }
+ }
+
+ break;
+ }
+ await Task.Delay(waitBetweenRetryDeleteAttempts);
+ }
+
+ _webViewHostService.Reload();
+ await WaitForDocumentReadyAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting via {Script}", scriptName);
+ }
+ }
+
+ _logger.LogInformation("Deleted items via {Script}", scriptName);
+ return deletedItems;
+ }
}
diff --git a/src/UI/UI.csproj b/src/UI/UI.csproj
index 99263ed..31027e3 100644
--- a/src/UI/UI.csproj
+++ b/src/UI/UI.csproj
@@ -10,7 +10,7 @@
Assets\logo.ico
CleanMyPosts
net9.0-windows
- 1.0.3
+ 2.0.0
false
latest
True
@@ -63,4 +63,23 @@
Resources.Designer.cs
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
diff --git a/src/UI/ViewModels/XViewModel.cs b/src/UI/ViewModels/XViewModel.cs
index 24edde9..b0957a9 100644
--- a/src/UI/ViewModels/XViewModel.cs
+++ b/src/UI/ViewModels/XViewModel.cs
@@ -64,26 +64,51 @@ public async Task InitializeAsync(WebView2 webView)
await _webViewHostService.InitializeAsync(webView);
_webViewHostService.Source = new Uri(_xBaseUrl);
-
- // logging JS errors and warnings to ILogger
- var jsScript = @"
- window.onerror = function(message, source, lineno, colno, error) {
- chrome.webview.postMessage(JSON.stringify({
- level: 'error',
- message: `JS Error: ${message} at ${source}:${lineno}:${colno}`
- }));
- };
- ";
-
- await _webViewHostService.ExecuteScriptAsync(jsScript);
-
- _isInitialized = true;
}
private async void OnNavigationCompleted(object sender, NavigationCompletedEventArgs e)
{
if (e.IsSuccess)
{
+ var jsLoggerPatch = @"
+ (() => {
+ const originalConsole = {
+ log: console.log,
+ warn: console.warn,
+ error: console.error,
+ };
+
+ function sendLog(level, args) {
+ chrome.webview.postMessage(JSON.stringify({
+ level,
+ message: args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')
+ }));
+ }
+
+ console.log = function (...args) {
+ sendLog('info', args);
+ originalConsole.log.apply(console, args);
+ };
+
+ console.warn = function (...args) {
+ sendLog('warn', args);
+ originalConsole.warn.apply(console, args);
+ };
+
+ console.error = function (...args) {
+ sendLog('error', args);
+ originalConsole.error.apply(console, args);
+ };
+
+ window.onerror = function (message, source, lineno, colno, error) {
+ sendLog('error', [`JS Error: ${message} at ${source}:${lineno}:${colno}`]);
+ };
+ })();
+ ";
+
+ await _webViewHostService.ExecuteScriptAsync(jsLoggerPatch);
+
+ _isInitialized = true;
if (!string.IsNullOrEmpty(_userName))
{
return;
@@ -256,14 +281,14 @@ private async Task DeleteLikes()
if (result == MessageDialogResult.Affirmative)
{
- var deletetCnt = await _xWebViewScriptService.DeleteLikesAsync();
- await ShowNotificationAsync($"{deletetCnt} like(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ var deletedItems = await _xWebViewScriptService.DeleteLikesAsync();
+ await ShowNotificationAsync($"{deletedItems} like(s) cleaned.", TimeSpan.FromSeconds(3));
}
}
else
{
- var deletetCnt = await _xWebViewScriptService.DeleteLikesAsync();
- await ShowNotificationAsync($"{deletetCnt} like(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ var deletedItems = await _xWebViewScriptService.DeleteLikesAsync();
+ await ShowNotificationAsync($"{deletedItems} like(s) cleaned.", TimeSpan.FromSeconds(3));
}
}
finally
@@ -321,6 +346,106 @@ private async Task DeleteFollowing()
}
}
+
+ [RelayCommand]
+ private async Task ShowReposts()
+ {
+ try
+ {
+ EnableUserInteractions(false);
+ await _xWebViewScriptService.ShowRepostsAsync();
+ }
+ finally
+ {
+ EnableUserInteractions(true);
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteReposts()
+ {
+ try
+ {
+ EnableUserInteractions(false, true, true);
+
+ if (_userSettingsService.GetConfirmDeletion())
+ {
+ _webViewHostService.Hide(true);
+ var result = await _dialogCoordinator.ShowMessageAsync(
+ this,
+ "Confirm Deletion",
+ "Are you sure you want to delete all reposts?",
+ MessageDialogStyle.AffirmativeAndNegative);
+ _webViewHostService.Hide(false);
+
+ if (result == MessageDialogResult.Affirmative)
+ {
+ var deletetCnt = await _xWebViewScriptService.DeleteRepostsAsync();
+ await ShowNotificationAsync($"{deletetCnt} repost(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ }
+ }
+ else
+ {
+ var deletetCnt = await _xWebViewScriptService.DeleteRepostsAsync();
+ await ShowNotificationAsync($"{deletetCnt} repost(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ }
+ }
+ finally
+ {
+ EnableUserInteractions(true);
+ }
+ }
+
+
+ [RelayCommand]
+ private async Task ShowReplies()
+ {
+ try
+ {
+ EnableUserInteractions(false);
+ await _xWebViewScriptService.ShowRepliesAsync();
+ }
+ finally
+ {
+ EnableUserInteractions(true);
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteReplies()
+ {
+ try
+ {
+ EnableUserInteractions(false, true, true);
+
+ if (_userSettingsService.GetConfirmDeletion())
+ {
+ _webViewHostService.Hide(true);
+ var result = await _dialogCoordinator.ShowMessageAsync(
+ this,
+ "Confirm Deletion",
+ "Are you sure you want to delete all replies?",
+ MessageDialogStyle.AffirmativeAndNegative);
+ _webViewHostService.Hide(false);
+
+ if (result == MessageDialogResult.Affirmative)
+ {
+ var deletetCnt = await _xWebViewScriptService.DeleteRepliesAsync();
+ await ShowNotificationAsync($"{deletetCnt} replie(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ }
+ }
+ else
+ {
+ var deletetCnt = await _xWebViewScriptService.DeleteFollowingAsync();
+ await ShowNotificationAsync($"{deletetCnt} replie(s) cleaned successfully.", TimeSpan.FromSeconds(3));
+ }
+ }
+ finally
+ {
+ EnableUserInteractions(true);
+ }
+ }
+
private void EnableUserInteractions(bool enableUserInteractions, bool useOverlay = true, bool showOverlayUpdateProgress = false)
{
if (enableUserInteractions)
diff --git a/src/UI/Views/LogPage.xaml b/src/UI/Views/LogPage.xaml
index 2ff9cf6..23b78ef 100644
--- a/src/UI/Views/LogPage.xaml
+++ b/src/UI/Views/LogPage.xaml
@@ -6,6 +6,7 @@
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:viewmodels="clr-namespace:CleanMyPosts.UI.ViewModels"
+ xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Style="{DynamicResource MahApps.Styles.Page}">
@@ -15,67 +16,38 @@
-
+
+
+
+
+
-
-
-
-
+
-
+
+
-
-
-
-
-
-
-
+
+
+
diff --git a/src/UI/Views/LogPage.xaml.cs b/src/UI/Views/LogPage.xaml.cs
index 10e8635..b2860af 100644
--- a/src/UI/Views/LogPage.xaml.cs
+++ b/src/UI/Views/LogPage.xaml.cs
@@ -1,97 +1,155 @@
-using System.Windows;
+using System.Collections.Specialized;
+using System.Text;
+using System.Windows;
using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Media;
using CleanMyPosts.UI.ViewModels;
+using ControlzEx.Theming;
namespace CleanMyPosts.UI.Views;
public partial class LogPage : Page
{
+ private readonly LogViewModel _viewModel;
+ private string _currentBackgroundColor = "#FFFAFA";
+ private string _currentTextColor = "black";
+ private string _currentBorderColor = "#EEE";
public LogPage(LogViewModel viewModel)
{
InitializeComponent();
- DataContext = viewModel;
+ _viewModel = viewModel;
+ DataContext = _viewModel;
+
+ Loaded += LogPage_Loaded;
+ _viewModel.LogEntries.CollectionChanged += LogEntries_CollectionChanged;
+ ThemeManager.Current.ThemeChanged += OnThemeChanged;
}
- private void TextBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ private void OnThemeChanged(object sender, ThemeChangedEventArgs e)
{
- if (sender is not TextBox tb)
+ if (LogWebView?.CoreWebView2 == null)
{
return;
}
- if (e.ClickCount == 2)
+ var theme = ThemeManager.Current.DetectTheme();
+ string backgroundColor, textColor, borderColor;
+
+ if (theme?.BaseColorScheme == "Dark")
{
- HandleDoubleClick(tb, e);
+ backgroundColor = "#1E1E1E";
+ textColor = "#E0E0E0";
+ borderColor = "#333";
}
- else if (e.ClickCount == 3)
+ else
{
- HandleTripleClick(tb, e);
+ backgroundColor = "#FFFAFA";
+ textColor = "black";
+ borderColor = "#EEE";
}
+
+ var js = $"setTheme('{backgroundColor}', '{textColor}', '{borderColor}');";
+ LogWebView.CoreWebView2.ExecuteScriptAsync(js);
}
- private static void HandleDoubleClick(TextBox tb, MouseButtonEventArgs e)
+
+ private async void LogPage_Loaded(object sender, RoutedEventArgs e)
{
- tb.Focus();
+ await LogWebView.EnsureCoreWebView2Async();
+ LogWebView.CoreWebView2.Settings.IsScriptEnabled = true;
+
+ // Define JS function for runtime use
+ var jsFunc = @"
+ function setTheme(backgroundColor, textColor, borderColor) {
+ document.body.style.backgroundColor = backgroundColor;
+ document.body.style.color = textColor;
+ const entries = document.querySelectorAll('.log-entry');
+ entries.forEach(entry => {
+ entry.style.borderBottom = `1px solid ${borderColor}`;
+ });
+ }";
+ await LogWebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(jsFunc);
- var pt = e.GetPosition(tb);
- var charIndex = tb.GetCharacterIndexFromPoint(pt, true);
- if (charIndex < 0)
+ // Detect theme
+ var theme = ThemeManager.Current.DetectTheme();
+ string backgroundColor, textColor, borderColor;
+
+ if (theme?.BaseColorScheme == "Dark")
{
- tb.SelectAll();
+ backgroundColor = "#1E1E1E";
+ textColor = "#E0E0E0";
+ borderColor = "#333";
}
else
{
- SelectWordAt(tb, charIndex);
+ backgroundColor = "#FFFAFA";
+ textColor = "black";
+ borderColor = "#EEE";
}
- e.Handled = true;
- }
-
- private static void SelectWordAt(TextBox tb, int charIndex)
- {
- var text = tb.Text;
- var start = charIndex;
- var end = charIndex;
- while (start > 0 && !char.IsWhiteSpace(text[start - 1]))
- {
- start--;
- }
+ _currentBackgroundColor = backgroundColor;
+ _currentTextColor = textColor;
+ _currentBorderColor = borderColor;
- while (end < text.Length && !char.IsWhiteSpace(text[end]))
- {
- end++;
- }
+ UpdateLogHtml();
- tb.Select(start, end - start);
+ var js = $"setTheme('{backgroundColor}', '{textColor}', '{borderColor}');";
+ _ = LogWebView.CoreWebView2.ExecuteScriptAsync(js);
}
- private static void HandleTripleClick(TextBox tb, MouseButtonEventArgs e)
+
+ private void LogEntries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
- tb.Focus();
- tb.SelectAll();
- e.Handled = true;
+ UpdateLogHtml();
+ }
- var listView = FindAncestor(tb);
- if (listView != null)
+ private void UpdateLogHtml()
+ {
+ if (LogWebView?.CoreWebView2 == null)
{
- listView.SelectedItem = tb.DataContext;
+ return;
}
+
+ string html = GenerateLogHtml();
+ LogWebView.NavigateToString(html);
}
- private static T FindAncestor(DependencyObject current) where T : DependencyObject
+ private string GenerateLogHtml()
{
- while (current != null)
- {
- if (current is T t)
- {
- return t;
- }
+ var html = $@"
+
+
+
+
+
+
+
+ ";
+
+ var sb = new StringBuilder();
+ sb.Append(html);
- current = VisualTreeHelper.GetParent(current);
+ foreach (var entry in _viewModel.LogEntries)
+ {
+ var safeEntry = System.Net.WebUtility.HtmlEncode(entry);
+ sb.AppendLine($"{safeEntry}
");
}
- return null;
+
+ sb.AppendLine("");
+ return sb.ToString();
}
+
}
diff --git a/src/UI/Views/SettingsPage.xaml b/src/UI/Views/SettingsPage.xaml
index 02dffdf..8d83d6b 100644
--- a/src/UI/Views/SettingsPage.xaml
+++ b/src/UI/Views/SettingsPage.xaml
@@ -7,9 +7,9 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
- xmlns:mui="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:CleanMyPosts.UI.Models"
+ xmlns:mui="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:properties="clr-namespace:CleanMyPosts.UI.Properties"
d:DesignHeight="450"
d:DesignWidth="800"
@@ -28,22 +28,22 @@
+ x:Name="Notification"
+ Width="300"
+ Height="50"
+ Margin="0"
+ Padding="10"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Top"
+ AnimateOpacity="True"
+ Background="{DynamicResource MahApps.Brushes.Control.Background}"
+ CloseButtonVisibility="Hidden"
+ Header="{Binding NotificationMessage}"
+ IsModal="False"
+ IsOpen="{Binding IsNotificationOpen}"
+ Opacity="0.9"
+ Position="Top"
+ Theme="Inverse" />
diff --git a/src/UI/Views/XPage.xaml b/src/UI/Views/XPage.xaml
index e70a4a7..7d42fd7 100644
--- a/src/UI/Views/XPage.xaml
+++ b/src/UI/Views/XPage.xaml
@@ -31,202 +31,301 @@
+
+
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+