From 3965749f9ee47e8e2bf49f25a7a5f0631212ee2f Mon Sep 17 00:00:00 2001 From: datboip <1479374+datboip@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:28:02 -0400 Subject: [PATCH 1/2] Keep the automation overlay off while logging in to refresh stats Refreshing the X stats while logged out loaded the X login page with the automation notice already on, which blocks clicks in the browser view, so there was no way to log in and the account was stuck on that screen. Start with the notice off; login() turns it on itself once login succeeds. Co-Authored-By: Claude Sonnet 5.5 --- src/renderer/src/view_models/XViewModel/auth.test.ts | 10 ++++++++-- src/renderer/src/view_models/XViewModel/auth.ts | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/view_models/XViewModel/auth.test.ts b/src/renderer/src/view_models/XViewModel/auth.test.ts index 16337185..d5f0743a 100644 --- a/src/renderer/src/view_models/XViewModel/auth.test.ts +++ b/src/renderer/src/view_models/XViewModel/auth.test.ts @@ -307,11 +307,17 @@ describe("auth.ts", () => { }); describe("loadUserStats", () => { - it("should show browser and automation notice", async () => { + it("should show the browser and keep the automation notice off while logging in", async () => { + // The notice blocks clicks, so it can't be on while the user may need to log in + let noticeDuringLogin: boolean | undefined; + mockVM.login = vi.fn().mockImplementation(async () => { + noticeDuringLogin = mockVM.showAutomationNotice; + }); + await Auth.loadUserStats(mockVM as XViewModel); expect(mockVM.showBrowser).toBe(true); - expect(mockVM.showAutomationNotice).toBe(true); + expect(noticeDuringLogin).toBe(false); }); it("should call login", async () => { diff --git a/src/renderer/src/view_models/XViewModel/auth.ts b/src/renderer/src/view_models/XViewModel/auth.ts index 74f70066..67818a8e 100644 --- a/src/renderer/src/view_models/XViewModel/auth.ts +++ b/src/renderer/src/view_models/XViewModel/auth.ts @@ -109,7 +109,9 @@ export async function login(vm: XViewModel): Promise { export async function loadUserStats(vm: XViewModel): Promise { vm.log("loadUserStats", "loading user stats"); vm.showBrowser = true; - vm.showAutomationNotice = true; + // login() shows the automation notice itself once login succeeds. Keep it off + // until then, otherwise someone who isn't logged in can't click the login form + vm.showAutomationNotice = false; vm.log("login", "getting user stats"); vm.instructions = vm.t("viewModels.x.auth.loadingStats"); From 51cf24a4955c407246b56101d6dd16eaaefa72e8 Mon Sep 17 00:00:00 2001 From: datboip <1479374+datboip@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:28:02 -0400 Subject: [PATCH 2/2] Add "only if they contain" filter to delete tweets and retweets Adds an optional filter under "Delete my tweets" and "Delete my retweets" so a delete can be limited to posts containing a word, `@user` or `#hashtag`. - Matching is case-insensitive and on whole words, so "coffee" finds "#coffee" but not "CoffeeBot" with an @ in front. A * stands for the rest of a word, so "coffee*" also finds "CoffeeBot". Anything else is taken literally. - SQLite's LIKE can't express word edges, so the match is a function registered on the account database. The Review count, the live preview on the options page and the delete itself all go through it. - A ticked filter with no text (or only stars) would match every post, so Continue stays disabled until it is filled in or unticked. - More than three * in a filter is rejected. Each one lets the pattern try every split of a word, and five of them against a long word did not finish. - Review lists the filter with the other conditions, and the Review count now reads the settings just saved from the options page. - The filter is marked as a Premium option, like the retweet age filter. That is a product decision and easy to change in xRequiresPremium. - Adds four nullable-safe columns to xAccount (additive migration). Co-Authored-By: Claude Sonnet 5.5 --- .../unit/containsFilter.unit.test.ts | 145 ++++++ .../unit/controller.deletion.unit.test.ts | 126 +++++ .../controller/deletion/containsFilter.ts | 112 +++++ .../deletion/deleteRetweetsStart.ts | 18 +- .../deletion/deleteTweetsCountNotArchived.ts | 101 ++-- .../controller/deletion/deleteTweetsStart.ts | 10 + .../controller/stats/getDeleteReviewStats.ts | 3 + src/account_x/x_account_controller.ts | 2 + src/database/migrations.ts | 10 + src/database/x_account.ts | 16 + src/renderer/src/i18n/locales/en.json | 12 + src/renderer/src/test_util.ts | 4 + src/renderer/src/util_x.test.ts | 6 + src/renderer/src/util_x.ts | 27 + .../view_models/XViewModel/view_model.test.ts | 4 + .../views/x/wizard/XWizardCheckPremium.vue | 30 ++ .../x/wizard/XWizardDeleteOptionsPage.test.ts | 247 +++++++++- .../x/wizard/XWizardDeleteOptionsPage.vue | 464 ++++++++++++++++-- .../src/views/x/wizard/XWizardReviewPage.vue | 29 +- src/shared_types/account.ts | 4 + 20 files changed, 1249 insertions(+), 121 deletions(-) create mode 100644 src/account_x/__tests__/unit/containsFilter.unit.test.ts create mode 100644 src/account_x/controller/deletion/containsFilter.ts diff --git a/src/account_x/__tests__/unit/containsFilter.unit.test.ts b/src/account_x/__tests__/unit/containsFilter.unit.test.ts new file mode 100644 index 00000000..9a7625ad --- /dev/null +++ b/src/account_x/__tests__/unit/containsFilter.unit.test.ts @@ -0,0 +1,145 @@ +import { describe, test, expect } from "vitest"; + +import { + containsFilterCondition, + containsFilterHasTooManyWildcards, + containsFilterMatches, + isEmptyContainsFilterText, +} from "../../controller/deletion/containsFilter"; + +describe("containsFilterMatches", () => { + test("matches a whole word regardless of case", () => { + expect(containsFilterMatches("Drinking coffee now", "Coffee")).toBe(true); + expect(containsFilterMatches("COFFEE", "coffee")).toBe(true); + }); + + test("matches the word inside a hashtag, mention, or punctuation", () => { + expect(containsFilterMatches("#Coffee - Fresh Cup", "Coffee")).toBe(true); + expect(containsFilterMatches("hey @Coffee!", "Coffee")).toBe(true); + expect(containsFilterMatches("(coffee)", "Coffee")).toBe(true); + expect(containsFilterMatches("#coffee\n\nnext line", "Coffee")).toBe(true); + }); + + test("does not match when the word is part of a longer word", () => { + expect(containsFilterMatches("@CoffeeBot hi", "Coffee")).toBe(false); + expect(containsFilterMatches("#CoffeeNow", "Coffee")).toBe(false); + expect(containsFilterMatches("uncoffee", "Coffee")).toBe(false); + }); + + test("a trailing star matches the rest of a word", () => { + const filter = "Coffee*"; + expect(containsFilterMatches("#Coffee", filter)).toBe(true); + expect(containsFilterMatches("@CoffeeBot", filter)).toBe(true); + expect(containsFilterMatches("Coffeebla", filter)).toBe(true); + expect(containsFilterMatches("coffeeTHIS", filter)).toBe(true); + expect(containsFilterMatches("cof fee", filter)).toBe(false); + }); + + test("a leading star matches the start of a word", () => { + expect(containsFilterMatches("the BestCoffee", "*Coffee")).toBe(true); + expect(containsFilterMatches("Coffee", "*Coffee")).toBe(true); + expect(containsFilterMatches("CoffeeBot", "*Coffee")).toBe(false); + }); + + test("a star in the middle matches letters, not spaces", () => { + expect(containsFilterMatches("Now-Playing now_bot", "now*bot")).toBe(true); + expect(containsFilterMatches("nowandbot", "now*bot")).toBe(true); + expect(containsFilterMatches("now and bot", "now*bot")).toBe(false); + }); + + test("a term that starts with # or @ needs no word edge before it", () => { + expect(containsFilterMatches("so#Coffee", "#Coffee")).toBe(true); + expect(containsFilterMatches("hey @someone", "@someone")).toBe(true); + expect(containsFilterMatches("hey @someoneelse", "@someone")).toBe(false); + }); + + test("takes everything else literally, including regex and SQL wildcards", () => { + expect(containsFilterMatches("it is 100% sure", "100%")).toBe(true); + expect(containsFilterMatches("it is 1000 sure", "100%")).toBe(false); + expect(containsFilterMatches("a_b", "a_b")).toBe(true); + expect(containsFilterMatches("axb", "a_b")).toBe(false); + expect(containsFilterMatches("cost $5 (approx)", "$5 (approx)")).toBe(true); + expect(containsFilterMatches("a.b", "a.b")).toBe(true); + expect(containsFilterMatches("axb", "a.b")).toBe(false); + expect(containsFilterMatches("back\\slash", "back\\slash")).toBe(true); + }); + + test("matches phrases with spaces", () => { + expect(containsFilterMatches("Hot Coffee: a cup", "hot coffee")).toBe(true); + expect(containsFilterMatches("Hot Coffee", "hot coffee")).toBe(false); + }); + + test("handles accented and non-latin words", () => { + expect(containsFilterMatches("Café del Mar", "café")).toBe(true); + expect(containsFilterMatches("Cafés", "café")).toBe(false); + expect(containsFilterMatches("音楽 を聴く", "音楽")).toBe(true); + }); + + test("ignores whitespace around the filter text", () => { + expect(containsFilterMatches("#Coffee", " Coffee ")).toBe(true); + }); + + test("never matches null text or an empty filter", () => { + expect(containsFilterMatches(null, "Coffee")).toBe(false); + expect(containsFilterMatches("anything", "")).toBe(false); + expect(containsFilterMatches("anything", " ")).toBe(false); + expect(containsFilterMatches("anything", "*")).toBe(false); + expect(containsFilterMatches("anything", " ** ")).toBe(false); + }); +}); + +describe("wildcard limit", () => { + test("stars in a row count as one", () => { + expect(containsFilterHasTooManyWildcards("a***b")).toBe(false); + expect(containsFilterMatches("axxb", "a***b")).toBe(true); + }); + + test("allows up to the limit and rejects more", () => { + expect(containsFilterHasTooManyWildcards("*a*b*c")).toBe(false); + expect(containsFilterHasTooManyWildcards("*a*b*c*")).toBe(true); + }); + + test("a filter over the limit matches nothing", () => { + expect(containsFilterMatches("abcde", "a*b*c*d*e")).toBe(false); + expect(containsFilterCondition("text", true, "a*b*c*d*e")).not.toBeNull(); + }); + + test("the worst case at the limit finishes quickly", () => { + const text = "a".repeat(300) + "c"; + const start = Date.now(); + expect(containsFilterMatches(text, "a*a*a*b")).toBe(false); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); + +describe("isEmptyContainsFilterText", () => { + test("is empty for blanks and for stars alone", () => { + expect(isEmptyContainsFilterText("")).toBe(true); + expect(isEmptyContainsFilterText(" ")).toBe(true); + expect(isEmptyContainsFilterText("*")).toBe(true); + expect(isEmptyContainsFilterText(" * * ")).toBe(true); + }); + + test("is not empty when there is something to look for", () => { + expect(isEmptyContainsFilterText("a")).toBe(false); + expect(isEmptyContainsFilterText("*a*")).toBe(false); + expect(isEmptyContainsFilterText("#")).toBe(false); + }); +}); + +describe("containsFilterCondition", () => { + test("returns null when disabled or empty", () => { + expect(containsFilterCondition("text", false, "Coffee")).toBeNull(); + expect(containsFilterCondition("text", true, "")).toBeNull(); + expect(containsFilterCondition("text", true, " ")).toBeNull(); + expect(containsFilterCondition("text", true, "*")).toBeNull(); + }); + + test("binds the trimmed filter text for the given column", () => { + const condition = containsFilterCondition("t.text", true, " Now* "); + + expect(condition).not.toBeNull(); + expect(condition!.sql).toContain("t.text"); + expect(condition!.param).toBe("Now*"); + }); +}); diff --git a/src/account_x/__tests__/unit/controller.deletion.unit.test.ts b/src/account_x/__tests__/unit/controller.deletion.unit.test.ts index a4a47c1a..4e749a1c 100644 --- a/src/account_x/__tests__/unit/controller.deletion.unit.test.ts +++ b/src/account_x/__tests__/unit/controller.deletion.unit.test.ts @@ -97,6 +97,94 @@ describe("XAccountController - Deletion", () => { expect(ids).toEqual(["eligible"]); }); + + test("only returns tweets containing the filter text when enabled", async () => { + controller.account!.deleteTweetsFilterEnabled = true; + controller.account!.deleteTweetsFilterText = "@Someone"; + + seedTweet(controller, { + tweetID: "mentions", + text: "hey @someone look at this", + }); + seedTweet(controller, { + tweetID: "reply", + text: "@SOMEONE no way", + }); + seedTweet(controller, { + tweetID: "unrelated", + text: "nothing to see here", + }); + seedTweet(controller, { + tweetID: "retweet-of-them", + text: "RT @someone: their post", + }); + + const result = await controller.deleteTweetsStart(); + const ids = result.tweets.map((tweet) => tweet.id).sort(); + + expect(ids).toEqual(["mentions", "reply"]); + }); + + test("matches whole words only, and a star matches the rest of a word", async () => { + seedTweet(controller, { + tweetID: "hashtag", + text: "#Coffee - Fresh Cup", + }); + seedTweet(controller, { tweetID: "bot", text: "@CoffeeBot hello" }); + seedTweet(controller, { tweetID: "glued", text: "coffeeTHIS" }); + seedTweet(controller, { tweetID: "other", text: "nothing here" }); + + controller.account!.deleteTweetsFilterEnabled = true; + controller.account!.deleteTweetsFilterText = "Coffee"; + const whole = await controller.deleteTweetsStart(); + expect(whole.tweets.map((tweet) => tweet.id)).toEqual(["hashtag"]); + + controller.account!.deleteTweetsFilterText = "Coffee*"; + const starred = await controller.deleteTweetsStart(); + expect(starred.tweets.map((tweet) => tweet.id).sort()).toEqual([ + "bot", + "glued", + "hashtag", + ]); + }); + + test("does not filter at all for stars alone", async () => { + seedTweet(controller, { tweetID: "a", text: "one" }); + seedTweet(controller, { tweetID: "b", text: "two" }); + + controller.account!.deleteTweetsFilterEnabled = true; + controller.account!.deleteTweetsFilterText = "*"; + const result = await controller.deleteTweetsStart(); + + // The page never lets this through, so it must at least not be treated + // as a filter that matches a particular post + expect(result.tweets.length).toBe(2); + }); + + test("treats LIKE wildcards in the filter text literally", async () => { + controller.account!.deleteTweetsFilterEnabled = true; + controller.account!.deleteTweetsFilterText = "100%"; + + seedTweet(controller, { tweetID: "literal", text: "I am 100% sure" }); + seedTweet(controller, { tweetID: "not-literal", text: "100 percent" }); + + const result = await controller.deleteTweetsStart(); + const ids = result.tweets.map((tweet) => tweet.id); + + expect(ids).toEqual(["literal"]); + }); + + test("ignores the filter text when the filter is disabled", async () => { + controller.account!.deleteTweetsFilterEnabled = false; + controller.account!.deleteTweetsFilterText = "@someone"; + + seedTweet(controller, { tweetID: "a", text: "@someone hi" }); + seedTweet(controller, { tweetID: "b", text: "unrelated" }); + + const result = await controller.deleteTweetsStart(); + + expect(result.tweets.length).toBe(2); + }); }); describe("deleteRetweetsStart", () => { @@ -137,6 +225,33 @@ describe("XAccountController - Deletion", () => { expect(ids).toEqual(["old-retweet"]); }); + + test("only returns retweets containing the filter text when enabled", async () => { + controller.account!.deleteRetweetsFilterEnabled = true; + controller.account!.deleteRetweetsFilterText = "@someone"; + + seedTweet(controller, { + tweetID: "retweet-of-them", + text: "RT @Someone: their post", + }); + seedTweet(controller, { + tweetID: "retweet-mentioning-them", + text: "RT @other: cc @someone", + }); + seedTweet(controller, { + tweetID: "other-retweet", + text: "RT @other: unrelated", + }); + seedTweet(controller, { + tweetID: "regular-tweet", + text: "@someone not a retweet", + }); + + const result = await controller.deleteRetweetsStart(); + const ids = result.tweets.map((tweet) => tweet.id).sort(); + + expect(ids).toEqual(["retweet-mentioning-them", "retweet-of-them"]); + }); }); describe("deleteLikesStart", () => { @@ -277,5 +392,16 @@ describe("XAccountController - Deletion", () => { expect(count).toBe(1); }); + + test("applies the contains filter when total=false but not when total=true", async () => { + controller.account!.deleteTweetsFilterEnabled = true; + controller.account!.deleteTweetsFilterText = "butter"; + + seedTweet(controller, { tweetID: "match", text: "butter is great" }); + seedTweet(controller, { tweetID: "no-match", text: "margarine is fine" }); + + expect(await controller.deleteTweetsCountNotArchived(false)).toBe(1); + expect(await controller.deleteTweetsCountNotArchived(true)).toBe(2); + }); }); }); diff --git a/src/account_x/controller/deletion/containsFilter.ts b/src/account_x/controller/deletion/containsFilter.ts new file mode 100644 index 00000000..85aac2b1 --- /dev/null +++ b/src/account_x/controller/deletion/containsFilter.ts @@ -0,0 +1,112 @@ +// The "only if they contain ..." delete filter. +// +// The match is case-insensitive and on whole words, so "Coffee" matches +// "#Coffee" and "hot coffee: Coffee!" but not "CoffeeBot". A "*" +// stands for the rest of a word, so "Coffee*" also matches "CoffeeBot" +// and "coffeeTHIS". Everything else the user types is taken literally. +// +// SQLite's LIKE can't express word boundaries, so the match is a function +// registered on the account database (see registerContainsFilterFunction), and +// the counts, the preview and the delete all go through it. + +import type Database from "better-sqlite3"; + +export const CONTAINS_FILTER_FUNCTION = "cyd_text_matches"; + +const WORD_CHAR = "[\\p{L}\\p{N}_]"; +const WORD_CHAR_RE = new RegExp(WORD_CHAR, "u"); + +// Each "*" lets the pattern try every split of a word, so a handful of them +// against a long word can take effectively forever. Runs of stars count as one, +// and a filter with more than this many is rejected (it matches nothing). +export const MAX_CONTAINS_FILTER_WILDCARDS = 3; + +function collapseWildcards(text: string): string { + return text.trim().replace(/\*+/g, "*"); +} + +export function containsFilterHasTooManyWildcards(text: string): boolean { + const stars = collapseWildcards(text).match(/\*/g); + return (stars?.length ?? 0) > MAX_CONTAINS_FILTER_WILDCARDS; +} + +// True when there is nothing to look for. Stars alone don't count, since "*" +// would match every post. +export function isEmptyContainsFilterText(text: string): boolean { + return text.replace(/\*/g, "").trim() === ""; +} + +export function buildContainsFilterRegExp(text: string): RegExp { + const term = collapseWildcards(text); + const body = term + .split("*") + .map((part) => part.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")) + .join(`${WORD_CHAR}*`); + + // Only anchor to a word edge on a side that starts or ends with a word + // character, so "#Coffee" and "@someone" don't need a letter before them + const first = term[0]; + const last = term[term.length - 1]; + const before = WORD_CHAR_RE.test(first) ? `(? { + if (typeof text !== "string" || typeof filterText !== "string") { + return 0; + } + if (lastFilterText !== filterText || lastRegExp === null) { + if ( + isEmptyContainsFilterText(filterText) || + containsFilterHasTooManyWildcards(filterText) + ) { + return 0; + } + lastRegExp = buildContainsFilterRegExp(filterText); + lastFilterText = filterText; + } + return lastRegExp.test(text) ? 1 : 0; + }, + ); +} + +// Returns an SQL condition for `column` and the parameter it binds, or null +// when the filter is disabled or empty (which means "no filter"). +export function containsFilterCondition( + column: string, + enabled: boolean, + text: string, +): { sql: string; param: string } | null { + if (!enabled || isEmptyContainsFilterText(text)) { + return null; + } + return { + sql: `${CONTAINS_FILTER_FUNCTION}(${column}, ?) = 1`, + param: text.trim(), + }; +} diff --git a/src/account_x/controller/deletion/deleteRetweetsStart.ts b/src/account_x/controller/deletion/deleteRetweetsStart.ts index 69a5b1af..b7540148 100644 --- a/src/account_x/controller/deletion/deleteRetweetsStart.ts +++ b/src/account_x/controller/deletion/deleteRetweetsStart.ts @@ -2,6 +2,7 @@ import log from "electron-log/main"; import { getTimestampDaysAgo } from "../../../util"; import type { XAccountController } from "../../x_account_controller"; import type { XDeleteTweetsStartResponse } from "../../../shared_types"; +import { containsFilterCondition } from "./containsFilter"; // When you start deleting retweets, return a list of tweets to delete export async function deleteRetweetsStart( @@ -21,10 +22,21 @@ export async function deleteRetweetsStart( ? getTimestampDaysAgo(controller.account.deleteRetweetsDaysOld) : getTimestampDaysAgo(0); - const tweets = controller.fetchTweetsWithMediaAndURLs( - "t.deletedRetweetAt IS NULL AND t.text LIKE ? AND t.createdAt <= ?", - ["RT @%", daysOldTimestamp], + let whereClause = + "t.deletedRetweetAt IS NULL AND t.text LIKE ? AND t.createdAt <= ?"; + const params: (string | number)[] = ["RT @%", daysOldTimestamp]; + + const containsFilter = containsFilterCondition( + "t.text", + controller.account.deleteRetweetsFilterEnabled, + controller.account.deleteRetweetsFilterText, ); + if (containsFilter) { + whereClause += ` AND ${containsFilter.sql}`; + params.push(containsFilter.param); + } + + const tweets = controller.fetchTweetsWithMediaAndURLs(whereClause, params); return { tweets }; } diff --git a/src/account_x/controller/deletion/deleteTweetsCountNotArchived.ts b/src/account_x/controller/deletion/deleteTweetsCountNotArchived.ts index 01fbfcaa..f374113f 100644 --- a/src/account_x/controller/deletion/deleteTweetsCountNotArchived.ts +++ b/src/account_x/controller/deletion/deleteTweetsCountNotArchived.ts @@ -2,6 +2,7 @@ import log from "electron-log/main"; import { exec, Sqlite3Count } from "../../../database"; import { getTimestampDaysAgo } from "../../../util"; import type { XAccountController } from "../../x_account_controller"; +import { containsFilterCondition } from "./containsFilter"; // Returns the count of tweets that are not archived // If total is true, return the total count of tweets not archived @@ -20,80 +21,44 @@ export async function deleteTweetsCountNotArchived( throw new Error("Account not found"); } - // Select just the tweets that need to be deleted based on the settings - let count: Sqlite3Count; + // Count all non-deleted, non-archived tweets, with no filters + let whereClause = + "archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ?"; + const params: (string | number)[] = ["RT @%", controller.account.username]; - if (total) { - // Count all non-deleted, non-archived tweets, with no filters - count = exec( - controller.db, - "SELECT COUNT(*) AS count FROM tweet WHERE archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ?", - ["RT @%", controller.account.username], - "get", - ) as Sqlite3Count; - } else { + // Select just the tweets that need to be deleted based on the settings + if (!total) { const daysOldTimestamp = controller.account.deleteTweetsDaysOldEnabled ? getTimestampDaysAgo(controller.account.deleteTweetsDaysOld) : getTimestampDaysAgo(0); - if ( - controller.account.deleteTweetsLikesThresholdEnabled && - controller.account.deleteTweetsRetweetsThresholdEnabled - ) { - // Both likes and retweets thresholds - count = exec( - controller.db, - "SELECT COUNT(*) AS count FROM tweet WHERE archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ? AND createdAt <= ? AND likeCount <= ? AND retweetCount <= ?", - [ - "RT @%", - controller.account.username, - daysOldTimestamp, - controller.account.deleteTweetsLikesThreshold, - controller.account.deleteTweetsRetweetsThreshold, - ], - "get", - ) as Sqlite3Count; - } else if ( - controller.account.deleteTweetsLikesThresholdEnabled && - !controller.account.deleteTweetsRetweetsThresholdEnabled - ) { - // Just likes threshold - count = exec( - controller.db, - "SELECT COUNT(*) AS count FROM tweet WHERE archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ? AND createdAt <= ? AND likeCount <= ?", - [ - "RT @%", - controller.account.username, - daysOldTimestamp, - controller.account.deleteTweetsLikesThreshold, - ], - "get", - ) as Sqlite3Count; - } else if ( - !controller.account.deleteTweetsLikesThresholdEnabled && - controller.account.deleteTweetsRetweetsThresholdEnabled - ) { - // Just retweets threshold - count = exec( - controller.db, - "SELECT COUNT(*) AS count FROM tweet WHERE archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ? AND createdAt <= ? AND retweetCount <= ?", - [ - "RT @%", - controller.account.username, - daysOldTimestamp, - controller.account.deleteTweetsRetweetsThreshold, - ], - "get", - ) as Sqlite3Count; - } else { - // Neither likes nor retweets threshold - count = exec( - controller.db, - "SELECT COUNT(*) AS count FROM tweet WHERE archivedAt IS NULL AND deletedTweetAt IS NULL AND text NOT LIKE ? AND username = ? AND createdAt <= ?", - ["RT @%", controller.account.username, daysOldTimestamp], - "get", - ) as Sqlite3Count; + whereClause += " AND createdAt <= ?"; + params.push(daysOldTimestamp); + + if (controller.account.deleteTweetsLikesThresholdEnabled) { + whereClause += " AND likeCount <= ?"; + params.push(controller.account.deleteTweetsLikesThreshold); + } + if (controller.account.deleteTweetsRetweetsThresholdEnabled) { + whereClause += " AND retweetCount <= ?"; + params.push(controller.account.deleteTweetsRetweetsThreshold); + } + const containsFilter = containsFilterCondition( + "text", + controller.account.deleteTweetsFilterEnabled, + controller.account.deleteTweetsFilterText, + ); + if (containsFilter) { + whereClause += ` AND ${containsFilter.sql}`; + params.push(containsFilter.param); } } + const count = exec( + controller.db, + `SELECT COUNT(*) AS count FROM tweet WHERE ${whereClause}`, + params, + "get", + ) as Sqlite3Count; + return count.count; } diff --git a/src/account_x/controller/deletion/deleteTweetsStart.ts b/src/account_x/controller/deletion/deleteTweetsStart.ts index 5e28983c..cb815168 100644 --- a/src/account_x/controller/deletion/deleteTweetsStart.ts +++ b/src/account_x/controller/deletion/deleteTweetsStart.ts @@ -2,6 +2,7 @@ import log from "electron-log/main"; import { getTimestampDaysAgo } from "../../../util"; import type { XAccountController } from "../../x_account_controller"; import type { XDeleteTweetsStartResponse } from "../../../shared_types"; +import { containsFilterCondition } from "./containsFilter"; export async function deleteTweetsStart( controller: XAccountController, @@ -42,6 +43,15 @@ export async function deleteTweetsStart( whereClause += " AND t.retweetCount <= ?"; params.push(controller.account.deleteTweetsRetweetsThreshold); } + const containsFilter = containsFilterCondition( + "t.text", + controller.account.deleteTweetsFilterEnabled, + controller.account.deleteTweetsFilterText, + ); + if (containsFilter) { + whereClause += ` AND ${containsFilter.sql}`; + params.push(containsFilter.param); + } // Fetch tweets using the helper function const tweets = controller.fetchTweetsWithMediaAndURLs(whereClause, params); diff --git a/src/account_x/controller/stats/getDeleteReviewStats.ts b/src/account_x/controller/stats/getDeleteReviewStats.ts index d4029252..6bb3f4f4 100644 --- a/src/account_x/controller/stats/getDeleteReviewStats.ts +++ b/src/account_x/controller/stats/getDeleteReviewStats.ts @@ -9,6 +9,9 @@ export async function getDeleteReviewStats( controller: XAccountController, ): Promise { const deleteReviewStats = emptyXDeleteReviewStats(); + // The delete settings may have just been saved from the options page, so + // count against what is in the database rather than the cached account + controller.refreshAccount(); if (!controller.account?.username) { log.info("XAccountController.getDeleteReviewStats: no account"); return deleteReviewStats; diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 609343d3..ac99f844 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -58,6 +58,7 @@ import * as Jobs from "./controller/jobs"; import * as Account from "./controller/account"; import { fetchTweetsWithMediaAndURLsFromDB } from "./controller/fetchTweetsWithMediaAndURLs"; import { migrations } from "./controller/migrations"; +import { registerContainsFilterFunction } from "./controller/deletion/containsFilter"; import { BlueskyService } from "./controller/bluesky/BlueskyService"; import { sweepLegacyOAuthCredentials } from "../credentials"; import { migrateAccountBlueskyOAuthCredentials } from "../bluesky_oauth"; @@ -195,6 +196,7 @@ export class XAccountController extends BaseAccountController { log.info(`XAccountController.initDB: dbPath=${dbPath}`); this.db = new Database(dbPath, {}); this.db.pragma("journal_mode = WAL"); + registerContainsFilterFunction(this.db); runMigrations(this.db, migrations); // Older versions stored the X-to-Bluesky OAuth state and session in this diff --git a/src/database/migrations.ts b/src/database/migrations.ts index 14766ced..4c320020 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -279,6 +279,16 @@ SELECT id, type, sortOrder, xAccountId, uuid, facebookAccountID FROM account;`, `ALTER TABLE blueskyLocalAccount ADD COLUMN connectedAt DATETIME DEFAULT NULL;`, ], }, + // Add the "only if they contain ..." delete filters for tweets and retweets + { + name: "add deleteTweetsFilter and deleteRetweetsFilter to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN deleteTweetsFilterEnabled BOOLEAN DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN deleteTweetsFilterText TEXT DEFAULT '';`, + `ALTER TABLE xAccount ADD COLUMN deleteRetweetsFilterEnabled BOOLEAN DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN deleteRetweetsFilterText TEXT DEFAULT '';`, + ], + }, ]; export const runMainMigrations = () => { diff --git a/src/database/x_account.ts b/src/database/x_account.ts index 4d8b32db..a1308d5a 100644 --- a/src/database/x_account.ts +++ b/src/database/x_account.ts @@ -31,6 +31,10 @@ interface XAccountRow { deleteRetweets: boolean; deleteRetweetsDaysOldEnabled: boolean; deleteRetweetsDaysOld: number; + deleteTweetsFilterEnabled: boolean; + deleteTweetsFilterText: string; + deleteRetweetsFilterEnabled: boolean; + deleteRetweetsFilterText: string; deleteLikes: boolean; deleteBookmarks: number; deleteDMs: boolean; @@ -81,6 +85,10 @@ function xAccountRowtoXAccount(row: XAccountRow): XAccount { deleteRetweets: !!row.deleteRetweets, deleteRetweetsDaysOldEnabled: !!row.deleteRetweetsDaysOldEnabled, deleteRetweetsDaysOld: row.deleteRetweetsDaysOld, + deleteTweetsFilterEnabled: !!row.deleteTweetsFilterEnabled, + deleteTweetsFilterText: row.deleteTweetsFilterText ?? "", + deleteRetweetsFilterEnabled: !!row.deleteRetweetsFilterEnabled, + deleteRetweetsFilterText: row.deleteRetweetsFilterText ?? "", deleteLikes: !!row.deleteLikes, deleteBookmarks: !!row.deleteBookmarks, deleteDMs: !!row.deleteDMs, @@ -176,6 +184,10 @@ export const saveXAccount = (account: XAccount) => { deleteRetweets = ?, deleteRetweetsDaysOldEnabled = ?, deleteRetweetsDaysOld = ?, + deleteTweetsFilterEnabled = ?, + deleteTweetsFilterText = ?, + deleteRetweetsFilterEnabled = ?, + deleteRetweetsFilterText = ?, deleteLikes = ?, deleteBookmarks = ?, deleteDMs = ?, @@ -220,6 +232,10 @@ export const saveXAccount = (account: XAccount) => { account.deleteRetweets ? 1 : 0, account.deleteRetweetsDaysOldEnabled ? 1 : 0, account.deleteRetweetsDaysOld, + account.deleteTweetsFilterEnabled ? 1 : 0, + account.deleteTweetsFilterText, + account.deleteRetweetsFilterEnabled ? 1 : 0, + account.deleteRetweetsFilterText, account.deleteLikes ? 1 : 0, account.deleteBookmarks ? 1 : 0, account.deleteDMs ? 1 : 0, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 27caa520..95b7377b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -339,6 +339,15 @@ "tweetsNotArchivedYet": "You have {strong} that haven't been archived yet", "orAtLeast": "or at least", "unlessTheyHaveAtLeast": "unless they have at least", + "onlyIfTheyContain": "only if they contain", + "containsFilterMissingText": "Type a word, {'@'}user or #hashtag to look for, or uncheck this box.", + "containsFilterTooManyWildcards": "That has too many * in it. Use {max} or fewer.", + "containsFilterWildcardHint": "Matches whole words only. Use * for the rest of a word, so Coffee* also finds CoffeeBot.", + "containsFilterPlaceholder": "{'@'}user, #hashtag or a word", + "containsFilterPreview": "{count} matches", + "containsFilterPreviewOne": "1 match", + "containsFilterPreviewNone": "nothing matches", + "containsFilterRateLimitNote": "X may pause the job for rate limits along the way; Cyd waits and continues on its own.", "likesDescription": "Likes are only visible to you on X. Cyd will delete all of the likes it can, but it can't delete ghost likes.", "bookmarksDescription": "Bookmarks are only visible to you on X.", "directMessagesWithdrawn": "X replaced direct messages with X Chat, and Cyd can't currently save or delete X Chat messages. Any direct messages Cyd already saved are still in your Cyd X archive, and you can still browse them.", @@ -390,6 +399,8 @@ "deleteTweetsUnlessRetweets": "Delete tweets unless they have at least a certain number of retweets", "deleteTweetsUnlessLikes": "Delete tweets unless they have at least a certain number of likes", "deleteRetweetsOlderThan": "Delete retweets older than a set number of days", + "deleteTweetsContaining": "Delete only tweets that contain certain text", + "deleteRetweetsContaining": "Delete only retweets that contain certain text", "unfollowEveryone": "Unfollow everyone", "deleteLikes": "Delete likes", "deleteBookmarks": "Delete bookmarks", @@ -520,6 +531,7 @@ "thatAreOlderThan": "that are older than {days} days", "unlessTheyHaveAtLeast": "unless they have at least {count} {type}", "unlessTheyHaveAtLeastOr": "unless they have at least {retweets} retweets or {likes} likes", + "thatContain": "that contain “{text}”", "haventSavedHTML": "You haven't saved HTML versions of any of these tweets.", "haventSavedHTMLSome": "You haven't saved HTML versions of {count} of these tweets.", "ifYouCare": "If you care,", diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index 1bb66020..ae23a261 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -55,6 +55,10 @@ export function createMockXAccount(overrides?: Partial): XAccount { deleteRetweets: false, deleteRetweetsDaysOldEnabled: false, deleteRetweetsDaysOld: 0, + deleteTweetsFilterEnabled: false, + deleteTweetsFilterText: "", + deleteRetweetsFilterEnabled: false, + deleteRetweetsFilterText: "", deleteLikes: false, deleteBookmarks: false, deleteDMs: false, diff --git a/src/renderer/src/util_x.test.ts b/src/renderer/src/util_x.test.ts index 380c4de0..aa1ceacd 100644 --- a/src/renderer/src/util_x.test.ts +++ b/src/renderer/src/util_x.test.ts @@ -53,6 +53,10 @@ function createXAccountFromDefaults(changes: object) { deleteRetweets: false, deleteRetweetsDaysOldEnabled: false, deleteRetweetsDaysOld: 0, + deleteTweetsFilterEnabled: false, + deleteTweetsFilterText: "", + deleteRetweetsFilterEnabled: false, + deleteRetweetsFilterText: "", deleteLikes: false, deleteBookmarks: false, unfollowEveryone: false, @@ -111,6 +115,8 @@ test("UtilX.xRequiresPremium() returns true when choosing any delete options", a "deleteTweetsLikesThresholdEnabled", "deleteTweetsRetweetsThresholdEnabled", "deleteRetweetsDaysOldEnabled", + "deleteTweetsFilterEnabled", + "deleteRetweetsFilterEnabled", "deleteLikes", "deleteBookmarks", "unfollowEveryone", diff --git a/src/renderer/src/util_x.ts b/src/renderer/src/util_x.ts index 4c2b220f..9826cebf 100644 --- a/src/renderer/src/util_x.ts +++ b/src/renderer/src/util_x.ts @@ -141,6 +141,11 @@ export async function xRequiresPremium( requiresPremium = true; } + if (xAccount.deleteTweets && xAccount.deleteTweetsFilterEnabled) { + console.log("Requires premium: deleteTweets and deleteTweetsFilterEnabled"); + requiresPremium = true; + } + // You can delete retweets for free, but only if you're not using these options if (xAccount.deleteRetweets && xAccount.deleteRetweetsDaysOldEnabled) { console.log( @@ -148,6 +153,12 @@ export async function xRequiresPremium( ); requiresPremium = true; } + if (xAccount.deleteRetweets && xAccount.deleteRetweetsFilterEnabled) { + console.log( + "Requires premium: deleteRetweets and deleteRetweetsFilterEnabled", + ); + requiresPremium = true; + } // Deleting likes, bookmarks, and unfollowing everyone are premium features if (xAccount.deleteLikes) { @@ -206,3 +217,19 @@ export async function xPostProgress( ); } } + +// True when an "only if they contain" filter has nothing to look for. Stars +// alone don't count, since "*" would match every post. Keep in step with +// isEmptyContainsFilterText in the main process. +export function isEmptyContainsFilterText(text: string): boolean { + return text.replace(/\*/g, "").trim() === ""; +} + +// More than this many "*" in a filter is rejected, since the match slows down +// badly with them. Keep in step with containsFilter.ts in the main process. +export const MAX_CONTAINS_FILTER_WILDCARDS = 3; + +export function containsFilterHasTooManyWildcards(text: string): boolean { + const stars = text.trim().replace(/\*+/g, "*").match(/\*/g); + return (stars?.length ?? 0) > MAX_CONTAINS_FILTER_WILDCARDS; +} diff --git a/src/renderer/src/view_models/XViewModel/view_model.test.ts b/src/renderer/src/view_models/XViewModel/view_model.test.ts index 70422335..b0ed1738 100644 --- a/src/renderer/src/view_models/XViewModel/view_model.test.ts +++ b/src/renderer/src/view_models/XViewModel/view_model.test.ts @@ -149,6 +149,10 @@ describe("XViewModel", () => { deleteRetweets: false, deleteRetweetsDaysOldEnabled: false, deleteRetweetsDaysOld: 0, + deleteTweetsFilterEnabled: false, + deleteTweetsFilterText: "", + deleteRetweetsFilterEnabled: false, + deleteRetweetsFilterText: "", deleteLikes: false, deleteBookmarks: false, deleteDMs: false, diff --git a/src/renderer/src/views/x/wizard/XWizardCheckPremium.vue b/src/renderer/src/views/x/wizard/XWizardCheckPremium.vue index c839f4a1..81461819 100644 --- a/src/renderer/src/views/x/wizard/XWizardCheckPremium.vue +++ b/src/renderer/src/views/x/wizard/XWizardCheckPremium.vue @@ -139,6 +139,36 @@ onMounted(async () => { +
  • +
    + + + {{ t("premium.deleteTweetsContaining") }} + +
    +
  • +
  • +
    + + + {{ t("premium.deleteRetweetsContaining") }} + +
    +
  • ({ setJobsType: vi.fn(), })); -vi.mock("../../../util_x", () => ({ - xHasSomeData: vi.fn().mockResolvedValue(true), -})); +vi.mock("../../../util_x", async () => { + const actual = + await vi.importActual("../../../util_x"); + return { + ...actual, + xHasSomeData: vi.fn().mockResolvedValue(true), + }; +}); // Mock child components vi.mock("../components/XLastImportOrBuildComponent.vue", () => ({ @@ -27,7 +32,7 @@ vi.mock("../components/XLastImportOrBuildComponent.vue", () => ({ vi.mock("../../shared_components/wizard/BaseWizardPage.vue", () => ({ default: { name: "BaseWizardPage", - template: "
    ", + template: '
    ', props: ["breadcrumbProps", "buttonProps"], }, })); @@ -43,6 +48,10 @@ describe("XWizardDeleteOptionsPage", () => { deleteTweets: false, deleteRetweets: false, deleteLikes: false, + deleteTweetsFilterEnabled: false, + deleteTweetsFilterText: "", + deleteRetweetsFilterEnabled: false, + deleteRetweetsFilterText: "", ...accountOverrides.xAccount, } as XAccount, }), @@ -118,6 +127,234 @@ describe("XWizardDeleteOptionsPage", () => { }); }); + describe("contains filter preview", () => { + const mountWithFilter = async (attachTo?: HTMLElement) => { + const mockModel = createMockModel({ + xAccount: { deleteTweets: true, deleteRetweets: true }, + }); + // The page loads its settings from the database, not from the model + window.electron.database.getAccount = vi + .fn() + .mockResolvedValue(mockModel.account); + wrapper = mount(XWizardDeleteOptionsPage, { + props: { model: mockModel as XViewModel }, + global: { plugins: [i18n] }, + attachTo, + }); + // Timers are faked in this block, so flush the mount's async work + // by advancing the fake clock rather than sleeping for real + await vi.advanceTimersByTimeAsync(50); + await wrapper.vm.$nextTick(); + // Open the "more options" for tweets + const showMore = wrapper + .findAll("button") + .find((btn) => btn.text().includes("Show more options")); + await showMore!.trigger("click"); + await wrapper.vm.$nextTick(); + }; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + window.electron.X.getDeleteReviewStats = vi.fn().mockResolvedValue({ + tweetsToDelete: 59, + retweetsToDelete: 3, + likesToDelete: 0, + bookmarksToDelete: 0, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows no preview until a filter term is typed", async () => { + await mountWithFilter(); + + expect(wrapper.find("#deleteTweetsFilterPreview").exists()).toBe(false); + expect(window.electron.X.getDeleteReviewStats).not.toHaveBeenCalled(); + }); + + it("saves the filter and shows the match count after typing", async () => { + await mountWithFilter(); + + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + const textInput = wrapper.find("#deleteTweetsFilterText"); + await textInput.setValue("@someone"); + await textInput.trigger("input"); + + // Debounced: nothing fetched yet + expect(window.electron.X.getDeleteReviewStats).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(500); + await wrapper.vm.$nextTick(); + + expect(window.electron.database.saveAccount).toHaveBeenCalled(); + const saved = JSON.parse( + (window.electron.database.saveAccount as ReturnType).mock + .calls[0][0], + ); + expect(saved.xAccount.deleteTweetsFilterEnabled).toBe(true); + expect(saved.xAccount.deleteTweetsFilterText).toBe("@someone"); + expect(window.electron.X.getDeleteReviewStats).toHaveBeenCalledTimes(1); + + const preview = wrapper.find("#deleteTweetsFilterPreview"); + expect(preview.exists()).toBe(true); + expect(preview.text()).toContain("59 match"); + }); + + const continueDisabled = () => + wrapper.findComponent({ name: "BaseWizardPage" }).props("buttonProps") + .nextButtons[0].disabled; + + it("loads a saved filter that is ticked but empty as unticked", async () => { + const mockModel = createMockModel({ + xAccount: { + deleteTweets: true, + deleteRetweets: true, + deleteTweetsFilterEnabled: true, + deleteTweetsFilterText: "", + deleteRetweetsFilterEnabled: true, + deleteRetweetsFilterText: " ", + }, + }); + window.electron.database.getAccount = vi + .fn() + .mockResolvedValue(mockModel.account); + wrapper = mount(XWizardDeleteOptionsPage, { + props: { model: mockModel as XViewModel }, + global: { plugins: [i18n] }, + }); + await vi.advanceTimersByTimeAsync(50); + await wrapper.vm.$nextTick(); + + // Both "more options" sections are closed, since no filter is active + const showMoreButtons = wrapper + .findAll("button") + .filter((btn) => btn.text().includes("Show more options")); + for (const btn of showMoreButtons) { + await btn.trigger("click"); + } + await wrapper.vm.$nextTick(); + + expect( + (wrapper.find("#deleteTweetsFilterEnabled").element as HTMLInputElement) + .checked, + ).toBe(false); + expect( + ( + wrapper.find("#deleteRetweetsFilterEnabled") + .element as HTMLInputElement + ).checked, + ).toBe(false); + expect(continueDisabled()).toBe(false); + }); + + it("blocks Continue while a ticked tweets filter has no text", async () => { + await mountWithFilter(); + expect(continueDisabled()).toBe(false); + + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + expect(continueDisabled()).toBe(true); + expect(wrapper.find("#deleteTweetsFilterMissing").exists()).toBe(true); + + await wrapper.find("#deleteTweetsFilterText").setValue(" "); + expect(continueDisabled()).toBe(true); + + await wrapper.find("#deleteTweetsFilterText").setValue("@someone"); + expect(continueDisabled()).toBe(false); + expect(wrapper.find("#deleteTweetsFilterMissing").exists()).toBe(false); + }); + + it("blocks Continue when a filter has too many wildcards", async () => { + await mountWithFilter(); + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + + await wrapper.find("#deleteTweetsFilterText").setValue("a*b*c*d*e"); + expect(continueDisabled()).toBe(true); + expect(wrapper.find("#deleteTweetsFilterTooManyWildcards").exists()).toBe( + true, + ); + expect(wrapper.find("#deleteTweetsFilterPreview").exists()).toBe(false); + + // Stars in a row count once + await wrapper.find("#deleteTweetsFilterText").setValue("a***b*c*d"); + expect(continueDisabled()).toBe(false); + expect(wrapper.find("#deleteTweetsFilterTooManyWildcards").exists()).toBe( + false, + ); + }); + + it("unticks the filter when its text is cleared", async () => { + await mountWithFilter(); + const checkbox = wrapper.find("#deleteTweetsFilterEnabled"); + const text = wrapper.find("#deleteTweetsFilterText"); + + await checkbox.setValue(true); + await text.setValue("@someone"); + expect((checkbox.element as HTMLInputElement).checked).toBe(true); + + await text.setValue(""); + expect((checkbox.element as HTMLInputElement).checked).toBe(false); + expect(continueDisabled()).toBe(false); + }); + + it("puts the cursor in the text box when the filter is ticked", async () => { + const root = document.createElement("div"); + document.body.appendChild(root); + try { + await mountWithFilter(root); + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + await wrapper.vm.$nextTick(); + + expect(document.activeElement?.id).toBe("deleteTweetsFilterText"); + } finally { + wrapper.unmount(); + root.remove(); + } + }); + + it("lets Continue through again when the empty filter is unticked", async () => { + await mountWithFilter(); + + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + expect(continueDisabled()).toBe(true); + + await wrapper.find("#deleteTweetsFilterEnabled").setValue(false); + expect(continueDisabled()).toBe(false); + }); + + it("blocks Continue while a ticked retweets filter has no text", async () => { + await mountWithFilter(); + const showMoreButtons = wrapper + .findAll("button") + .filter((btn) => btn.text().includes("Show more options")); + await showMoreButtons[0].trigger("click"); + await wrapper.vm.$nextTick(); + + await wrapper.find("#deleteRetweetsFilterEnabled").setValue(true); + expect(continueDisabled()).toBe(true); + expect(wrapper.find("#deleteRetweetsFilterMissing").exists()).toBe(true); + + await wrapper.find("#deleteRetweetsFilterText").setValue("#hashtag"); + expect(continueDisabled()).toBe(false); + }); + + it("only fetches once for a burst of keystrokes", async () => { + await mountWithFilter(); + + await wrapper.find("#deleteTweetsFilterEnabled").setValue(true); + const textInput = wrapper.find("#deleteTweetsFilterText"); + for (const value of ["@", "@s", "@so"]) { + await textInput.setValue(value); + await textInput.trigger("input"); + await vi.advanceTimersByTimeAsync(100); + } + await vi.advanceTimersByTimeAsync(500); + + expect(window.electron.X.getDeleteReviewStats).toHaveBeenCalledTimes(1); + }); + }); + describe("data loading", () => { it("should check for existing data on mount", async () => { const { xHasSomeData } = await import("../../../util_x"); diff --git a/src/renderer/src/views/x/wizard/XWizardDeleteOptionsPage.vue b/src/renderer/src/views/x/wizard/XWizardDeleteOptionsPage.vue index 3ceb41fb..caa1a70a 100644 --- a/src/renderer/src/views/x/wizard/XWizardDeleteOptionsPage.vue +++ b/src/renderer/src/views/x/wizard/XWizardDeleteOptionsPage.vue @@ -1,8 +1,13 @@