diff --git a/README.md b/README.md index 2731104..6d3ec65 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ [![CI Tests](https://github.com/thorstenalpers/CleanMyPosts/actions/workflows/ci.yml/badge.svg)](https://github.com/thorstenalpers/CleanMyPosts/actions/workflows/ci.yml) [![Star this repo](https://img.shields.io/github/stars/thorstenalpers/CleanMyPosts.svg?style=social&label=Star&maxAge=60)](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
- Clean Tweets GIF + Delete posts GIF +
+ +
+ Delete reposts +
+ Delete reposts GIF +
+ +
+ Delete replies +
+ Delete replies GIF
Clean likes
- Clean Likes GIF + Delete Likes GIF
@@ -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 @@ - + + + + + - - - - + - + - - - + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +