Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,15 +50,27 @@ Please keep in mind:
Here’s a quick look at how CleanMyPosts works:

<details>
<summary><strong>Clean posts</strong></summary>
<summary><strong>Delete posts</strong></summary>
<br/>
<img src="./assets/clean-posts.gif" alt="Clean Tweets GIF" width="700" />
<img src="./assets/delete-posts.gif" alt="Delete posts GIF" width="700" />
</details>

<details>
<summary><strong>Delete reposts</strong></summary>
<br/>
<img src="./assets/delete-reposts.gif" alt="Delete reposts GIF" width="700" />
</details>

<details>
<summary><strong>Delete replies</strong></summary>
<br/>
<img src="./assets/delete-replies.gif" alt="Delete replies GIF" width="700" />
</details>

<details>
<summary><strong>Clean likes</strong></summary>
<br/>
<img src="./assets/clean-likes.gif" alt="Clean Likes GIF" width="700" />
<img src="./assets/clean-likes.gif" alt="Delete Likes GIF" width="700" />
</details>

<details>
Expand All @@ -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.
Expand Down
File renamed without changes
File renamed without changes
File renamed without changes
5 changes: 5 additions & 0 deletions release-notes/v2.0.0.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/Core/Contracts/Services/IFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public interface IFileService
void Save<T>(string folderPath, string fileName, T content);

void Delete(string folderPath, string fileName);
string ReadFile(string filePath);
}
5 changes: 5 additions & 0 deletions src/Core/Services/FileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public T Read<T>(string folderPath, string fileName)
return default;
}

public string ReadFile(string filePath)
{
return File.ReadAllText(filePath);
}

public void Save<T>(string folderPath, string fileName, T content)
{
if (!Directory.Exists(folderPath))
Expand Down
145 changes: 101 additions & 44 deletions src/Tests/Services/XScriptServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CleanMyPosts.Core.Contracts.Services;
using CleanMyPosts.UI.Contracts.Services;
using CleanMyPosts.UI.Models;
using CleanMyPosts.UI.Services;
Expand All @@ -12,97 +13,153 @@ public class XScriptServiceTests
private readonly Mock<ILogger<XScriptService>> _loggerMock = new();
private readonly Mock<IWebViewHostService> _webViewHostServiceMock = new();
private readonly Mock<IUserSettingsService> _userSettingsServiceMock = new();
private readonly Mock<IFileService> _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<string>(s => s.Contains("AppTabBar_Profile_Link"))))
.ReturnsAsync("\"testuser\"");

// Setup dummy for Reload (do nothing)
_webViewHostServiceMock.Setup(x => x.Reload());

_fileServiceMock.Setup(x => x.Read<string>(It.IsAny<string>(), It.IsAny<string>()))
.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<string>())).ReturnsAsync("\"complete\"");
_webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny<EventHandler<NavigationCompletedEventArgs>>());
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<string>())).ReturnsAsync("\"complete\"");
_webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny<EventHandler<NavigationCompletedEventArgs>>());
var fakeJsResult = "\"\\\"testuser\\\"\""; // double quoted JSON string
_webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is<string>(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<bool>)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<string>())).ReturnsAsync("\"complete\"");
_webViewHostServiceMock.SetupAdd(x => x.NavigationCompleted += It.IsAny<EventHandler<NavigationCompletedEventArgs>>());
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<string>(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<string>()))
_webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is<string>(s => s.Contains("emptyState"))))
.ReturnsAsync("true");

var result = await (Task<bool>)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<bool>)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<string>()))
.ReturnsAsync("false")
.ReturnsAsync("false")
.ReturnsAsync("false")
.ReturnsAsync("false")
_webViewHostServiceMock.Setup(x => x.ExecuteScriptAsync(It.Is<string>(s => s.Contains("article"))))
.ReturnsAsync("false");

var result = await (Task<bool>)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<bool>)method.Invoke(_service, null);

Assert.False(result);
}

}
14 changes: 0 additions & 14 deletions src/Tests/ViewModels/XViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()), Times.Once);
}

[StaFact]
public async Task OnNavigationCompleted_UserLoggedIn_EnablesButtons()
{
Expand Down
4 changes: 4 additions & 0 deletions src/UI/Contracts/Services/IXScriptService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public interface IXScriptService
Task<int> DeleteFollowingAsync();

Task<string> GetUserNameAsync();
Task ShowRepostsAsync();
Task ShowRepliesAsync();
Task<int> DeleteRepliesAsync();
Task<int> DeleteRepostsAsync();
}
6 changes: 3 additions & 3 deletions src/UI/Models/TimeoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion src/UI/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/UI/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
<value>Settings</value>
</data>
<data name="SettingsPageAboutText" xml:space="preserve">
<value>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.</value>
<value>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.</value>
</data>
<data name="SettingsPageAboutTitle" xml:space="preserve">
<value>About</value>
Expand Down
Loading