From ce4391e6b9b293198bfbc141a6923ae65f02b67f Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 19 Jan 2025 10:45:34 -0800 Subject: [PATCH 01/50] For XWizardImportOrBuildPage, use option cards instead of radio buttons for choosing a build database strategy --- src/renderer/src/App.vue | 8 + .../src/views/x/XWizardImportOrBuildPage.vue | 178 +++++++++--------- 2 files changed, 92 insertions(+), 94 deletions(-) diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index a4860fbd..84da13d1 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -377,6 +377,14 @@ body { padding-left: 1.5rem; } +.option-card { + cursor: pointer; +} + +.option-card.selected { + background-color: #f0f0f0; +} + /* Run Jobs styles */ .run-jobs-state { diff --git a/src/renderer/src/views/x/XWizardImportOrBuildPage.vue b/src/renderer/src/views/x/XWizardImportOrBuildPage.vue index 9244fba5..bf222129 100644 --- a/src/renderer/src/views/x/XWizardImportOrBuildPage.vue +++ b/src/renderer/src/views/x/XWizardImportOrBuildPage.vue @@ -59,6 +59,7 @@ onMounted(() => { recommendedState.value = RecommendedState.BuildFromScratch; } } + if (recommendedState.value == RecommendedState.ImportArchive || recommendedState.value == RecommendedState.Unknown) { buildDatabaseStrategy.value = 'importArchive'; } else { @@ -81,74 +82,68 @@ onMounted(() => { :button-text-no-data="'Skip to Delete Options'" :button-state="State.WizardDeleteOptions" @set-state="emit('setState', $event)" /> -
- - - - - -
diff --git a/src/shared_types/bluesky_migration.ts b/src/shared_types/bluesky_migration.ts new file mode 100644 index 00000000..1a28c146 --- /dev/null +++ b/src/shared_types/bluesky_migration.ts @@ -0,0 +1,6 @@ +export type BlueskyMigrationProfile = { + did: string; + handle: string; + displayName?: string; + avatar?: string; +} \ No newline at end of file diff --git a/src/shared_types/index.ts b/src/shared_types/index.ts index d35a1abb..cdc06e7a 100644 --- a/src/shared_types/index.ts +++ b/src/shared_types/index.ts @@ -1,3 +1,4 @@ export * from './common'; export * from './account'; -export * from './x'; \ No newline at end of file +export * from './x'; +export * from './bluesky_migration'; \ No newline at end of file From d89f4ee643f8a456efecfde813d9bc502a94bd09 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 4 Feb 2025 14:19:17 -0800 Subject: [PATCH 16/50] Pass all querystrings from the oauth callback to the oauthCallback function, not pre-defined ones. And handle errors --- src/account_x/ipc.ts | 4 +-- src/account_x/x_account_controller.ts | 18 ++++++---- src/main.ts | 7 +--- src/preload.ts | 4 +-- src/renderer/src/main.ts | 2 +- src/renderer/src/views/x/XWizardMigrate.vue | 39 +++++++++++++-------- 6 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts index 1aeeb9c9..9608549e 100644 --- a/src/account_x/ipc.ts +++ b/src/account_x/ipc.ts @@ -447,10 +447,10 @@ export const defineIPCX = () => { } }); - ipcMain.handle('X:blueskyCallback', async (_, accountID: number, paramsState: string, paramsIss: string, paramsCode: string): Promise => { + ipcMain.handle('X:blueskyCallback', async (_, accountID: number, queryString: string): Promise => { try { const controller = getXAccountController(accountID); - return await controller.blueskyCallback(paramsState, paramsIss, paramsCode); + return await controller.blueskyCallback(queryString); } catch (error) { throw new Error(packageExceptionForReport(error as Error)); } diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 2db5cd60..cb09759c 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -2110,19 +2110,25 @@ export class XAccountController { } } - async blueskyCallback(paramsState: string, paramsIss: string, paramsCode: string): Promise { + async blueskyCallback(queryString: string): Promise { // Initialize the Bluesky client if (!this.blueskyClient) { this.blueskyClient = await this.blueskyInitClient(); } - const params = new URLSearchParams(); - params.append("state", paramsState); - params.append("iss", paramsIss); - params.append("code", paramsCode); - + const params = new URLSearchParams(queryString); const { session, state } = await this.blueskyClient.callback(params); + // Handle errors + const error = params.get("error"); + const errorDescription = params.get("error_description"); + if (errorDescription) { + return errorDescription; + } + if (error) { + return `The authorization failed with error: ${error}`; + } + log.info("XAccountController.blueskyCallback: authorize() was called with state", state); log.info("XAccountController.blueskyCallback: user authenticated as", session.did); diff --git a/src/main.ts b/src/main.ts index 78d951e0..bb44f644 100644 --- a/src/main.ts +++ b/src/main.ts @@ -103,11 +103,6 @@ const openCydURL = async (cydURL: string) => { // If hostname is "bluesky-oauth", this means finish the Bluesky OAuth flow if (url.hostname == "bluesky-oauth") { - const params = new URLSearchParams(url.search); - const state = params.get('state'); - const iss = params.get('iss'); - const code = params.get('code'); - // Get the account ID that's in the middle of the OAuth flow const accountID = database.getConfig('blueskyOAuthAccountID'); const blueskyOAuthCallbackEventName = `blueskyOAuthCallback-${accountID}`; @@ -117,7 +112,7 @@ const openCydURL = async (cydURL: string) => { // Send the event to the renderer if (win) { - win.webContents.send(blueskyOAuthCallbackEventName, state, iss, code); + win.webContents.send(blueskyOAuthCallbackEventName, url.search); } return; } diff --git a/src/preload.ts b/src/preload.ts index 38d6ee1c..a0b740fa 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -288,8 +288,8 @@ contextBridge.exposeInMainWorld('electron', { blueskyAuthorize: (accountID: number, handle: string): Promise => { return ipcRenderer.invoke('X:blueskyAuthorize', accountID, handle); }, - blueskyCallback: (accountID: number, paramsState: string, paramsIss: string, paramsCode: string): Promise => { - return ipcRenderer.invoke('X:blueskyCallback', accountID, paramsState, paramsIss, paramsCode) + blueskyCallback: (accountID: number, queryString: string): Promise => { + return ipcRenderer.invoke('X:blueskyCallback', accountID, queryString) }, }, diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index b8a98eb4..2d47b84a 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -124,7 +124,7 @@ declare global { setConfig: (accountID: number, key: string, value: string) => void; blueskyGetProfile: (accountID: number) => Promise; blueskyAuthorize: (accountID: number, handle: string) => Promise; - blueskyCallback: (accountID: number, paramsState: string, paramsIss: string, paramsCode: string) => Promise; + blueskyCallback: (accountID: number, queryString: string) => Promise; }; onPowerMonitorSuspend: (callback: () => void) => void; onPowerMonitorResume: (callback: () => void) => void; diff --git a/src/renderer/src/views/x/XWizardMigrate.vue b/src/renderer/src/views/x/XWizardMigrate.vue index 055e9e97..4468efd1 100644 --- a/src/renderer/src/views/x/XWizardMigrate.vue +++ b/src/renderer/src/views/x/XWizardMigrate.vue @@ -31,24 +31,35 @@ const connectClicked = async () => { connectButtonText.value = 'Connecting...'; state.value = State.Connecting; - const ret: boolean | string = await window.electron.X.blueskyAuthorize(props.model.account.id, blueskyHandle.value); - if (ret !== true) { - await window.electron.showMessage('Failed to connect to Bluesky: ' + ret); + try { + const ret: boolean | string = await window.electron.X.blueskyAuthorize(props.model.account.id, blueskyHandle.value); + if (ret !== true) { + await window.electron.showMessage('Failed to connect to Bluesky: ' + ret); + connectButtonText.value = 'Connect'; + state.value = State.NotConnected; + } else { + connectButtonText.value = 'Connect'; + state.value = State.FinishInBrowser; + } + } catch (e) { + await window.electron.showMessage('Failed to connect to Bluesky: ' + e); connectButtonText.value = 'Connect'; state.value = State.NotConnected; - } else { - connectButtonText.value = 'Connect'; - state.value = State.FinishInBrowser; } } -const oauthCallback = async (paramsState: string, paramsIss: string, paramsCode: string) => { - const ret: boolean | string = await window.electron.X.blueskyCallback(props.model.account.id, paramsState, paramsIss, paramsCode); - if (ret !== true) { - await window.electron.showMessage('Failed to connect to Bluesky: ' + ret); +const oauthCallback = async (queryString: string) => { + try { + const ret: boolean | string = await window.electron.X.blueskyCallback(props.model.account.id, queryString); + if (ret !== true) { + await window.electron.showMessage('Failed to connect to Bluesky: ' + ret); + state.value = State.NotConnected; + } else { + state.value = State.Connected; + } + } catch (e) { + await window.electron.showMessage('Failed to connect to Bluesky: ' + e); state.value = State.NotConnected; - } else { - state.value = State.Connected; } } @@ -69,8 +80,8 @@ onMounted(async () => { } // Listen for OAuth callback event - window.electron.ipcRenderer.on(blueskyOAuthCallbackEventName, async (_event: IpcRendererEvent, state: string, iss: string, code: string) => { - await oauthCallback(state, iss, code); + window.electron.ipcRenderer.on(blueskyOAuthCallbackEventName, async (_event: IpcRendererEvent, queryString: string) => { + await oauthCallback(queryString); }); }); From 68bf09ad2021d405bf242fc376e3a676fa13d3b4 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 4 Feb 2025 14:22:10 -0800 Subject: [PATCH 17/50] Handle errors before trying to complete the callback --- src/account_x/x_account_controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index cb09759c..7eee50cb 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -2117,7 +2117,6 @@ export class XAccountController { } const params = new URLSearchParams(queryString); - const { session, state } = await this.blueskyClient.callback(params); // Handle errors const error = params.get("error"); @@ -2129,6 +2128,9 @@ export class XAccountController { return `The authorization failed with error: ${error}`; } + // Finish the callback + const { session, state } = await this.blueskyClient.callback(params); + log.info("XAccountController.blueskyCallback: authorize() was called with state", state); log.info("XAccountController.blueskyCallback: user authenticated as", session.did); From 53a1b5ec9b800de94625cc48634a4215645fb5ba Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 4 Feb 2025 15:04:23 -0800 Subject: [PATCH 18/50] Support deleting configs, including with LIKE, and add support for disconnecting an atproto session --- src/account_x/ipc.ts | 27 +++++++++++++++ src/account_x/x_account_controller.ts | 37 +++++++++++++++++++-- src/database/config.ts | 30 +++++++++++++++++ src/main.ts | 2 +- src/preload.ts | 15 +++++++++ src/renderer/src/main.ts | 5 +++ src/renderer/src/views/x/XWizardMigrate.vue | 9 +++-- 7 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts index 9608549e..31e30b80 100644 --- a/src/account_x/ipc.ts +++ b/src/account_x/ipc.ts @@ -429,6 +429,24 @@ export const defineIPCX = () => { } }); + ipcMain.handle('X:deleteConfig', async (_, accountID: number, key: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteConfig(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteConfigLike', async (_, accountID: number, key: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteConfigLike(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + ipcMain.handle('X:blueskyGetProfile', async (_, accountID: number): Promise => { try { const controller = getXAccountController(accountID); @@ -455,4 +473,13 @@ export const defineIPCX = () => { throw new Error(packageExceptionForReport(error as Error)); } }); + + ipcMain.handle('X:blueskyDisconnect', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.blueskyDisconnect(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); }; diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 7eee50cb..0f2a28cb 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -43,6 +43,8 @@ import { Sqlite3Count, getConfig as globalGetConfig, setConfig as globalSetConfig, + deleteConfig as globalDeleteConfig, + deleteConfigLike as globalDeleteConfigLike, } from '../database' import { IMITMController } from '../mitm'; import { @@ -2013,6 +2015,14 @@ export class XAccountController { return globalSetConfig(key, value, this.db); } + async deleteConfig(key: string) { + return globalDeleteConfig(key, this.db); + } + + async deleteConfigLike(key: string) { + return globalDeleteConfigLike(key, this.db); + } + async blueskyInitClient(): Promise { // Bluesky client, for migrating let host; @@ -2088,9 +2098,6 @@ export class XAccountController { // Authorize the handle const url = await this.blueskyClient.authorize(handle); - // Save the handle - await this.setConfig("blueskyHandle", handle); - // Save the account ID in the global config const accountID = this.account?.id == null ? "" : this.account.id.toString(); await globalSetConfig("blueskyOAuthAccountID", accountID); @@ -2148,4 +2155,28 @@ export class XAccountController { return "agent.did is null"; } } + + async blueskyDisconnect(): Promise { + // Revoke the session + try { + if (!this.blueskyClient) { + this.blueskyClient = await this.blueskyInitClient(); + } + const did = await this.getConfig("blueskyDID"); + if (did) { + const session = await this.blueskyClient.restore(did); + await session.signOut(); + } + } catch (e) { + log.error("XAccountController.blueskyDisconnect: Error revoking session", e); + } + + // Delete from global config + await globalDeleteConfig("blueskyOAuthAccountID"); + + // Delete from account config + await this.deleteConfig("blueskyDID"); + await this.deleteConfigLike("blueskyStateStore-%"); + await this.deleteConfigLike("blueskySessionStore-%"); + } } diff --git a/src/database/config.ts b/src/database/config.ts index 22150854..b11d5343 100644 --- a/src/database/config.ts +++ b/src/database/config.ts @@ -28,6 +28,20 @@ export const setConfig = (key: string, value: string, db: Database.Database | nu exec(db, 'INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]); } +export const deleteConfig = (key: string, db: Database.Database | null = null) => { + if (!db) { + db = getMainDatabase(); + } + exec(db, 'DELETE FROM config WHERE key = ?', [key]); +} + +export const deleteConfigLike = (key: string, db: Database.Database | null = null) => { + if (!db) { + db = getMainDatabase(); + } + exec(db, 'DELETE FROM config WHERE key LIKE ?', [key]); +} + // IPC export const defineIPCDatabaseConfig = () => { @@ -46,4 +60,20 @@ export const defineIPCDatabaseConfig = () => { throw new Error(packageExceptionForReport(error as Error)); } }); + + ipcMain.handle('database:deleteConfig', async (_, key) => { + try { + deleteConfig(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:deleteConfigLike', async (_, key) => { + try { + deleteConfigLike(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index bb44f644..62968c3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -108,7 +108,7 @@ const openCydURL = async (cydURL: string) => { const blueskyOAuthCallbackEventName = `blueskyOAuthCallback-${accountID}`; // Reset the config value - database.setConfig('blueskyOAuthAccountID', ''); + database.deleteConfig('blueskyOAuthAccountID'); // Send the event to the renderer if (win) { diff --git a/src/preload.ts b/src/preload.ts index a0b740fa..0dc8cedd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -94,6 +94,12 @@ contextBridge.exposeInMainWorld('electron', { setConfig: (key: string, value: string) => { ipcRenderer.invoke('database:setConfig', key, value) }, + deleteConfig: (key: string) => { + ipcRenderer.invoke('database:deleteConfig', key) + }, + deleteConfigLike: (key: string) => { + ipcRenderer.invoke('database:deleteConfigLike', key) + }, getErrorReport: (id: number): Promise => { return ipcRenderer.invoke('database:getErrorReport', id) }, @@ -282,6 +288,12 @@ contextBridge.exposeInMainWorld('electron', { setConfig: (accountID: number, key: string, value: string): Promise => { return ipcRenderer.invoke('X:setConfig', accountID, key, value); }, + deleteConfig: (accountID: number, key: string): Promise => { + return ipcRenderer.invoke('X:deleteConfig', accountID, key); + }, + deleteConfigLike: (accountID: number, key: string): Promise => { + return ipcRenderer.invoke('X:deleteConfigLike', accountID, key); + }, blueskyGetProfile: (accountID: number): Promise => { return ipcRenderer.invoke('X:blueskyGetProfile', accountID); }, @@ -291,6 +303,9 @@ contextBridge.exposeInMainWorld('electron', { blueskyCallback: (accountID: number, queryString: string): Promise => { return ipcRenderer.invoke('X:blueskyCallback', accountID, queryString) }, + blueskyDisconnect: (accountID: number): Promise => { + return ipcRenderer.invoke('X:blueskyDisconnect', accountID) + }, }, // Handle events from the main process diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 2d47b84a..88091601 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -60,6 +60,8 @@ declare global { database: { getConfig: (key: string) => Promise; setConfig: (key: string, value: string) => void; + deleteConfig: (key: string) => void; + deleteConfigLike: (key: string) => void; getErrorReport: (id: number) => Promise; getNewErrorReports: (accountID: number) => Promise; createErrorReport: (accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null) => Promise; @@ -122,9 +124,12 @@ declare global { getCookie: (accountID: number, name: string) => Promise; getConfig: (accountID: number, key: string) => Promise; setConfig: (accountID: number, key: string, value: string) => void; + deleteConfig: (accountID: number, key: string) => void; + deleteConfigLike: (accountID: number, key: string) => void; blueskyGetProfile: (accountID: number) => Promise; blueskyAuthorize: (accountID: number, handle: string) => Promise; blueskyCallback: (accountID: number, queryString: string) => Promise; + blueskyDisconnect: (accountID: number) => Promise; }; onPowerMonitorSuspend: (callback: () => void) => void; onPowerMonitorResume: (callback: () => void) => void; diff --git a/src/renderer/src/views/x/XWizardMigrate.vue b/src/renderer/src/views/x/XWizardMigrate.vue index 4468efd1..90192bf0 100644 --- a/src/renderer/src/views/x/XWizardMigrate.vue +++ b/src/renderer/src/views/x/XWizardMigrate.vue @@ -55,6 +55,7 @@ const oauthCallback = async (queryString: string) => { await window.electron.showMessage('Failed to connect to Bluesky: ' + ret); state.value = State.NotConnected; } else { + blueskyProfile.value = await window.electron.X.blueskyGetProfile(props.model.account.id); state.value = State.Connected; } } catch (e) { @@ -64,7 +65,7 @@ const oauthCallback = async (queryString: string) => { } const disconnectClicked = async () => { - // await window.electron.X.blueskyDisconnect(props.model.account.id); + await window.electron.X.blueskyDisconnect(props.model.account.id); state.value = State.NotConnected; } @@ -72,7 +73,11 @@ const blueskyOAuthCallbackEventName = `blueskyOAuthCallback-${props.model.accoun onMounted(async () => { // Get Bluesky profile - blueskyProfile.value = await window.electron.X.blueskyGetProfile(props.model.account.id); + try { + blueskyProfile.value = await window.electron.X.blueskyGetProfile(props.model.account.id); + } catch (e) { + await window.electron.showMessage('Failed to get Bluesky profile: ' + e); + } if (blueskyProfile.value) { state.value = State.Connected; } else { From c02b2fc11d5380232d7a2fed3e7e4721ccb882ca Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 5 Feb 2025 09:41:49 -0800 Subject: [PATCH 19/50] Add bluesky migration join table --- src/account_x/x_account_controller.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index e9f25146..a9a1184e 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -325,6 +325,18 @@ export class XAccountController { `UPDATE tweet SET deletedLikeAt = deletedAt WHERE deletedAt IS NOT NULL AND isLiked = 1;` ] }, + // Add tweet_bsky_migration table + { + name: "20241127_add_tweet_bsky_migration_table", + sql: [ + `CREATE TABLE tweet_bsky_migration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tweetID TEXT NOT NULL, + atprotoURI TEXT NOT NULL, + migratedAt DATETIME NOT NULL +);` + ] + } ]) log.info("XAccountController.initDB: database initialized"); } From c0b732bbcb045d213e3021cc7622f9fd6e8adad0 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Wed, 5 Feb 2025 10:48:16 -0800 Subject: [PATCH 20/50] Count the number of tweets to migrate before starting migration, and show it to the user --- src/account_x/ipc.ts | 10 +++ src/account_x/x_account_controller.ts | 42 ++++++++++ src/preload.ts | 4 + src/renderer/src/main.ts | 2 + src/renderer/src/views/x/XWizardMigrate.vue | 85 ++++++++++++++++----- src/shared_types/x.ts | 6 ++ 6 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts index 31e30b80..ecdaa9dc 100644 --- a/src/account_x/ipc.ts +++ b/src/account_x/ipc.ts @@ -17,6 +17,7 @@ import { XArchiveInfo, XImportArchiveResponse, BlueskyMigrationProfile, + XMigrateTweetCounts, } from '../shared_types' import { getMITMController } from '../mitm'; import { packageExceptionForReport } from '../util' @@ -482,4 +483,13 @@ export const defineIPCX = () => { throw new Error(packageExceptionForReport(error as Error)); } }); + + ipcMain.handle('X:blueskyGetTweetCounts', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.blueskyGetTweetCounts(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); }; diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index a9a1184e..3dae39da 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -34,6 +34,7 @@ import { XArchiveInfo, emptyXArchiveInfo, XImportArchiveResponse, BlueskyMigrationProfile, + XMigrateTweetCounts, } from '../shared_types' import { runMigrations, @@ -2221,4 +2222,45 @@ export class XAccountController { await this.deleteConfigLike("blueskyStateStore-%"); await this.deleteConfigLike("blueskySessionStore-%"); } + + // When you start deleting tweets, return a list of tweets to delete + async blueskyGetTweetCounts(): Promise { + if (!this.db) { + this.initDB(); + } + + if (!this.account) { + throw new Error("Account not found"); + } + + // For now, select the count of all tweets. + // Once we have reply_to, we can filter the ones we cannot migrate. + + const username = this.account.username; + const toMigrate: Sqlite3Count = exec(this.db, ` + SELECT COUNT(*) AS count + FROM tweet + LEFT JOIN tweet_bsky_migration ON tweet.tweetID = tweet_bsky_migration.tweetID + WHERE tweet_bsky_migration.tweetID IS NULL + AND tweet.text NOT LIKE ? + AND tweet.isLiked = ? + AND tweet.username = ? + `, ["RT @%", 0, username], "get") as Sqlite3Count; + const alreadyMigrated: Sqlite3Count = exec(this.db, ` + SELECT COUNT(*) AS count + FROM tweet + INNER JOIN tweet_bsky_migration ON tweet.tweetID = tweet_bsky_migration.tweetID + WHERE tweet.text NOT LIKE ? + AND tweet.isLiked = ? + AND tweet.username = ? + `, ["RT @%", 0, username], "get") as Sqlite3Count; + + // Return the counts + const resp: XMigrateTweetCounts = { + toMigrateCount: toMigrate.count, + cannotMigrateCount: 0, + alreadyMigratedCount: alreadyMigrated.count, + } + return resp; + } } diff --git a/src/preload.ts b/src/preload.ts index 0dc8cedd..f4edd84d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,6 +16,7 @@ import { XAccount, XImportArchiveResponse, BlueskyMigrationProfile, + XMigrateTweetCounts, } from './shared_types' contextBridge.exposeInMainWorld('electron', { @@ -306,6 +307,9 @@ contextBridge.exposeInMainWorld('electron', { blueskyDisconnect: (accountID: number): Promise => { return ipcRenderer.invoke('X:blueskyDisconnect', accountID) }, + blueskyGetTweetCounts: (accountID: number): Promise => { + return ipcRenderer.invoke('X:blueskyGetTweetCounts', accountID) + }, }, // Handle events from the main process diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 88091601..da3b5fb8 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -22,6 +22,7 @@ import type { XAccount, XImportArchiveResponse, BlueskyMigrationProfile, + XMigrateTweetCounts, } from "../../shared_types"; import App from "./App.vue"; @@ -130,6 +131,7 @@ declare global { blueskyAuthorize: (accountID: number, handle: string) => Promise; blueskyCallback: (accountID: number, queryString: string) => Promise; blueskyDisconnect: (accountID: number) => Promise; + blueskyGetTweetCounts: (accountID: number) => Promise; }; onPowerMonitorSuspend: (callback: () => void) => void; onPowerMonitorResume: (callback: () => void) => void; diff --git a/src/renderer/src/views/x/XWizardMigrate.vue b/src/renderer/src/views/x/XWizardMigrate.vue index 90192bf0..9263aa2f 100644 --- a/src/renderer/src/views/x/XWizardMigrate.vue +++ b/src/renderer/src/views/x/XWizardMigrate.vue @@ -4,18 +4,23 @@ import { ref, onMounted, onUnmounted } from 'vue' import { AccountXViewModel } from '../../view_models/AccountXViewModel' -import { BlueskyMigrationProfile } from '../../../../shared_types' +import { + BlueskyMigrationProfile, + XMigrateTweetCounts, +} from '../../../../shared_types' enum State { + Loading, NotConnected, Connecting, FinishInBrowser, Connected, } -const state = ref(State.NotConnected); +const state = ref(State.Loading); const blueskyProfile = ref(null); +const tweetCounts = ref(null); const blueskyHandle = ref(''); const connectButtonText = ref('Connect'); @@ -48,6 +53,11 @@ const connectClicked = async () => { } } +const loadTweetCounts = async () => { + tweetCounts.value = await window.electron.X.blueskyGetTweetCounts(props.model.account.id); + console.log("Tweet counts", JSON.parse(JSON.stringify(tweetCounts.value))); +} + const oauthCallback = async (queryString: string) => { try { const ret: boolean | string = await window.electron.X.blueskyCallback(props.model.account.id, queryString); @@ -57,6 +67,8 @@ const oauthCallback = async (queryString: string) => { } else { blueskyProfile.value = await window.electron.X.blueskyGetProfile(props.model.account.id); state.value = State.Connected; + + await loadTweetCounts(); } } catch (e) { await window.electron.showMessage('Failed to connect to Bluesky: ' + e); @@ -69,6 +81,10 @@ const disconnectClicked = async () => { state.value = State.NotConnected; } +const migrateClicked = async () => { + //await window.electron.X.blueskyMigrate(props.model.account.id); +} + const blueskyOAuthCallbackEventName = `blueskyOAuthCallback-${props.model.account.id}`; onMounted(async () => { @@ -80,8 +96,10 @@ onMounted(async () => { } if (blueskyProfile.value) { state.value = State.Connected; + await loadTweetCounts(); } else { state.value = State.NotConnected; + tweetCounts.value = null; } // Listen for OAuth callback event @@ -104,23 +122,15 @@ onUnmounted(async () => { Premium

- Import your old tweets into a Bluesky account. + Import your old tweets into a Bluesky account. You may want to make a new Bluesky account just for your + old tweets.

- - + - + + + From bbcc40b7844cb0f1a3775e63b7be39b9640d7ee9 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 18 Feb 2025 08:45:13 -0800 Subject: [PATCH 29/50] Add support for deleting migrated tweets --- src/account_x/ipc.ts | 9 +++ src/account_x/types.ts | 8 ++ src/account_x/x_account_controller.ts | 49 +++++++++++- src/preload.ts | 5 +- src/renderer/src/main.ts | 1 + .../src/views/x/XWizardMigrateBluesky.vue | 80 +++++++++++++++++-- src/shared_types/x.ts | 4 +- 7 files changed, 143 insertions(+), 13 deletions(-) diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts index be2a216e..978fe454 100644 --- a/src/account_x/ipc.ts +++ b/src/account_x/ipc.ts @@ -482,4 +482,13 @@ export const defineIPCX = () => { throw new Error(packageExceptionForReport(error as Error)); } }); + + ipcMain.handle('X:blueskyDeleteMigratedTweet', async (_, accountID: number, tweetID: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.blueskyDeleteMigratedTweet(tweetID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); }; diff --git a/src/account_x/types.ts b/src/account_x/types.ts index 31f80fb4..9ec6f414 100644 --- a/src/account_x/types.ts +++ b/src/account_x/types.ts @@ -103,6 +103,14 @@ export interface XMessageRow { deletedAt: string | null; } +export type XTweetBlueskyMigrationRow = { + id: number; + tweetID: string; + atprotoURI: string; + atprotoCID: string; + migratedAt: string; +} + // Converters export function convertXJobRowToXJob(row: XJobRow): XJob { diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index de20e1e6..0f2a7af2 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -58,6 +58,7 @@ import { XConversationRow, XMessageRow, XConversationParticipantRow, + XTweetBlueskyMigrationRow, convertXJobRowToXJob, convertTweetRowToXTweetItem, convertTweetRowToXTweetItemArchive, @@ -2577,20 +2578,21 @@ export class XAccountController { AND (tweet.isReply = ? AND tweet.replyUserID != ?) `, ["RT @%", 0, username, 1, userRow.userID], "get") as Sqlite3Count; - const alreadyMigrated: Sqlite3Count = exec(this.db, ` - SELECT COUNT(*) AS count + const alreadyMigratedTweets: XTweetRow[] = exec(this.db, ` + SELECT tweet.* FROM tweet INNER JOIN tweet_bsky_migration ON tweet.tweetID = tweet_bsky_migration.tweetID WHERE tweet.text NOT LIKE ? AND tweet.isLiked = ? AND tweet.username = ? - `, ["RT @%", 0, username], "get") as Sqlite3Count; + `, ["RT @%", 0, username], "all") as XTweetRow[]; + const alreadyMigratedTweetIDs = alreadyMigratedTweets.map((tweet) => tweet.tweetID); // Return the counts const resp: XMigrateTweetCounts = { toMigrateTweetIDs: toMigrateTweetIDs, cannotMigrateCount: cannotMigrate.count, - alreadyMigratedCount: alreadyMigrated.count, + alreadyMigratedTweetIDs: alreadyMigratedTweetIDs, } log.info("XAccountController.blueskyGetTweetCounts: returning", resp); return resp; @@ -2687,4 +2689,43 @@ export class XAccountController { return false; } } + + async blueskyDeleteMigratedTweet(tweetID: string): Promise { + if (!this.db) { this.initDB(); } + if (!this.account) { throw new Error("Account not found"); } + + // Get the Bluesky client + if (!this.blueskyClient) { + this.blueskyClient = await this.blueskyInitClient(); + } + const did = await this.getConfig("blueskyDID"); + if (!did) { + throw new Error("Bluesky DID not found"); + } + const session = await this.blueskyClient.restore(did); + const agent = new Agent(session) + + // Select the migration record + const migration: XTweetBlueskyMigrationRow = exec(this.db, ` + SELECT * + FROM tweet_bsky_migration + WHERE tweetID = ? + `, [tweetID], "get") as XTweetBlueskyMigrationRow; + + + try { + // Delete it from Bluesky + await agent.deletePost(migration.atprotoURI) + + // Delete the migration record + exec(this.db, ` + DELETE FROM tweet_bsky_migration WHERE tweetID = ? + `, [tweetID]); + + return true; + } catch (e) { + log.error("XAccountController.blueskyDeleteMigratedTweet: Error deleting migrated tweet from Bluesky", e); + return false; + } + } } diff --git a/src/preload.ts b/src/preload.ts index db32b7d8..4ef6808e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -317,9 +317,12 @@ contextBridge.exposeInMainWorld('electron', { blueskyGetTweetCounts: (accountID: number): Promise => { return ipcRenderer.invoke('X:blueskyGetTweetCounts', accountID) }, - blueskyMigrateTweet: (accountID: number, tweetID: string): Promise => { + blueskyMigrateTweet: (accountID: number, tweetID: string): Promise => { return ipcRenderer.invoke('X:blueskyMigrateTweet', accountID, tweetID) }, + blueskyDeleteMigratedTweet: (accountID: number, tweetID: string): Promise => { + return ipcRenderer.invoke('X:blueskyDeleteMigratedTweet', accountID, tweetID) + } }, Facebook: { resetProgress: (accountID: number): Promise => { diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 17aea97e..b66484f5 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -138,6 +138,7 @@ declare global { blueskyDisconnect: (accountID: number) => Promise; blueskyGetTweetCounts: (accountID: number) => Promise; blueskyMigrateTweet: (accountID: number, tweetID: string) => Promise; + blueskyDeleteMigratedTweet: (accountID: number, tweetID: string) => Promise; }; Facebook: { resetProgress: (accountID: number) => Promise; diff --git a/src/renderer/src/views/x/XWizardMigrateBluesky.vue b/src/renderer/src/views/x/XWizardMigrateBluesky.vue index e94cd30b..85cf2b33 100644 --- a/src/renderer/src/views/x/XWizardMigrateBluesky.vue +++ b/src/renderer/src/views/x/XWizardMigrateBluesky.vue @@ -17,6 +17,7 @@ enum State { FinishInBrowser, Connected, Migrating, + Deleting, Finished, } @@ -26,6 +27,8 @@ const blueskyProfile = ref(null); const tweetCounts = ref(null); const migratedTweetsCount = ref(0); const skippedTweetsCount = ref(0); +const deletedPostsCount = ref(0); +const skippedDeletePostsCount = ref(0); const shouldCancelMigration = ref(false); const blueskyHandle = ref(''); @@ -124,6 +127,30 @@ const migrateCancelClicked = async () => { shouldCancelMigration.value = true; } +const deleteClicked = async () => { + if (tweetCounts.value === null) { + await window.electron.showMessage("You don't have any tweets to delete.", ''); + return; + } + + deletedPostsCount.value = 0; + skippedDeletePostsCount.value = 0; + + state.value = State.Deleting; + + for (const tweetID of tweetCounts.value.alreadyMigratedTweetIDs) { + if (await window.electron.X.blueskyDeleteMigratedTweet(props.model.account.id, tweetID)) { + deletedPostsCount.value++; + } else { + skippedDeletePostsCount.value++; + console.error('Failed to delete migrated tweet', tweetID); + } + } + + await loadTweetCounts(); + state.value = State.Connected; +} + const viewBlueskyProfileClicked = async () => { await openURL(`https://bsky.app/profile/${blueskyProfile.value?.handle}`); } @@ -206,7 +233,7 @@ onUnmounted(async () => { - + + + diff --git a/src/shared_types/x.ts b/src/shared_types/x.ts index 95d78dfe..bce013dd 100644 --- a/src/shared_types/x.ts +++ b/src/shared_types/x.ts @@ -271,8 +271,10 @@ export interface XImportArchiveResponse { skipCount: number; } +// Migration types + export type XMigrateTweetCounts = { toMigrateTweetIDs: string[]; cannotMigrateCount: number; - alreadyMigratedCount: number; + alreadyMigratedTweetIDs: string[]; } \ No newline at end of file From 4ff2368ddaaf4977a8aa245570befc41d6f2cbf0 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 18 Feb 2025 08:57:28 -0800 Subject: [PATCH 30/50] Handle migrating replies --- src/account_x/x_account_controller.ts | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 0f2a7af2..cfc6c23c 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -2660,6 +2660,63 @@ export class XAccountController { }); } + // Get the current user's user ID + const userRow: XUserRow = exec(this.db, ` + SELECT * + FROM user + WHERE screenName = ? + `, [this.account.username], "get") as XUserRow; + + // Handle replies + let reply = null; + if (tweet.isReply && tweet.replyUserID == userRow.userID) { + // Find the parent tweet migration + const parentMigration: XTweetBlueskyMigrationRow = exec(this.db, ` + SELECT * + FROM tweet_bsky_migration + WHERE tweetID = ? + `, [tweet.replyTweetID], "get") as XTweetBlueskyMigrationRow; + if (parentMigration) { + // Find the root tweet in the thread + let foundRoot = false; + let rootTweetID = tweet.replyTweetID; + while (!foundRoot) { + const parentTweet: XTweetRow = exec(this.db, ` + SELECT * + FROM tweet + WHERE tweetID = ? + `, [rootTweetID], "get") as XTweetRow; + if (parentTweet && parentTweet.isReply && parentTweet.replyUserID == userRow.userID) { + rootTweetID = parentTweet.replyTweetID; + } else { + foundRoot = true; + } + } + + if (foundRoot) { + // Get the root migration + const rootMigration: XTweetBlueskyMigrationRow = exec(this.db, ` + SELECT * + FROM tweet_bsky_migration + WHERE tweetID = ? + `, [rootTweetID], "get") as XTweetBlueskyMigrationRow; + if (rootMigration) { + // Build the reply + reply = { + root: { + uri: rootMigration.atprotoURI, + cid: rootMigration.atprotoCID, + }, + parent: { + uri: parentMigration.atprotoURI, + cid: parentMigration.atprotoCID, + }, + }; + } + } + } + } + // Build the record const record: BskyPostRecord = { '$type': 'app.bsky.feed.post', @@ -2669,6 +2726,9 @@ export class XAccountController { if (facets.length > 0) { record['facets'] = facets; } + if (reply) { + record['reply'] = reply; + } // TODO: add media, reply_to, quotes // See: https://docs.bsky.app/docs/advanced-guides/posts#replies-quote-posts-and-embeds From 8f59c85d38e5353446b08b0a5c3cfba2384704c3 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 18 Feb 2025 09:13:12 -0800 Subject: [PATCH 31/50] Support quoted tweets --- src/account_x/x_account_controller.ts | 49 ++++++++++++++++++- .../shared_components/SidebarArchive.vue | 4 ++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index cfc6c23c..476b3c2e 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -2717,6 +2717,48 @@ export class XAccountController { } } + // Handle quotes + let embed = null; + if (tweet.isQuote && tweet.quotedTweet) { + // Parse the quoted tweet URL to see if it's a self-quote + // URL looks like: https://twitter.com/{username}/status/{tweetID} + const quotedTweetURL = new URL(tweet.quotedTweet); + const quotedTweetUsername = quotedTweetURL.pathname.split('/')[1]; + const quotedTweetID = quotedTweetURL.pathname.split('/')[3]; + + // Self quote + if (quotedTweetUsername == this.account.username) { + // Load the quoted tweet migration + const quotedMigration: XTweetBlueskyMigrationRow = exec(this.db, ` + SELECT * + FROM tweet_bsky_migration + WHERE tweetID = ? + `, [quotedTweetID], "get") as XTweetBlueskyMigrationRow; + if (quotedMigration) { + embed = { + '$type': 'app.bsky.embed.record', + record: { + uri: quotedMigration.atprotoURI, + cid: quotedMigration.atprotoCID, + } + } + } + } + + // External quote? Make it a website card + if (!embed) { + embed = { + '$type': 'app.bsky.embed.external', + external: { + uri: tweet.quotedTweet, + title: "Quoted Tweet on X", + description: `View tweet at ` + quotedTweetURL, + } + } + } + + } + // Build the record const record: BskyPostRecord = { '$type': 'app.bsky.feed.post', @@ -2729,9 +2771,12 @@ export class XAccountController { if (reply) { record['reply'] = reply; } + if (embed) { + record['embed'] = embed; + } - // TODO: add media, reply_to, quotes - // See: https://docs.bsky.app/docs/advanced-guides/posts#replies-quote-posts-and-embeds + // TODO: add media + // See: https://docs.bsky.app/docs/advanced-guides/posts try { // Post it to Bluesky diff --git a/src/renderer/src/views/shared_components/SidebarArchive.vue b/src/renderer/src/views/shared_components/SidebarArchive.vue index d24ecdd7..6fd572bf 100644 --- a/src/renderer/src/views/shared_components/SidebarArchive.vue +++ b/src/renderer/src/views/shared_components/SidebarArchive.vue @@ -2,6 +2,7 @@ import { ref, getCurrentInstance, + onMounted, } from 'vue'; import { ArchiveInfo, emptyArchiveInfo @@ -32,6 +33,9 @@ const openArchive = async () => { await window.electron.archive.openFolder(props.accountID, "index.html"); }; +onMounted(async () => { + archiveInfo.value = await window.electron.archive.getInfo(props.accountID); +}); - - - - From 3487f9088f1a653ede366c66b060e027e5e9fbcb Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 18 Feb 2025 12:35:44 -0800 Subject: [PATCH 37/50] Ask if the user is sure they want to delete migrated tweets --- src/renderer/src/views/x/XWizardMigrateBluesky.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/views/x/XWizardMigrateBluesky.vue b/src/renderer/src/views/x/XWizardMigrateBluesky.vue index d9b7af66..0032b957 100644 --- a/src/renderer/src/views/x/XWizardMigrateBluesky.vue +++ b/src/renderer/src/views/x/XWizardMigrateBluesky.vue @@ -137,6 +137,10 @@ const deleteClicked = async () => { return; } + if (!await window.electron.showQuestion('Are you sure you want to delete all migrated tweets from Bluesky?', 'Yes, delete them all', 'No, keep them')) { + return; + } + deletedPostsCount.value = 0; skippedDeletePostsCount.value = 0; From 0a119de9fbe98888f9c36c584c66be635bd15037 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 18 Feb 2025 14:06:21 -0800 Subject: [PATCH 38/50] Make the CheckPremium view work for migrating to Bluesky, not just deleting data --- src/renderer/src/views/x/XView.vue | 2 + .../src/views/x/XWizardCheckPremium.vue | 147 ++++++++++++++---- .../src/views/x/XWizardMigrateBluesky.vue | 27 +++- 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/views/x/XView.vue b/src/renderer/src/views/x/XView.vue index 6e3efa49..eeb65d9a 100644 --- a/src/renderer/src/views/x/XView.vue +++ b/src/renderer/src/views/x/XView.vue @@ -258,6 +258,7 @@ const startJobs = async () => { await updateUserAuthenticated(); console.log("userAuthenticated", userAuthenticated.value); if (!userAuthenticated.value) { + localStorage.setItem(`premiumCheckReason-${model.value.account.id}`, 'deleteData'); model.value.state = State.WizardCheckPremium; await startStateLoop(); return; @@ -266,6 +267,7 @@ const startJobs = async () => { await updateUserPremium(); console.log("userPremium", userPremium.value); if (!userPremium.value) { + localStorage.setItem(`premiumCheckReason-${model.value.account.id}`, 'deleteData'); model.value.state = State.WizardCheckPremium; await startStateLoop(); return; diff --git a/src/renderer/src/views/x/XWizardCheckPremium.vue b/src/renderer/src/views/x/XWizardCheckPremium.vue index 3e1dce48..8bc8166d 100644 --- a/src/renderer/src/views/x/XWizardCheckPremium.vue +++ b/src/renderer/src/views/x/XWizardCheckPremium.vue @@ -1,5 +1,5 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/renderer/src/views/x/XWizardMigrateBluesky.vue b/src/renderer/src/views/x/XWizardMigrateBluesky.vue index 0032b957..5aa5a986 100644 --- a/src/renderer/src/views/x/XWizardMigrateBluesky.vue +++ b/src/renderer/src/views/x/XWizardMigrateBluesky.vue @@ -1,14 +1,16 @@