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
41 changes: 40 additions & 1 deletion src/account_x.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
XRateLimitInfo, emptyXRateLimitInfo,
XIndexMessagesStartResponse,
XDeleteTweetsStartResponse,
XDeleteRetweetsStartResponse,
XProgressInfo, emptyXProgressInfo,
ResponseData,
// XTweet
Expand Down Expand Up @@ -1182,7 +1183,7 @@ export class XAccountController {
return true;
}

// When you start deleting tweets, retur a list of tweetIDs to delete
// When you start deleting tweets, return a list of tweetIDs to delete
async deleteTweetsStart(): Promise<XDeleteTweetsStartResponse> {
if (!this.db) {
this.initDB();
Expand Down Expand Up @@ -1248,6 +1249,35 @@ export class XAccountController {
return true;
}

// When you start deleting retweets, return a list of tweetIDs to delete
async deleteRetweetsStart(): Promise<XDeleteRetweetsStartResponse> {
if (!this.db) {
this.initDB();
}

if (!this.account) {
throw new Error("Account not found");
}

// Select just the retweets that need to be deleted based on the settings
const daysOldTimestamp = getTimestampDaysAgo(this.account.deleteRetweetsDaysOld);
const tweets: XTweetRow[] = exec(
this.db,
'SELECT id, tweetID, username FROM tweet WHERE deletedAt IS NULL AND isRetweeted = ? AND username != ? AND createdAt <= ? ORDER BY createdAt DESC',
[1, this.account.username, daysOldTimestamp],
"all"
) as XTweetRow[];

log.debug("XAccountController.deleteRetweetsStart", tweets);
return {
tweets: tweets.map((row) => ({
id: row.id,
username: row.username,
tweetID: row.tweetID
})),
};
}

async syncProgress(progressJSON: string) {
this.progress = JSON.parse(progressJSON);
}
Expand Down Expand Up @@ -1623,4 +1653,13 @@ export const defineIPCX = () => {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('X:deleteRetweetsStart', async (_, accountID: number): Promise<XDeleteRetweetsStartResponse> => {
try {
const controller = getXAccountController(accountID);
return await controller.deleteRetweetsStart();
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});
};
6 changes: 5 additions & 1 deletion src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
XArchiveStartResponse,
XIndexMessagesStartResponse,
XDeleteTweetsStartResponse,
XDeleteRetweetsStartResponse,
XRateLimitInfo,
XProgressInfo,
ResponseData
Expand Down Expand Up @@ -178,6 +179,9 @@ contextBridge.exposeInMainWorld('electron', {
},
deleteTweet: (accountID: number, tweetID: string): Promise<boolean> => {
return ipcRenderer.invoke('X:deleteTweet', accountID, tweetID);
}
},
deleteRetweetsStart: (accountID: number): Promise<XDeleteRetweetsStartResponse> => {
return ipcRenderer.invoke('X:deleteRetweetsStart', accountID);
},
}
})
6 changes: 6 additions & 0 deletions src/renderer/src/automation_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export enum AutomationErrorType {
x_runJob_deleteTweets_WaitForMenuFailed = "x_runJob_deleteTweets_WaitForMenuFailed",
x_runJob_deleteTweets_WaitForDeleteConfirmationFailed = "x_runJob_deleteTweets_WaitForDeleteConfirmationFailed",
x_runJob_deleteTweets_FailedToUpdateDeleteTimestamp = "x_runJob_deleteTweets_FailedToUpdateDeleteTimestamp",
x_runJob_deleteRetweets_FailedToStart = "x_runJob_deleteRetweets_FailedToStart",
x_runJob_deleteRetweets_WaitForMenuFailed = "x_runJob_deleteRetweets_WaitForMenuFailed",
x_runJob_deleteRetweets_FailedToUpdateDeleteTimestamp = "x_runJob_deleteRetweets_FailedToUpdateDeleteTimestamp",
x_runJob_UnknownError = "x_runJob_UnknownError",
x_runError = "x_runError",
x_unknownError = "x_unknown",
Expand Down Expand Up @@ -70,6 +73,9 @@ export const AutomationErrorTypeToMessage = {
[AutomationErrorType.x_runJob_deleteTweets_WaitForMenuFailed]: "Failed to wait for menu while deleting tweets",
[AutomationErrorType.x_runJob_deleteTweets_WaitForDeleteConfirmationFailed]: "Failed to wait for delete confirmation popup while deleting tweets",
[AutomationErrorType.x_runJob_deleteTweets_FailedToUpdateDeleteTimestamp]: "Failed to update delete timestamp while deleting tweets",
[AutomationErrorType.x_runJob_deleteRetweets_FailedToStart]: "Failed to start deleting retweets",
[AutomationErrorType.x_runJob_deleteRetweets_WaitForMenuFailed]: "Failed to wait for unretweet menu while deleting retweets",
[AutomationErrorType.x_runJob_deleteRetweets_FailedToUpdateDeleteTimestamp]: "Failed to update delete timestamp while deleting retweets",
[AutomationErrorType.x_runJob_UnknownError]: "An unknown error occured",
[AutomationErrorType.x_runError]: "Error while in X run function",
[AutomationErrorType.x_unknownError]: "An unknown error occured",
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/components/XJobStatusComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ const getJobTypeText = (jobType: string) => {
archiveTweets: 'Archiving tweets',
archiveBuild: 'Building archive',
deleteTweets: 'Deleting tweets',
deleteRetweets: 'Deleting retweets',
deleteLikes: 'Deleting likes',
deleteDMs: 'Deleting DMs'
};
return jobTypeTexts[jobType] || '';
return jobTypeTexts[jobType] || jobType;
};

const cycleRunningIcon = () => {
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/components/XProgressComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ onUnmounted(() => {
</div>
</template>

<!-- Delete Retweets -->
<template v-if="progress.currentJob == 'deleteRetweets'">
<p>
Deleted
<b>{{ progress.retweetsDeleted.toLocaleString() }} out of
{{ progress.totalRetweetsToDelete.toLocaleString() }} retweets</b>.
<template v-if="progress.isDeleteRetweetsFinished">
Finished deleting retweets!
</template>
</p>
<div class="d-flex align-items-center justify-content-between">
<div class="progress flex-grow-1 me-2">
<div class="progress-bar" role="progressbar"
:style="{ width: `${(progress.retweetsDeleted / progress.totalRetweetsToDelete) * 100}%` }"
:aria-valuenow="(progress.retweetsDeleted / progress.totalRetweetsToDelete) * 100"
aria-valuemin="0" aria-valuemax="100">
{{ Math.round((progress.retweetsDeleted / progress.totalRetweetsToDelete) * 100) }}%
</div>
</div>
</div>
</template>

<!-- Build archive -->
<template v-if="progress.currentJob == 'archiveBuild'">
<p>Building archive website</p>
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
XArchiveStartResponse,
XIndexMessagesStartResponse,
XDeleteTweetsStartResponse,
XDeleteRetweetsStartResponse,
XRateLimitInfo,
XProgressInfo,
ResponseData
Expand Down Expand Up @@ -80,6 +81,7 @@ declare global {
getLatestResponseData: (accountID: number) => Promise<ResponseData | null>;
deleteTweetsStart: (accountID: number) => Promise<XDeleteTweetsStartResponse>;
deleteTweet: (accountID: number, tweetID: string) => Promise<boolean>;
deleteRetweetsStart: (accountID: number) => Promise<XDeleteRetweetsStartResponse>;
};
};
}
Expand Down
92 changes: 88 additions & 4 deletions src/renderer/src/view_models/AccountXViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
XArchiveStartResponse, emptyXArchiveStartResponse,
XIndexMessagesStartResponse, emptyXIndexMessagesStartResponse,
XDeleteTweetsStartResponse, emptyXDeleteTweetsStartResponse,
XDeleteRetweetsStartResponse, emptyXDeleteRetweetsStartResponse,
XRateLimitInfo, emptyXRateLimitInfo,
XProgressInfo, emptyXProgressInfo
} from '../../../shared_types';
Expand Down Expand Up @@ -33,6 +34,7 @@ export class AccountXViewModel extends BaseViewModel {
private archiveStartResponse: XArchiveStartResponse = emptyXArchiveStartResponse();
private indexMessagesStartResponse: XIndexMessagesStartResponse = emptyXIndexMessagesStartResponse();
private deleteTweetsStartResponse: XDeleteTweetsStartResponse = emptyXDeleteTweetsStartResponse();
private deleteRetweetsStartResponse: XDeleteRetweetsStartResponse = emptyXDeleteRetweetsStartResponse();

async init() {
if (this.account && this.account.xAccount && this.account.xAccount.username) {
Expand Down Expand Up @@ -176,7 +178,7 @@ export class AccountXViewModel extends BaseViewModel {
if (this.account.xAccount?.deleteDMs) {
jobTypes.push("indexConversations");
jobTypes.push("indexMessages");
jobTypes.push("deleteMessages");
jobTypes.push("deleteDMs");
}
jobTypes.push("archiveBuild");

Expand Down Expand Up @@ -424,6 +426,9 @@ export class AccountXViewModel extends BaseViewModel {
async runJob(iJob: number) {
await this.waitForPause();

// Variables for use in the switch statement
let alreadyDeleted = false;

// Start the job
this.jobs[iJob].startedAt = new Date();
this.jobs[iJob].status = "running";
Expand Down Expand Up @@ -1040,7 +1045,7 @@ Hang on while I scroll down to your earliest likes that I've seen.
this.instructions = `
**${this.actionString}**

I'm deleting your tweets, based on your criteria.
I'm deleting your tweets based on your criteria, starting with the earliest.
`;
this.showAutomationNotice = true;

Expand Down Expand Up @@ -1131,7 +1136,86 @@ I'm deleting your tweets, based on your criteria.
break;

case "deleteRetweets":
this.log("runJob", "deleteRetweets: NOT IMPLEMENTED");
this.showBrowser = true;
this.instructions = `
**${this.actionString}**

I'm deleting your retweets, starting with the earliest.
`;
this.showAutomationNotice = true;

// Load the retweets to delete
try {
this.deleteRetweetsStartResponse = await window.electron.X.deleteRetweetsStart(this.account.id);
} catch (e) {
await this.error(AutomationErrorType.x_runJob_deleteTweets_FailedToStart, {
exception: (e as Error).toString()
})
break;
}
this.log('runJob', ["jobType=deleteRetweets", "deleteReteetsStartResponse", this.deleteRetweetsStartResponse]);

// Start the progress
this.progress.totalRetweetsToDelete = this.deleteRetweetsStartResponse.tweets.length;
this.progress.retweetsDeleted = 0;
await this.syncProgress();

for (let i = 0; i < this.deleteRetweetsStartResponse.tweets.length; i++) {
alreadyDeleted = false;

// Load the URL
await this.loadURLWithRateLimit(`https://x.com/${this.deleteRetweetsStartResponse.tweets[i].username}/status/${this.deleteRetweetsStartResponse.tweets[i].tweetID}`);
await this.sleep(200);

await this.waitForPause();

// Wait for the retweet menu button to appear
try {
await this.waitForSelector('article:has(+ div[data-testid="inline_reply_offscreen"]) button[data-testid="unretweet"]');
} catch (e) {
// If it doesn't appear, let's assume this retweet was already deleted
alreadyDeleted = true;
}
await this.sleep(200);

if (!alreadyDeleted) {
// Click the retweet menu button
await this.scriptClickElement('article:has(+ div[data-testid="inline_reply_offscreen"]) button[data-testid="unretweet"]');

// Wait for the unretweet menu to appear
try {
await this.waitForSelector('div[role="menu"] div[role="menuitem"]:first-of-type');
} catch (e) {
await this.error(AutomationErrorType.x_runJob_deleteRetweets_WaitForMenuFailed, {
exception: (e as Error).toString()
});
break;
}
await this.sleep(200);

// Click the delete button
await this.scriptClickElement('div[role="menu"] div[role="menuitem"]:first-of-type');
await this.sleep(200);
}

// Mark the tweet as deleted
try {
// Deleting retweets uses the same deleteTweet IPC function as deleting tweets
await window.electron.X.deleteTweet(this.account.id, this.deleteRetweetsStartResponse.tweets[i].tweetID);
} catch (e) {
await this.error(AutomationErrorType.x_runJob_deleteRetweets_FailedToUpdateDeleteTimestamp, {
exception: (e as Error).toString()
}, {
deleteRetweetsStartResponseTweet: this.deleteRetweetsStartResponse.tweets[i],
index: i
})
break;
}

this.progress.retweetsDeleted += 1;
await this.syncProgress();
}

await this.finishJob(iJob);
break;

Expand All @@ -1140,7 +1224,7 @@ I'm deleting your tweets, based on your criteria.
await this.finishJob(iJob);
break;

case "deleteMessages":
case "deleteDMs":
this.log("runJob", "deleteMessages: NOT IMPLEMENTED");
await this.finishJob(iJob);
break;
Expand Down
14 changes: 14 additions & 0 deletions src/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,18 @@ export function emptyXDeleteTweetsStartResponse(): XDeleteTweetsStartResponse {
return {
tweets: []
}
}

export type XDeleteRetweetsStartResponse = {
tweets: {
id: number;
username: string;
tweetID: string;
}[];
}

export function emptyXDeleteRetweetsStartResponse(): XDeleteRetweetsStartResponse {
return {
tweets: []
}
}