A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add guided profile mirroring onboarding and account sync tooling

j4ckxyz 08f3e975 8c2b161f

+1396 -128
+9 -1
README.md
··· 35 35 36 36 1. Register the first user (this user becomes admin). 37 37 2. Add Twitter cookies in Settings. 38 - 3. Add at least one mapping. 38 + 3. Add at least one mapping via the guided "Add account" flow (Twitter sources -> Bluesky account -> credential validation -> verify email + create). 39 39 4. Click `Run now`. 40 40 41 41 ### 3) Useful Docker commands ··· 391 391 npm run cli -- run-now 392 392 ``` 393 393 394 + `add-mapping` now runs a guided onboarding flow: 395 + 396 + 1. enter one or more Twitter source usernames 397 + 2. create Bluesky account (or use existing) 398 + 3. enter Bluesky identifier + app password (+ optional custom PDS URL) 399 + 4. verify email, then create mapping and auto-sync profile metadata from Twitter 400 + 394 401 ## Recommended Command Examples 395 402 396 403 Always invoke CLI commands as: ··· 419 426 420 427 ```bash 421 428 npm run cli -- add-mapping 429 + npm run cli -- sync-profile <mapping-id-or-handle> --source <twitter-username> 422 430 npm run cli -- edit-mapping <mapping-id-or-handle> 423 431 npm run cli -- remove <mapping-id-or-handle> 424 432 ```
+1
package.json
··· 12 12 "rebuild:native": "bash scripts/rebuild-native.sh", 13 13 "postinstall": "bash scripts/rebuild-native.sh", 14 14 "run:once": "node dist/index.js --run-once --no-web", 15 + "test:twitter-metadata": "tsx scripts/test-twitter-profile-metadata.ts", 15 16 "start": "node dist/index.js", 16 17 "cli": "tsx src/cli.ts", 17 18 "dev": "tsx src/index.ts",
+90
scripts/test-twitter-profile-metadata.ts
··· 1 + import { Scraper } from '@the-convocation/twitter-scraper'; 2 + import { getConfig } from '../src/config-manager.js'; 3 + 4 + interface CookieSet { 5 + label: string; 6 + authToken: string; 7 + ct0: string; 8 + } 9 + 10 + const normalizeHandle = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 11 + 12 + const handles = process.argv 13 + .slice(2) 14 + .map(normalizeHandle) 15 + .filter((value) => value.length > 0); 16 + 17 + if (handles.length === 0) { 18 + console.error('Usage: npm run test:twitter-metadata -- <twitter-handle> [<twitter-handle> ...]'); 19 + process.exit(1); 20 + } 21 + 22 + const config = getConfig(); 23 + const cookieSets: CookieSet[] = []; 24 + 25 + if (config.twitter.authToken && config.twitter.ct0) { 26 + cookieSets.push({ 27 + label: 'primary', 28 + authToken: config.twitter.authToken, 29 + ct0: config.twitter.ct0, 30 + }); 31 + } 32 + 33 + if (config.twitter.backupAuthToken && config.twitter.backupCt0) { 34 + cookieSets.push({ 35 + label: 'backup', 36 + authToken: config.twitter.backupAuthToken, 37 + ct0: config.twitter.backupCt0, 38 + }); 39 + } 40 + 41 + if (cookieSets.length === 0) { 42 + console.error('No Twitter cookies configured. Run setup-twitter first.'); 43 + process.exit(1); 44 + } 45 + 46 + const run = async () => { 47 + const errors: string[] = []; 48 + 49 + for (const handle of handles) { 50 + let resolved = false; 51 + for (const cookieSet of cookieSets) { 52 + const scraper = new Scraper(); 53 + try { 54 + await scraper.setCookies([`auth_token=${cookieSet.authToken}`, `ct0=${cookieSet.ct0}`]); 55 + const profile = await scraper.getProfile(handle); 56 + const avatar = profile.avatar || ''; 57 + const banner = profile.banner || ''; 58 + const name = profile.name || ''; 59 + const biography = profile.biography || ''; 60 + 61 + console.log(`\n[${handle}] via ${cookieSet.label} cookies`); 62 + console.log(` username: ${profile.username || handle}`); 63 + console.log(` name: ${name || '(none)'}`); 64 + console.log(` biography: ${biography ? JSON.stringify(biography) : '(none)'}`); 65 + console.log(` avatar: ${avatar || '(none)'}`); 66 + console.log(` banner: ${banner || '(none)'}`); 67 + resolved = true; 68 + break; 69 + } catch (error) { 70 + errors.push(`[${handle}] ${cookieSet.label}: ${error instanceof Error ? error.message : String(error)}`); 71 + } 72 + } 73 + 74 + if (!resolved) { 75 + console.error(`\n[${handle}] Failed to fetch profile with all configured cookie sets.`); 76 + } 77 + } 78 + 79 + if (errors.length > 0) { 80 + console.log('\nDetailed errors:'); 81 + for (const error of errors) { 82 + console.log(` - ${error}`); 83 + } 84 + } 85 + }; 86 + 87 + run().catch((error) => { 88 + console.error(error instanceof Error ? error.message : String(error)); 89 + process.exit(1); 90 + });
+212 -25
src/cli.ts
··· 6 6 import inquirer from 'inquirer'; 7 7 import { deleteAllPosts } from './bsky.js'; 8 8 import { 9 + type AccountMapping, 10 + type AppConfig, 9 11 addMapping, 10 12 getConfig, 11 13 removeMapping, 12 14 saveConfig, 13 15 updateTwitterConfig, 14 - type AccountMapping, 15 - type AppConfig, 16 16 } from './config-manager.js'; 17 17 import { dbService } from './db.js'; 18 + import { 19 + fetchTwitterMirrorProfile, 20 + syncBlueskyProfileFromTwitter, 21 + validateBlueskyCredentials, 22 + } from './profile-mirror.js'; 18 23 19 24 const __filename = fileURLToPath(import.meta.url); 20 25 const __dirname = path.dirname(__filename); ··· 155 160 156 161 const program = new Command(); 157 162 158 - program 159 - .name('tweets-2-bsky-cli') 160 - .description('CLI for full Tweets -> Bluesky dashboard workflows') 161 - .version('2.1.0'); 163 + program.name('tweets-2-bsky-cli').description('CLI for full Tweets -> Bluesky dashboard workflows').version('2.1.0'); 162 164 163 165 program 164 166 .command('setup-ai') ··· 255 257 256 258 program 257 259 .command('add-mapping') 258 - .description('Add a new Twitter -> Bluesky mapping') 260 + .description('Add a new Twitter -> Bluesky mapping with guided onboarding') 259 261 .action(async () => { 262 + const config = getConfig(); 263 + const ownerDefault = 264 + config.users.find((user) => user.role === 'admin')?.username || config.users[0]?.username || ''; 265 + 260 266 const answers = await inquirer.prompt([ 261 267 { 262 268 type: 'input', 263 - name: 'owner', 264 - message: 'Owner name:', 265 - }, 266 - { 267 - type: 'input', 268 269 name: 'twitterUsernames', 269 270 message: 'Twitter username(s) to watch (comma separated, without @):', 270 271 }, 272 + ]); 273 + 274 + const usernames = String(answers.twitterUsernames || '') 275 + .split(',') 276 + .map((username: string) => normalizeHandle(username)) 277 + .filter((username: string) => username.length > 0); 278 + 279 + if (usernames.length === 0) { 280 + console.log('Please provide at least one Twitter username.'); 281 + return; 282 + } 283 + 284 + const accountFlow = await inquirer.prompt([ 285 + { 286 + type: 'list', 287 + name: 'accountState', 288 + message: 'Bluesky account setup:', 289 + choices: [ 290 + { name: 'Open bsky.app and create a new account', value: 'create' }, 291 + { name: 'I already have a Bluesky account', value: 'existing' }, 292 + ], 293 + }, 294 + ]); 295 + 296 + if (accountFlow.accountState === 'create') { 297 + console.log('Open https://bsky.app to create the account, then generate an app password.'); 298 + const continueAnswer = await inquirer.prompt([ 299 + { 300 + type: 'confirm', 301 + name: 'continueAfterCreate', 302 + message: 'Continue once your Bluesky account exists?', 303 + default: true, 304 + }, 305 + ]); 306 + 307 + if (!continueAnswer.continueAfterCreate) { 308 + console.log('Cancelled.'); 309 + return; 310 + } 311 + } 312 + 313 + const bskyAnswers = await inquirer.prompt([ 271 314 { 272 315 type: 'input', 273 316 name: 'bskyIdentifier', ··· 283 326 name: 'bskyServiceUrl', 284 327 message: 'Bluesky service URL:', 285 328 default: 'https://bsky.social', 329 + }, 330 + ]); 331 + 332 + let validation: Awaited<ReturnType<typeof validateBlueskyCredentials>>; 333 + try { 334 + validation = await validateBlueskyCredentials({ 335 + bskyIdentifier: bskyAnswers.bskyIdentifier, 336 + bskyPassword: bskyAnswers.bskyPassword, 337 + bskyServiceUrl: bskyAnswers.bskyServiceUrl, 338 + }); 339 + console.log(`Authenticated as @${validation.handle} on ${validation.serviceUrl}.`); 340 + if (validation.emailConfirmed) { 341 + console.log('Email status: confirmed ✅'); 342 + } else { 343 + console.log('Email status: not confirmed yet ⚠️ (media upload features may be limited until verified).'); 344 + } 345 + console.log(`Verify email from: ${validation.settingsUrl}`); 346 + } catch (error) { 347 + console.log(`Bluesky credential validation failed: ${error instanceof Error ? error.message : String(error)}`); 348 + return; 349 + } 350 + 351 + const continueAfterVerify = await inquirer.prompt([ 352 + { 353 + type: 'confirm', 354 + name: 'continueAfterVerify', 355 + message: 'Continue with mapping creation and profile mirror sync now?', 356 + default: validation.emailConfirmed, 357 + }, 358 + ]); 359 + 360 + if (!continueAfterVerify.continueAfterVerify) { 361 + console.log('Cancelled.'); 362 + return; 363 + } 364 + 365 + try { 366 + const preview = await fetchTwitterMirrorProfile(usernames[0] || ''); 367 + console.log(`Twitter mirror preview from @${preview.username}:`); 368 + console.log(` Display name -> ${preview.mirroredDisplayName}`); 369 + console.log(` Bio preview -> ${JSON.stringify(preview.mirroredDescription)}`); 370 + } catch (error) { 371 + console.log(`Twitter metadata preview failed: ${error instanceof Error ? error.message : String(error)}`); 372 + console.log('Continuing. You can retry profile sync later with sync-profile.'); 373 + } 374 + 375 + const metadataAnswers = await inquirer.prompt([ 376 + { 377 + type: 'input', 378 + name: 'owner', 379 + message: 'Owner name (optional):', 380 + default: ownerDefault, 286 381 }, 287 382 { 288 383 type: 'input', ··· 294 389 name: 'groupEmoji', 295 390 message: 'Group emoji icon (optional):', 296 391 }, 392 + { 393 + type: 'list', 394 + name: 'mirrorSourceUsername', 395 + message: 'Use which Twitter source for profile mirror metadata?', 396 + choices: usernames.map((username) => ({ 397 + name: `@${username}`, 398 + value: username, 399 + })), 400 + default: usernames[0], 401 + }, 297 402 ]); 298 403 299 - const usernames = answers.twitterUsernames 300 - .split(',') 301 - .map((username: string) => username.trim()) 302 - .filter((username: string) => username.length > 0); 303 - 304 404 addMapping({ 305 - owner: answers.owner, 405 + owner: metadataAnswers.owner, 306 406 twitterUsernames: usernames, 307 - bskyIdentifier: answers.bskyIdentifier, 308 - bskyPassword: answers.bskyPassword, 309 - bskyServiceUrl: answers.bskyServiceUrl, 310 - groupName: answers.groupName?.trim() || undefined, 311 - groupEmoji: answers.groupEmoji?.trim() || undefined, 407 + bskyIdentifier: bskyAnswers.bskyIdentifier, 408 + bskyPassword: bskyAnswers.bskyPassword, 409 + bskyServiceUrl: bskyAnswers.bskyServiceUrl, 410 + groupName: metadataAnswers.groupName?.trim() || undefined, 411 + groupEmoji: metadataAnswers.groupEmoji?.trim() || undefined, 312 412 }); 313 - console.log('Mapping added successfully.'); 413 + 414 + const latestConfig = getConfig(); 415 + const createdMapping = [...latestConfig.mappings] 416 + .reverse() 417 + .find( 418 + (mapping) => 419 + normalizeHandle(mapping.bskyIdentifier) === normalizeHandle(bskyAnswers.bskyIdentifier) && 420 + normalizeHandle(mapping.bskyServiceUrl || 'https://bsky.social') === 421 + normalizeHandle(bskyAnswers.bskyServiceUrl || 'https://bsky.social') && 422 + mapping.twitterUsernames.length === usernames.length && 423 + mapping.twitterUsernames.every( 424 + (username, index) => normalizeHandle(username) === normalizeHandle(usernames[index] || ''), 425 + ), 426 + ); 427 + 428 + if (!createdMapping) { 429 + console.log('Mapping added, but could not locate it for automatic profile sync.'); 430 + return; 431 + } 432 + 433 + try { 434 + const syncResult = await syncBlueskyProfileFromTwitter({ 435 + twitterUsername: metadataAnswers.mirrorSourceUsername, 436 + bskyIdentifier: createdMapping.bskyIdentifier, 437 + bskyPassword: createdMapping.bskyPassword, 438 + bskyServiceUrl: createdMapping.bskyServiceUrl, 439 + }); 440 + console.log('Mapping added successfully. Bluesky profile mirror sync completed.'); 441 + if (syncResult.warnings.length > 0) { 442 + console.log('Profile sync warnings:'); 443 + for (const warning of syncResult.warnings) { 444 + console.log(` - ${warning}`); 445 + } 446 + } 447 + } catch (error) { 448 + console.log('Mapping added successfully, but automatic profile sync failed.'); 449 + console.log(`Reason: ${error instanceof Error ? error.message : String(error)}`); 450 + console.log('Run `npm run cli -- sync-profile` later to retry.'); 451 + } 452 + }); 453 + 454 + program 455 + .command('sync-profile [mapping]') 456 + .description('Sync Bluesky profile from a mapped Twitter source') 457 + .option('-s, --source <username>', 'Twitter source username to mirror from') 458 + .action(async (mappingRef?: string, options?: { source?: string }) => { 459 + const mapping = await ensureMapping(mappingRef); 460 + if (!mapping) return; 461 + 462 + const requestedSource = options?.source ? normalizeHandle(options.source) : ''; 463 + if ( 464 + requestedSource && 465 + !mapping.twitterUsernames.some((username) => normalizeHandle(username) === normalizeHandle(requestedSource)) 466 + ) { 467 + console.log(`@${requestedSource} is not part of the selected mapping.`); 468 + return; 469 + } 470 + 471 + const sourceTwitterUsername = requestedSource || mapping.twitterUsernames[0]; 472 + if (!sourceTwitterUsername) { 473 + console.log('Mapping has no Twitter source usernames.'); 474 + return; 475 + } 476 + 477 + try { 478 + const result = await syncBlueskyProfileFromTwitter({ 479 + twitterUsername: sourceTwitterUsername, 480 + bskyIdentifier: mapping.bskyIdentifier, 481 + bskyPassword: mapping.bskyPassword, 482 + bskyServiceUrl: mapping.bskyServiceUrl, 483 + }); 484 + 485 + console.log(`Profile sync completed for ${mapping.bskyIdentifier} from @${result.twitterProfile.username}.`); 486 + if (result.warnings.length > 0) { 487 + console.log('Warnings:'); 488 + for (const warning of result.warnings) { 489 + console.log(` - ${warning}`); 490 + } 491 + } 492 + } catch (error) { 493 + console.log(`Profile sync failed: ${error instanceof Error ? error.message : String(error)}`); 494 + } 314 495 }); 315 496 316 497 program ··· 517 698 const mapping = await ensureMapping(mappingRef); 518 699 if (!mapping) return; 519 700 520 - const args = ['--run-once', '--backfill-mapping', mapping.id, '--backfill-limit', String(parsePositiveInt(options.limit, 15))]; 701 + const args = [ 702 + '--run-once', 703 + '--backfill-mapping', 704 + mapping.id, 705 + '--backfill-limit', 706 + String(parsePositiveInt(options.limit, 15)), 707 + ]; 521 708 if (options.dryRun) args.push('--dry-run'); 522 709 if (!options.web) args.push('--no-web'); 523 710
+422
src/profile-mirror.ts
··· 1 + import { BskyAgent } from '@atproto/api'; 2 + import type { BlobRef } from '@atproto/api'; 3 + import { Scraper, type Profile as TwitterProfile } from '@the-convocation/twitter-scraper'; 4 + import axios from 'axios'; 5 + import sharp from 'sharp'; 6 + import { getConfig } from './config-manager.js'; 7 + 8 + const PROFILE_IMAGE_MAX_BYTES = 1_000_000; 9 + const PROFILE_IMAGE_TARGET_BYTES = 950 * 1024; 10 + const DEFAULT_BSKY_SERVICE_URL = 'https://bsky.social'; 11 + const BSKY_SETTINGS_URL = 'https://bsky.app/settings/account'; 12 + const MIRROR_SUFFIX = '{UNOFFICIAL}'; 13 + 14 + type ProfileImageKind = 'avatar' | 'banner'; 15 + 16 + interface TwitterCookieSet { 17 + label: string; 18 + authToken: string; 19 + ct0: string; 20 + } 21 + 22 + interface ProcessedProfileImage { 23 + buffer: Buffer; 24 + mimeType: 'image/jpeg' | 'image/png'; 25 + } 26 + 27 + export interface TwitterMirrorProfile { 28 + username: string; 29 + profileUrl: string; 30 + name?: string; 31 + biography?: string; 32 + avatarUrl?: string; 33 + bannerUrl?: string; 34 + mirroredDisplayName: string; 35 + mirroredDescription: string; 36 + } 37 + 38 + export interface BlueskyCredentialValidation { 39 + did: string; 40 + handle: string; 41 + email?: string; 42 + emailConfirmed: boolean; 43 + serviceUrl: string; 44 + settingsUrl: string; 45 + } 46 + 47 + export interface MirrorProfileSyncResult { 48 + twitterProfile: TwitterMirrorProfile; 49 + bsky: BlueskyCredentialValidation; 50 + avatarSynced: boolean; 51 + bannerSynced: boolean; 52 + warnings: string[]; 53 + } 54 + 55 + const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 56 + 57 + const normalizeOptionalString = (value: unknown): string | undefined => { 58 + if (typeof value !== 'string') return undefined; 59 + const trimmed = value.trim(); 60 + return trimmed.length > 0 ? trimmed : undefined; 61 + }; 62 + 63 + const normalizeBskyServiceUrl = (value?: string): string => { 64 + const raw = normalizeOptionalString(value) || DEFAULT_BSKY_SERVICE_URL; 65 + const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; 66 + const url = new URL(withProtocol); 67 + return url.toString().replace(/\/$/, ''); 68 + }; 69 + 70 + const getGraphemeSegments = (value: string): string[] => { 71 + const SegmenterCtor = (globalThis.Intl as any).Segmenter as 72 + | (new ( 73 + locale: string, 74 + options: { granularity: 'grapheme' }, 75 + ) => { 76 + segment: (input: string) => Iterable<{ segment: string }>; 77 + }) 78 + | undefined; 79 + if (SegmenterCtor) { 80 + const segmenter = new SegmenterCtor('en', { granularity: 'grapheme' }); 81 + return [...segmenter.segment(value)].map((segment) => segment.segment); 82 + } 83 + return Array.from(value); 84 + }; 85 + 86 + const truncateGraphemes = (value: string, limit: number): string => { 87 + if (limit <= 0) return ''; 88 + const segments = getGraphemeSegments(value); 89 + if (segments.length <= limit) { 90 + return value; 91 + } 92 + return segments.slice(0, limit).join(''); 93 + }; 94 + 95 + const normalizeWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim(); 96 + 97 + const normalizeTwitterAvatarUrl = (url?: string): string | undefined => { 98 + if (!url) return undefined; 99 + return url.replace('_normal.', '_400x400.'); 100 + }; 101 + 102 + const normalizeTwitterBannerUrl = (url?: string): string | undefined => { 103 + if (!url) return undefined; 104 + if (/\/\d+x\d+(?:$|\?)/.test(url)) { 105 + return url; 106 + } 107 + return `${url}/1500x500`; 108 + }; 109 + 110 + const inferImageMimeTypeFromUrl = (url: string): 'image/jpeg' | 'image/png' => { 111 + const lower = url.toLowerCase(); 112 + if (lower.includes('.png')) return 'image/png'; 113 + return 'image/jpeg'; 114 + }; 115 + 116 + const detectImageMimeType = (contentType: unknown, url: string): 'image/jpeg' | 'image/png' => { 117 + if (typeof contentType === 'string') { 118 + const normalized = contentType.split(';')[0]?.trim().toLowerCase(); 119 + if (normalized === 'image/png') return 'image/png'; 120 + if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg'; 121 + } 122 + return inferImageMimeTypeFromUrl(url); 123 + }; 124 + 125 + const getProfileImagePreset = (kind: ProfileImageKind) => { 126 + if (kind === 'avatar') { 127 + return { 128 + width: 640, 129 + height: 640, 130 + }; 131 + } 132 + return { 133 + width: 1500, 134 + height: 500, 135 + }; 136 + }; 137 + 138 + const compressProfileImage = async ( 139 + sourceBuffer: Buffer, 140 + sourceMimeType: 'image/jpeg' | 'image/png', 141 + kind: ProfileImageKind, 142 + ): Promise<ProcessedProfileImage> => { 143 + const preset = getProfileImagePreset(kind); 144 + const metadata = await sharp(sourceBuffer, { failOn: 'none' }).metadata(); 145 + const hasAlpha = Boolean(metadata.hasAlpha); 146 + const scales = [1, 0.92, 0.85, 0.78, 0.7, 0.62, 0.54, 0.46]; 147 + const jpegQualities = [92, 88, 84, 80, 76, 72, 68, 64]; 148 + const basePng = sourceMimeType === 'image/png' && hasAlpha; 149 + 150 + let best: ProcessedProfileImage | null = null; 151 + 152 + for (let i = 0; i < scales.length; i += 1) { 153 + const scale = scales[i] || 1; 154 + const jpegQuality = jpegQualities[i] || 70; 155 + const width = Math.max(kind === 'avatar' ? 256 : 800, Math.round(preset.width * scale)); 156 + const height = Math.max(kind === 'avatar' ? 256 : 260, Math.round(preset.height * scale)); 157 + 158 + const resized = sharp(sourceBuffer, { failOn: 'none' }).rotate().resize(width, height, { 159 + fit: 'cover', 160 + position: 'centre', 161 + withoutEnlargement: false, 162 + }); 163 + 164 + const pngBuffer = basePng 165 + ? await resized 166 + .clone() 167 + .png({ 168 + compressionLevel: 9, 169 + adaptiveFiltering: true, 170 + palette: true, 171 + quality: 90, 172 + }) 173 + .toBuffer() 174 + : null; 175 + 176 + if (pngBuffer) { 177 + if (pngBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) { 178 + return { 179 + buffer: pngBuffer, 180 + mimeType: 'image/png', 181 + }; 182 + } 183 + if (pngBuffer.length <= PROFILE_IMAGE_MAX_BYTES) { 184 + if (!best || pngBuffer.length < best.buffer.length) { 185 + best = { 186 + buffer: pngBuffer, 187 + mimeType: 'image/png', 188 + }; 189 + } 190 + } 191 + } 192 + 193 + const jpegBuffer = await resized 194 + .clone() 195 + .flatten({ background: '#ffffff' }) 196 + .jpeg({ quality: jpegQuality, mozjpeg: true }) 197 + .toBuffer(); 198 + 199 + if (jpegBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) { 200 + return { 201 + buffer: jpegBuffer, 202 + mimeType: 'image/jpeg', 203 + }; 204 + } 205 + 206 + if (jpegBuffer.length <= PROFILE_IMAGE_MAX_BYTES) { 207 + if (!best || jpegBuffer.length < best.buffer.length) { 208 + best = { 209 + buffer: jpegBuffer, 210 + mimeType: 'image/jpeg', 211 + }; 212 + } 213 + } 214 + } 215 + 216 + if (best) { 217 + return best; 218 + } 219 + 220 + throw new Error('Could not compress image under Bluesky profile limit (1MB).'); 221 + }; 222 + 223 + const buildTwitterCookieSets = (): TwitterCookieSet[] => { 224 + const config = getConfig(); 225 + const sets: TwitterCookieSet[] = []; 226 + 227 + if (config.twitter.authToken && config.twitter.ct0) { 228 + sets.push({ 229 + label: 'primary', 230 + authToken: config.twitter.authToken, 231 + ct0: config.twitter.ct0, 232 + }); 233 + } 234 + 235 + if (config.twitter.backupAuthToken && config.twitter.backupCt0) { 236 + sets.push({ 237 + label: 'backup', 238 + authToken: config.twitter.backupAuthToken, 239 + ct0: config.twitter.backupCt0, 240 + }); 241 + } 242 + 243 + return sets; 244 + }; 245 + 246 + const fetchTwitterProfileWithCookies = async ( 247 + username: string, 248 + cookieSet: TwitterCookieSet, 249 + ): Promise<TwitterProfile> => { 250 + const scraper = new Scraper(); 251 + await scraper.setCookies([`auth_token=${cookieSet.authToken}`, `ct0=${cookieSet.ct0}`]); 252 + return scraper.getProfile(username); 253 + }; 254 + 255 + export const buildMirroredDisplayName = (name: string | undefined, username: string): string => { 256 + const baseName = normalizeWhitespace(name || '') || `@${normalizeTwitterUsername(username)}`; 257 + const lowerSuffix = MIRROR_SUFFIX.toLowerCase(); 258 + const merged = baseName.toLowerCase().endsWith(lowerSuffix) ? baseName : `${baseName} ${MIRROR_SUFFIX}`; 259 + return truncateGraphemes(merged, 64); 260 + }; 261 + 262 + export const buildMirroredDescription = (biography: string | undefined, username: string): string => { 263 + const normalizedUsername = normalizeTwitterUsername(username); 264 + const intro = `Unofficial mirror account of https://x.com/${normalizedUsername} from Twitter`; 265 + const bio = normalizeWhitespace(biography || ''); 266 + if (!bio) { 267 + return truncateGraphemes(intro, 256); 268 + } 269 + 270 + const full = `${intro}\n\n"${bio}"`; 271 + if (getGraphemeSegments(full).length <= 256) { 272 + return full; 273 + } 274 + 275 + const reserved = getGraphemeSegments(`${intro}\n\n""`).length; 276 + const maxBioLength = Math.max(0, 256 - reserved); 277 + const truncatedBio = truncateGraphemes(bio, maxBioLength); 278 + return `${intro}\n\n"${truncatedBio}"`; 279 + }; 280 + 281 + export const fetchTwitterMirrorProfile = async (inputUsername: string): Promise<TwitterMirrorProfile> => { 282 + const username = normalizeTwitterUsername(inputUsername); 283 + if (!username) { 284 + throw new Error('Twitter username is required.'); 285 + } 286 + 287 + const cookieSets = buildTwitterCookieSets(); 288 + if (cookieSets.length === 0) { 289 + throw new Error('Twitter cookies are not configured. Save auth_token and ct0 in settings first.'); 290 + } 291 + 292 + let lastError: unknown; 293 + for (const cookieSet of cookieSets) { 294 + try { 295 + const profile = await fetchTwitterProfileWithCookies(username, cookieSet); 296 + const resolvedUsername = normalizeTwitterUsername(profile.username || username); 297 + const cleanedName = normalizeOptionalString(profile.name); 298 + const cleanedBio = normalizeOptionalString(profile.biography); 299 + 300 + return { 301 + username: resolvedUsername, 302 + profileUrl: `https://x.com/${resolvedUsername}`, 303 + name: cleanedName, 304 + biography: cleanedBio, 305 + avatarUrl: normalizeTwitterAvatarUrl(profile.avatar), 306 + bannerUrl: normalizeTwitterBannerUrl(profile.banner), 307 + mirroredDisplayName: buildMirroredDisplayName(cleanedName, resolvedUsername), 308 + mirroredDescription: buildMirroredDescription(cleanedBio, resolvedUsername), 309 + }; 310 + } catch (error) { 311 + lastError = error; 312 + } 313 + } 314 + 315 + if (lastError instanceof Error && lastError.message) { 316 + throw new Error(`Failed to fetch Twitter profile: ${lastError.message}`); 317 + } 318 + throw new Error('Failed to fetch Twitter profile.'); 319 + }; 320 + 321 + export const validateBlueskyCredentials = async (args: { 322 + bskyIdentifier: string; 323 + bskyPassword: string; 324 + bskyServiceUrl?: string; 325 + }): Promise<BlueskyCredentialValidation> => { 326 + const identifier = normalizeOptionalString(args.bskyIdentifier); 327 + const password = normalizeOptionalString(args.bskyPassword); 328 + if (!identifier || !password) { 329 + throw new Error('Bluesky identifier and app password are required.'); 330 + } 331 + 332 + const serviceUrl = normalizeBskyServiceUrl(args.bskyServiceUrl); 333 + const agent = new BskyAgent({ service: serviceUrl }); 334 + await agent.login({ identifier, password }); 335 + 336 + const sessionResponse = await agent.com.atproto.server.getSession(); 337 + const session = sessionResponse.data; 338 + return { 339 + did: session.did, 340 + handle: session.handle, 341 + email: session.email, 342 + emailConfirmed: Boolean(session.emailConfirmed), 343 + serviceUrl, 344 + settingsUrl: BSKY_SETTINGS_URL, 345 + }; 346 + }; 347 + 348 + const uploadProfileImage = async (agent: BskyAgent, url: string, kind: ProfileImageKind): Promise<BlobRef> => { 349 + const response = await axios.get<ArrayBuffer>(url, { 350 + responseType: 'arraybuffer', 351 + timeout: 20_000, 352 + maxContentLength: 10 * 1024 * 1024, 353 + }); 354 + 355 + const mimeType = detectImageMimeType(response.headers?.['content-type'], url); 356 + const sourceBuffer = Buffer.from(response.data); 357 + const processed = await compressProfileImage(sourceBuffer, mimeType, kind); 358 + const { data } = await agent.uploadBlob(processed.buffer, { 359 + encoding: processed.mimeType, 360 + }); 361 + return data.blob; 362 + }; 363 + 364 + export const syncBlueskyProfileFromTwitter = async (args: { 365 + twitterUsername: string; 366 + bskyIdentifier: string; 367 + bskyPassword: string; 368 + bskyServiceUrl?: string; 369 + }): Promise<MirrorProfileSyncResult> => { 370 + const twitterProfile = await fetchTwitterMirrorProfile(args.twitterUsername); 371 + const bsky = await validateBlueskyCredentials({ 372 + bskyIdentifier: args.bskyIdentifier, 373 + bskyPassword: args.bskyPassword, 374 + bskyServiceUrl: args.bskyServiceUrl, 375 + }); 376 + 377 + const agent = new BskyAgent({ service: bsky.serviceUrl }); 378 + await agent.login({ 379 + identifier: args.bskyIdentifier, 380 + password: args.bskyPassword, 381 + }); 382 + 383 + const warnings: string[] = []; 384 + let avatarBlob: BlobRef | undefined; 385 + let bannerBlob: BlobRef | undefined; 386 + 387 + if (twitterProfile.avatarUrl) { 388 + try { 389 + avatarBlob = await uploadProfileImage(agent, twitterProfile.avatarUrl, 'avatar'); 390 + } catch (error) { 391 + warnings.push(`Avatar sync failed: ${error instanceof Error ? error.message : String(error)}`); 392 + } 393 + } else { 394 + warnings.push('No Twitter avatar found for this profile.'); 395 + } 396 + 397 + if (twitterProfile.bannerUrl) { 398 + try { 399 + bannerBlob = await uploadProfileImage(agent, twitterProfile.bannerUrl, 'banner'); 400 + } catch (error) { 401 + warnings.push(`Banner sync failed: ${error instanceof Error ? error.message : String(error)}`); 402 + } 403 + } else { 404 + warnings.push('No Twitter banner found for this profile.'); 405 + } 406 + 407 + await agent.upsertProfile((existing) => ({ 408 + ...(existing || {}), 409 + displayName: twitterProfile.mirroredDisplayName, 410 + description: twitterProfile.mirroredDescription, 411 + ...(avatarBlob ? { avatar: avatarBlob } : {}), 412 + ...(bannerBlob ? { banner: bannerBlob } : {}), 413 + })); 414 + 415 + return { 416 + twitterProfile, 417 + bsky, 418 + avatarSynced: Boolean(avatarBlob), 419 + bannerSynced: Boolean(bannerBlob), 420 + warnings, 421 + }; 422 + };
+127 -7
src/server.ts
··· 22 22 } from './config-manager.js'; 23 23 import { dbService } from './db.js'; 24 24 import type { ProcessedTweet } from './db.js'; 25 + import { 26 + fetchTwitterMirrorProfile, 27 + syncBlueskyProfileFromTwitter, 28 + validateBlueskyCredentials, 29 + } from './profile-mirror.js'; 25 30 26 31 const __filename = fileURLToPath(import.meta.url); 27 32 const __dirname = path.dirname(__filename); ··· 545 550 const params = new URLSearchParams(); 546 551 for (const uri of chunk) params.append('uris', uri); 547 552 548 - const responseData = await fetchAppview( 549 - '/xrpc/app.bsky.feed.getPosts', 550 - params, 551 - `getPosts chunk=${chunk.length}`, 552 - ); 553 + const responseData = await fetchAppview('/xrpc/app.bsky.feed.getPosts', params, `getPosts chunk=${chunk.length}`); 553 554 if (!responseData) { 554 555 continue; 555 556 } ··· 796 797 return normalized.length > 0 ? normalized : undefined; 797 798 }; 798 799 800 + const getErrorMessage = (error: unknown, fallback = 'Request failed.'): string => { 801 + if (axios.isAxiosError(error)) { 802 + const apiError = error.response?.data as { error?: unknown } | undefined; 803 + if (typeof apiError?.error === 'string' && apiError.error.length > 0) { 804 + return apiError.error; 805 + } 806 + if (typeof error.message === 'string' && error.message.length > 0) { 807 + return error.message; 808 + } 809 + } 810 + if (error instanceof Error && error.message.length > 0) { 811 + return error.message; 812 + } 813 + return fallback; 814 + }; 815 + 799 816 const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 800 817 if (typeof value === 'boolean') { 801 818 return value; ··· 811 828 const getUserDisplayLabel = (user: Pick<WebUser, 'id' | 'username' | 'email'>): string => 812 829 user.username || user.email || `user-${user.id.slice(0, 8)}`; 813 830 814 - const getActorLabel = (actor: AuthenticatedUser): string => actor.username || actor.email || `user-${actor.id.slice(0, 8)}`; 831 + const getActorLabel = (actor: AuthenticatedUser): string => 832 + actor.username || actor.email || `user-${actor.id.slice(0, 8)}`; 815 833 816 834 const getActorPublicLabel = (actor: AuthenticatedUser): string => actor.username || `user-${actor.id.slice(0, 8)}`; 817 835 ··· 1802 1820 res.json({ success: true, reassignedCount: reassigned }); 1803 1821 }); 1804 1822 1823 + app.post('/api/onboarding/twitter-profile', authenticateToken, async (req: any, res) => { 1824 + if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 1825 + res.status(403).json({ error: 'You do not have permission to create mappings.' }); 1826 + return; 1827 + } 1828 + 1829 + const twitterUsername = normalizeActor(req.body?.twitterUsername || ''); 1830 + if (!twitterUsername) { 1831 + res.status(400).json({ error: 'Twitter username is required.' }); 1832 + return; 1833 + } 1834 + 1835 + try { 1836 + const profile = await fetchTwitterMirrorProfile(twitterUsername); 1837 + res.json(profile); 1838 + } catch (error) { 1839 + res.status(400).json({ error: getErrorMessage(error, 'Failed to fetch Twitter profile metadata.') }); 1840 + } 1841 + }); 1842 + 1843 + app.post('/api/onboarding/bsky-credentials', authenticateToken, async (req: any, res) => { 1844 + if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 1845 + res.status(403).json({ error: 'You do not have permission to create mappings.' }); 1846 + return; 1847 + } 1848 + 1849 + const bskyIdentifier = normalizeOptionalString(req.body?.bskyIdentifier); 1850 + const bskyPassword = normalizeOptionalString(req.body?.bskyPassword); 1851 + const bskyServiceUrl = normalizeOptionalString(req.body?.bskyServiceUrl); 1852 + 1853 + if (!bskyIdentifier || !bskyPassword) { 1854 + res.status(400).json({ error: 'Bluesky identifier and app password are required.' }); 1855 + return; 1856 + } 1857 + 1858 + try { 1859 + const validation = await validateBlueskyCredentials({ 1860 + bskyIdentifier, 1861 + bskyPassword, 1862 + bskyServiceUrl, 1863 + }); 1864 + res.json(validation); 1865 + } catch (error) { 1866 + res.status(400).json({ error: getErrorMessage(error, 'Failed to validate Bluesky credentials.') }); 1867 + } 1868 + }); 1869 + 1805 1870 app.post('/api/mappings', authenticateToken, (req: any, res) => { 1806 1871 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 1807 1872 res.status(403).json({ error: 'You do not have permission to create mappings.' }); ··· 1839 1904 1840 1905 const ownerUser = usersById.get(createdByUserId); 1841 1906 const owner = 1842 - normalizeOptionalString(req.body?.owner) || (ownerUser ? getUserPublicLabel(ownerUser) : getActorPublicLabel(req.user)); 1907 + normalizeOptionalString(req.body?.owner) || 1908 + (ownerUser ? getUserPublicLabel(ownerUser) : getActorPublicLabel(req.user)); 1843 1909 const normalizedGroupName = normalizeGroupName(req.body?.groupName); 1844 1910 const normalizedGroupEmoji = normalizeGroupEmoji(req.body?.groupEmoji); 1845 1911 ··· 1947 2013 config.mappings[index] = updatedMapping; 1948 2014 saveConfig(config); 1949 2015 res.json(sanitizeMapping(updatedMapping, createUserLookupById(config), req.user)); 2016 + }); 2017 + 2018 + app.post('/api/mappings/:id/sync-profile-from-twitter', authenticateToken, async (req: any, res) => { 2019 + const { id } = req.params; 2020 + const config = getConfig(); 2021 + const mapping = config.mappings.find((entry) => entry.id === id); 2022 + 2023 + if (!mapping) { 2024 + res.status(404).json({ error: 'Mapping not found' }); 2025 + return; 2026 + } 2027 + 2028 + if (!canManageMapping(req.user, mapping)) { 2029 + res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 2030 + return; 2031 + } 2032 + 2033 + const requestedSource = normalizeActor(req.body?.sourceTwitterUsername || ''); 2034 + if ( 2035 + requestedSource && 2036 + !mapping.twitterUsernames.some((username) => normalizeActor(username) === normalizeActor(requestedSource)) 2037 + ) { 2038 + res.status(400).json({ error: 'Selected Twitter source is not part of this mapping.' }); 2039 + return; 2040 + } 2041 + 2042 + const sourceTwitterUsername = requestedSource || mapping.twitterUsernames[0]; 2043 + if (!sourceTwitterUsername) { 2044 + res.status(400).json({ error: 'Mapping has no Twitter source usernames.' }); 2045 + return; 2046 + } 2047 + 2048 + try { 2049 + const result = await syncBlueskyProfileFromTwitter({ 2050 + twitterUsername: sourceTwitterUsername, 2051 + bskyIdentifier: mapping.bskyIdentifier, 2052 + bskyPassword: mapping.bskyPassword, 2053 + bskyServiceUrl: mapping.bskyServiceUrl, 2054 + }); 2055 + 2056 + for (const key of [ 2057 + normalizeActor(mapping.bskyIdentifier), 2058 + normalizeActor(result.bsky.handle), 2059 + normalizeActor(result.bsky.did), 2060 + ]) { 2061 + if (key) { 2062 + profileCache.delete(key); 2063 + } 2064 + } 2065 + 2066 + res.json({ success: true, ...result }); 2067 + } catch (error) { 2068 + res.status(400).json({ error: getErrorMessage(error, 'Failed to sync Bluesky profile from Twitter.') }); 2069 + } 1950 2070 }); 1951 2071 1952 2072 app.delete('/api/mappings/:id', authenticateToken, (req: any, res) => {
+535 -95
web/src/App.tsx
··· 238 238 mappings: AccountMapping[]; 239 239 } 240 240 241 + interface TwitterMirrorProfile { 242 + username: string; 243 + profileUrl: string; 244 + name?: string; 245 + biography?: string; 246 + avatarUrl?: string; 247 + bannerUrl?: string; 248 + mirroredDisplayName: string; 249 + mirroredDescription: string; 250 + } 251 + 252 + interface BlueskyCredentialValidation { 253 + did: string; 254 + handle: string; 255 + email?: string; 256 + emailConfirmed: boolean; 257 + serviceUrl: string; 258 + settingsUrl: string; 259 + } 260 + 261 + interface MirrorProfileSyncResult { 262 + success: boolean; 263 + twitterProfile: TwitterMirrorProfile; 264 + bsky: BlueskyCredentialValidation; 265 + avatarSynced: boolean; 266 + bannerSynced: boolean; 267 + warnings: string[]; 268 + } 269 + 241 270 interface BootstrapStatus { 242 271 bootstrapOpen: boolean; 243 272 } ··· 322 351 activity: '/activity', 323 352 settings: '/settings', 324 353 }; 325 - const ADD_ACCOUNT_STEP_COUNT = 4; 326 - const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const; 354 + const ADD_ACCOUNT_STEPS = ['Sources', 'Create', 'Bluesky', 'Verify & Create'] as const; 355 + const ADD_ACCOUNT_STEP_COUNT = ADD_ACCOUNT_STEPS.length; 327 356 const ACCOUNT_SEARCH_MIN_SCORE = 22; 328 357 const DEFAULT_BACKFILL_LIMIT = 15; 329 358 const DEFAULT_USER_PERMISSIONS: UserPermissions = { ··· 701 730 const [newMapping, setNewMapping] = useState<MappingFormState>(defaultMappingForm); 702 731 const [newTwitterUsers, setNewTwitterUsers] = useState<string[]>([]); 703 732 const [newTwitterInput, setNewTwitterInput] = useState(''); 733 + const [newTwitterMirrorProfiles, setNewTwitterMirrorProfiles] = useState<Record<string, TwitterMirrorProfile>>({}); 734 + const [selectedMirrorSourceUsername, setSelectedMirrorSourceUsername] = useState(''); 735 + const [validatedBskyCredentials, setValidatedBskyCredentials] = useState<BlueskyCredentialValidation | null>(null); 736 + const [isMirrorPreviewLoading, setIsMirrorPreviewLoading] = useState(false); 737 + const [isCredentialValidationBusy, setIsCredentialValidationBusy] = useState(false); 738 + const [syncingProfileMappingId, setSyncingProfileMappingId] = useState<string | null>(null); 704 739 const [editForm, setEditForm] = useState<MappingFormState>(defaultMappingForm); 705 740 const [editTwitterUsers, setEditTwitterUsers] = useState<string[]>([]); 706 741 const [editTwitterInput, setEditTwitterInput] = useState(''); ··· 834 869 setRecentActivity([]); 835 870 setEditingMapping(null); 836 871 setNewTwitterUsers([]); 872 + setNewTwitterMirrorProfiles({}); 873 + setSelectedMirrorSourceUsername(''); 874 + setValidatedBskyCredentials(null); 875 + setIsMirrorPreviewLoading(false); 876 + setIsCredentialValidationBusy(false); 877 + setSyncingProfileMappingId(null); 837 878 setEditTwitterUsers([]); 838 879 setNewGroupName(''); 839 880 setNewGroupEmoji(DEFAULT_GROUP_EMOJI); ··· 1230 1271 }; 1231 1272 }, [isAddAccountSheetOpen]); 1232 1273 1274 + useEffect(() => { 1275 + if (newTwitterUsers.length === 0) { 1276 + setSelectedMirrorSourceUsername(''); 1277 + return; 1278 + } 1279 + 1280 + const normalizedSelected = normalizeTwitterUsername(selectedMirrorSourceUsername); 1281 + const hasSelected = newTwitterUsers.some((username) => normalizeTwitterUsername(username) === normalizedSelected); 1282 + if (!hasSelected) { 1283 + setSelectedMirrorSourceUsername(newTwitterUsers[0] || ''); 1284 + } 1285 + }, [newTwitterUsers, selectedMirrorSourceUsername]); 1286 + 1287 + useEffect(() => { 1288 + setValidatedBskyCredentials(null); 1289 + }, [newMapping.bskyIdentifier, newMapping.bskyPassword, newMapping.bskyServiceUrl]); 1290 + 1233 1291 const pendingBackfills = status?.pendingBackfills ?? []; 1234 1292 const currentStatus = status?.currentStatus; 1235 1293 const latestActivity = recentActivity[0]; 1294 + const selectedMirrorPreview = selectedMirrorSourceUsername 1295 + ? newTwitterMirrorProfiles[normalizeTwitterUsername(selectedMirrorSourceUsername)] 1296 + : undefined; 1236 1297 const dashboardTabs = useMemo( 1237 1298 () => [ 1238 1299 { id: 'overview' as DashboardTab, label: 'Overview', icon: LayoutDashboard }, ··· 1906 1967 } 1907 1968 }; 1908 1969 1970 + const resolveProfileSyncSource = (mapping: AccountMapping): string | null => { 1971 + const candidates = [ 1972 + ...new Set(mapping.twitterUsernames.map(normalizeTwitterUsername).filter((value) => value.length > 0)), 1973 + ]; 1974 + if (candidates.length === 0) { 1975 + showNotice('error', 'Mapping has no Twitter source usernames.'); 1976 + return null; 1977 + } 1978 + 1979 + if (candidates.length === 1) { 1980 + return candidates[0] || null; 1981 + } 1982 + 1983 + const typed = window.prompt( 1984 + `This mapping has multiple Twitter sources. Enter one to mirror from:\n${candidates 1985 + .map((username) => `@${username}`) 1986 + .join(', ')}`, 1987 + candidates[0], 1988 + ); 1989 + 1990 + if (typed === null) { 1991 + return null; 1992 + } 1993 + 1994 + const selected = normalizeTwitterUsername(typed); 1995 + if (!selected || !candidates.includes(selected)) { 1996 + showNotice('error', 'Please enter one of the mapped Twitter usernames.'); 1997 + return null; 1998 + } 1999 + 2000 + return selected; 2001 + }; 2002 + 2003 + const handleSyncProfileFromTwitter = async (mapping: AccountMapping) => { 2004 + if (!authHeaders) { 2005 + return; 2006 + } 2007 + if (!canManageMapping(mapping)) { 2008 + showNotice('error', 'You do not have permission to update this mapping.'); 2009 + return; 2010 + } 2011 + 2012 + const sourceTwitterUsername = resolveProfileSyncSource(mapping); 2013 + if (!sourceTwitterUsername) { 2014 + return; 2015 + } 2016 + 2017 + const confirmed = window.confirm( 2018 + `Sync profile from @${sourceTwitterUsername}? This replaces display name, bio, avatar, and banner and keeps the {UNOFFICIAL} suffix.`, 2019 + ); 2020 + if (!confirmed) { 2021 + return; 2022 + } 2023 + 2024 + setSyncingProfileMappingId(mapping.id); 2025 + try { 2026 + const response = await axios.post<MirrorProfileSyncResult>( 2027 + `/api/mappings/${mapping.id}/sync-profile-from-twitter`, 2028 + { sourceTwitterUsername }, 2029 + { headers: authHeaders }, 2030 + ); 2031 + 2032 + const warnings = response.data?.warnings || []; 2033 + if (warnings.length > 0) { 2034 + showNotice( 2035 + 'info', 2036 + `Profile synced from @${sourceTwitterUsername} with ${warnings.length} warning(s). First warning: ${warnings[0]}`, 2037 + ); 2038 + } else { 2039 + showNotice('success', `Profile synced from @${sourceTwitterUsername}.`); 2040 + } 2041 + 2042 + await fetchData(); 2043 + } catch (error) { 2044 + handleAuthFailure(error, 'Failed to sync profile from Twitter.'); 2045 + } finally { 2046 + setSyncingProfileMappingId((previous) => (previous === mapping.id ? null : previous)); 2047 + } 2048 + }; 2049 + 1909 2050 const addNewTwitterUsername = () => { 1910 2051 setNewTwitterUsers((previous) => addTwitterUsernames(previous, newTwitterInput)); 1911 2052 setNewTwitterInput(''); ··· 2115 2256 }); 2116 2257 setNewTwitterUsers([]); 2117 2258 setNewTwitterInput(''); 2259 + setNewTwitterMirrorProfiles({}); 2260 + setSelectedMirrorSourceUsername(''); 2261 + setValidatedBskyCredentials(null); 2262 + setIsMirrorPreviewLoading(false); 2263 + setIsCredentialValidationBusy(false); 2118 2264 setAddAccountStep(1); 2119 2265 }; 2120 2266 ··· 2144 2290 })); 2145 2291 }; 2146 2292 2293 + const ensureTwitterMirrorProfileLoaded = useCallback( 2294 + async (twitterUsername: string): Promise<TwitterMirrorProfile | null> => { 2295 + if (!authHeaders) { 2296 + return null; 2297 + } 2298 + 2299 + const normalized = normalizeTwitterUsername(twitterUsername); 2300 + if (!normalized) { 2301 + return null; 2302 + } 2303 + 2304 + const cached = newTwitterMirrorProfiles[normalized]; 2305 + if (cached) { 2306 + return cached; 2307 + } 2308 + 2309 + setIsMirrorPreviewLoading(true); 2310 + try { 2311 + const response = await axios.post<TwitterMirrorProfile>( 2312 + '/api/onboarding/twitter-profile', 2313 + { twitterUsername: normalized }, 2314 + { headers: authHeaders }, 2315 + ); 2316 + setNewTwitterMirrorProfiles((previous) => ({ 2317 + ...previous, 2318 + [normalized]: response.data, 2319 + })); 2320 + return response.data; 2321 + } catch (error) { 2322 + handleAuthFailure(error, 'Failed to fetch Twitter profile metadata.'); 2323 + return null; 2324 + } finally { 2325 + setIsMirrorPreviewLoading(false); 2326 + } 2327 + }, 2328 + [authHeaders, handleAuthFailure, newTwitterMirrorProfiles], 2329 + ); 2330 + 2331 + const validateAddAccountCredentials = useCallback(async (): Promise<BlueskyCredentialValidation | null> => { 2332 + if (!authHeaders) { 2333 + return null; 2334 + } 2335 + 2336 + const bskyIdentifier = newMapping.bskyIdentifier.trim(); 2337 + const bskyPassword = newMapping.bskyPassword.trim(); 2338 + const bskyServiceUrl = newMapping.bskyServiceUrl.trim(); 2339 + 2340 + if (!bskyIdentifier || !bskyPassword) { 2341 + showNotice('error', 'Bluesky identifier and app password are required.'); 2342 + return null; 2343 + } 2344 + 2345 + setIsCredentialValidationBusy(true); 2346 + try { 2347 + const response = await axios.post<BlueskyCredentialValidation>( 2348 + '/api/onboarding/bsky-credentials', 2349 + { 2350 + bskyIdentifier, 2351 + bskyPassword, 2352 + bskyServiceUrl, 2353 + }, 2354 + { headers: authHeaders }, 2355 + ); 2356 + setValidatedBskyCredentials(response.data); 2357 + return response.data; 2358 + } catch (error) { 2359 + setValidatedBskyCredentials(null); 2360 + handleAuthFailure(error, 'Failed to validate Bluesky credentials.'); 2361 + return null; 2362 + } finally { 2363 + setIsCredentialValidationBusy(false); 2364 + } 2365 + }, [ 2366 + authHeaders, 2367 + handleAuthFailure, 2368 + newMapping.bskyIdentifier, 2369 + newMapping.bskyPassword, 2370 + newMapping.bskyServiceUrl, 2371 + showNotice, 2372 + ]); 2373 + 2374 + useEffect(() => { 2375 + if (!isAddAccountSheetOpen || addAccountStep !== 1) { 2376 + return; 2377 + } 2378 + 2379 + if (!selectedMirrorSourceUsername) { 2380 + return; 2381 + } 2382 + 2383 + void ensureTwitterMirrorProfileLoaded(selectedMirrorSourceUsername); 2384 + }, [addAccountStep, ensureTwitterMirrorProfileLoaded, isAddAccountSheetOpen, selectedMirrorSourceUsername]); 2385 + 2147 2386 const submitNewMapping = async () => { 2148 2387 if (!authHeaders) { 2149 2388 return; ··· 2158 2397 return; 2159 2398 } 2160 2399 2400 + const sourceTwitterUsername = normalizeTwitterUsername(selectedMirrorSourceUsername || newTwitterUsers[0] || ''); 2401 + if (!sourceTwitterUsername) { 2402 + showNotice('error', 'Select a Twitter source for profile mirroring.'); 2403 + return; 2404 + } 2405 + 2406 + const mirrorPreview = await ensureTwitterMirrorProfileLoaded(sourceTwitterUsername); 2407 + if (!mirrorPreview) { 2408 + showNotice('error', 'Fetch Twitter metadata before creating this mapping.'); 2409 + return; 2410 + } 2411 + 2412 + if (!(validatedBskyCredentials || (await validateAddAccountCredentials()))) { 2413 + showNotice('error', 'Validate Bluesky credentials before creating this mapping.'); 2414 + return; 2415 + } 2416 + 2161 2417 setIsBusy(true); 2162 2418 2163 2419 try { 2164 - await axios.post( 2420 + const createResponse = await axios.post<AccountMapping>( 2165 2421 '/api/mappings', 2166 2422 { 2167 2423 owner: newMapping.owner.trim(), ··· 2175 2431 { headers: authHeaders }, 2176 2432 ); 2177 2433 2434 + const syncResponse = await axios.post<MirrorProfileSyncResult>( 2435 + `/api/mappings/${createResponse.data.id}/sync-profile-from-twitter`, 2436 + { 2437 + sourceTwitterUsername, 2438 + }, 2439 + { headers: authHeaders }, 2440 + ); 2441 + 2442 + const warnings = syncResponse.data.warnings || []; 2443 + 2178 2444 setNewMapping(defaultMappingForm()); 2179 2445 setNewTwitterUsers([]); 2180 2446 setNewTwitterInput(''); 2447 + setNewTwitterMirrorProfiles({}); 2448 + setSelectedMirrorSourceUsername(''); 2449 + setValidatedBskyCredentials(null); 2181 2450 setIsAddAccountSheetOpen(false); 2182 2451 setAddAccountStep(1); 2183 - showNotice('success', 'Account mapping added.'); 2452 + if (warnings.length > 0) { 2453 + showNotice('info', `Account mapping added. Profile mirrored with ${warnings.length} warning(s).`); 2454 + } else { 2455 + showNotice('success', 'Account mapping added and Bluesky profile mirrored from Twitter.'); 2456 + } 2184 2457 await fetchData(); 2185 2458 } catch (error) { 2186 2459 handleAuthFailure(error, 'Failed to add account mapping.'); ··· 2191 2464 2192 2465 const advanceAddAccountStep = () => { 2193 2466 if (addAccountStep === 1) { 2194 - if (!newMapping.owner.trim()) { 2195 - showNotice('error', 'Owner is required.'); 2467 + if (newTwitterUsers.length === 0) { 2468 + showNotice('error', 'Add at least one Twitter username.'); 2196 2469 return; 2197 2470 } 2198 - setAddAccountStep(2); 2471 + 2472 + const sourceTwitterUsername = normalizeTwitterUsername(selectedMirrorSourceUsername || newTwitterUsers[0] || ''); 2473 + if (!sourceTwitterUsername) { 2474 + showNotice('error', 'Select a Twitter source for profile mirroring.'); 2475 + return; 2476 + } 2477 + 2478 + setIsMirrorPreviewLoading(true); 2479 + void ensureTwitterMirrorProfileLoaded(sourceTwitterUsername) 2480 + .then((profile) => { 2481 + if (!profile) { 2482 + return; 2483 + } 2484 + setAddAccountStep(2); 2485 + }) 2486 + .finally(() => { 2487 + setIsMirrorPreviewLoading(false); 2488 + }); 2199 2489 return; 2200 2490 } 2201 2491 2202 2492 if (addAccountStep === 2) { 2203 - if (newTwitterUsers.length === 0) { 2204 - showNotice('error', 'Add at least one Twitter username.'); 2205 - return; 2206 - } 2207 2493 setAddAccountStep(3); 2208 2494 return; 2209 2495 } ··· 2213 2499 showNotice('error', 'Bluesky identifier and app password are required.'); 2214 2500 return; 2215 2501 } 2216 - setAddAccountStep(4); 2502 + 2503 + setIsCredentialValidationBusy(true); 2504 + void validateAddAccountCredentials() 2505 + .then((validation) => { 2506 + if (!validation) { 2507 + return; 2508 + } 2509 + setAddAccountStep(4); 2510 + }) 2511 + .finally(() => { 2512 + setIsCredentialValidationBusy(false); 2513 + }); 2217 2514 } 2218 2515 }; 2219 2516 ··· 2577 2874 } 2578 2875 2579 2876 if (!emailForm.newEmail.trim() || !emailForm.password || (hasCurrentEmail && !emailForm.currentEmail.trim())) { 2580 - showNotice('error', hasCurrentEmail ? 'Fill in current email, new email, and password.' : 'Fill in new email and password.'); 2877 + showNotice( 2878 + 'error', 2879 + hasCurrentEmail ? 'Fill in current email, new email, and password.' : 'Fill in new email and password.', 2880 + ); 2581 2881 return; 2582 2882 } 2583 2883 ··· 3136 3436 const profileHandle = profile?.handle || mapping.bskyIdentifier; 3137 3437 const profileName = profile?.displayName || profileHandle; 3138 3438 const mappingGroup = getMappingGroupMeta(mapping); 3439 + const syncingProfile = syncingProfileMappingId === mapping.id; 3139 3440 3140 3441 return ( 3141 3442 <tr ··· 3230 3531 onClick={() => startEditMapping(mapping)} 3231 3532 > 3232 3533 Edit 3534 + </Button> 3535 + <Button 3536 + variant="outline" 3537 + size="sm" 3538 + disabled={Boolean(syncingProfileMappingId)} 3539 + onClick={() => { 3540 + void handleSyncProfileFromTwitter(mapping); 3541 + }} 3542 + > 3543 + {syncingProfile ? ( 3544 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 3545 + ) : ( 3546 + <RefreshCw className="mr-1 h-4 w-4" /> 3547 + )} 3548 + Sync Profile 3233 3549 </Button> 3234 3550 {canQueueBackfillsPermission ? ( 3235 3551 <> ··· 4681 4997 {addAccountStep === 1 ? ( 4682 4998 <div className="space-y-4 animate-fade-in"> 4683 4999 <div className="space-y-1"> 4684 - <p className="text-sm font-semibold">Who owns this mapping?</p> 4685 - <p className="text-xs text-muted-foreground">Set a label so account rows stay easy to scan.</p> 4686 - </div> 4687 - <div className="space-y-2"> 4688 - <Label htmlFor="add-account-owner">Owner</Label> 4689 - <Input 4690 - id="add-account-owner" 4691 - value={newMapping.owner} 4692 - onChange={(event) => { 4693 - setNewMapping((previous) => ({ ...previous, owner: event.target.value })); 4694 - }} 4695 - placeholder="jack" 4696 - /> 4697 - </div> 4698 - <div className="space-y-2"> 4699 - <Label>Use Existing Folder (Optional)</Label> 4700 - {reusableGroupOptions.length === 0 ? ( 4701 - <p className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground"> 4702 - No folders yet. Create one below or from the Accounts tab. 4703 - </p> 4704 - ) : ( 4705 - <div className="flex flex-wrap gap-2"> 4706 - {reusableGroupOptions.map((group) => { 4707 - const selected = getGroupKey(newMapping.groupName) === group.key; 4708 - return ( 4709 - <button 4710 - key={`preset-group-${group.key}`} 4711 - className={cn( 4712 - 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs transition-colors', 4713 - selected 4714 - ? 'border-foreground bg-foreground text-background' 4715 - : 'border-border bg-background text-foreground hover:bg-muted', 4716 - )} 4717 - onClick={() => applyGroupPresetToNewMapping(group.key)} 4718 - type="button" 4719 - > 4720 - <span>{group.emoji}</span> 4721 - <span>{group.name}</span> 4722 - </button> 4723 - ); 4724 - })} 4725 - </div> 4726 - )} 4727 - </div> 4728 - <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 4729 - <div className="space-y-2"> 4730 - <Label htmlFor="add-account-group-name">Folder / Group Name (Optional)</Label> 4731 - <Input 4732 - id="add-account-group-name" 4733 - value={newMapping.groupName} 4734 - onChange={(event) => { 4735 - setNewMapping((previous) => ({ ...previous, groupName: event.target.value })); 4736 - }} 4737 - placeholder="Gaming, News, Sports..." 4738 - /> 4739 - </div> 4740 - <div className="space-y-2"> 4741 - <Label htmlFor="add-account-group-emoji">Emoji</Label> 4742 - <Input 4743 - id="add-account-group-emoji" 4744 - value={newMapping.groupEmoji} 4745 - onChange={(event) => { 4746 - setNewMapping((previous) => ({ ...previous, groupEmoji: event.target.value })); 4747 - }} 4748 - maxLength={8} 4749 - placeholder={DEFAULT_GROUP_EMOJI} 4750 - /> 4751 - </div> 4752 - </div> 4753 - </div> 4754 - ) : null} 4755 - 4756 - {addAccountStep === 2 ? ( 4757 - <div className="space-y-4 animate-fade-in"> 4758 - <div className="space-y-1"> 4759 - <p className="text-sm font-semibold">Choose Twitter sources</p> 5000 + <p className="text-sm font-semibold">Add Twitter source account(s)</p> 4760 5001 <p className="text-xs text-muted-foreground"> 4761 - Add one or many usernames. Press Enter or comma to add quickly. 5002 + Enter one or more usernames. We will preview metadata from your selected source. 4762 5003 </p> 4763 5004 </div> 4764 5005 <div className="space-y-2"> ··· 4807 5048 )) 4808 5049 )} 4809 5050 </div> 5051 + <div className="space-y-2"> 5052 + <Label htmlFor="add-account-source-select">Profile Mirror Source</Label> 5053 + <select 5054 + id="add-account-source-select" 5055 + className={selectClassName} 5056 + value={selectedMirrorSourceUsername} 5057 + onChange={(event) => { 5058 + setSelectedMirrorSourceUsername(event.target.value); 5059 + }} 5060 + > 5061 + {newTwitterUsers.map((username) => ( 5062 + <option key={`source-${username}`} value={username}> 5063 + @{username} 5064 + </option> 5065 + ))} 5066 + </select> 5067 + </div> 5068 + <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 5069 + <div className="flex items-center justify-between gap-2"> 5070 + <p className="font-medium">Twitter metadata preview</p> 5071 + <Button 5072 + variant="outline" 5073 + size="sm" 5074 + type="button" 5075 + disabled={!selectedMirrorSourceUsername || isMirrorPreviewLoading} 5076 + onClick={() => { 5077 + if (!selectedMirrorSourceUsername) return; 5078 + void ensureTwitterMirrorProfileLoaded(selectedMirrorSourceUsername); 5079 + }} 5080 + > 5081 + {isMirrorPreviewLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} 5082 + Refresh preview 5083 + </Button> 5084 + </div> 5085 + {selectedMirrorPreview ? ( 5086 + <> 5087 + <p> 5088 + <span className="font-medium">Display name:</span> {selectedMirrorPreview.mirroredDisplayName} 5089 + </p> 5090 + <p className="whitespace-pre-wrap text-xs text-muted-foreground"> 5091 + {selectedMirrorPreview.mirroredDescription} 5092 + </p> 5093 + </> 5094 + ) : ( 5095 + <p className="text-xs text-muted-foreground"> 5096 + Add a username and fetch preview to confirm the mirrored profile text. 5097 + </p> 5098 + )} 5099 + </div> 5100 + </div> 5101 + ) : null} 5102 + 5103 + {addAccountStep === 2 ? ( 5104 + <div className="space-y-4 animate-fade-in"> 5105 + <div className="space-y-1"> 5106 + <p className="text-sm font-semibold">Create Bluesky account (or use existing)</p> 5107 + <p className="text-xs text-muted-foreground"> 5108 + Open Bluesky in a new tab, create account if needed, then generate an app password. 5109 + </p> 5110 + </div> 5111 + <div className="grid gap-2 sm:grid-cols-2"> 5112 + <Button asChild variant="outline"> 5113 + <a href="https://bsky.app" target="_blank" rel="noreferrer"> 5114 + Create account 5115 + <ArrowUpRight className="ml-2 h-4 w-4" /> 5116 + </a> 5117 + </Button> 5118 + <Button asChild variant="outline"> 5119 + <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noreferrer"> 5120 + I have an account 5121 + <ArrowUpRight className="ml-2 h-4 w-4" /> 5122 + </a> 5123 + </Button> 5124 + </div> 5125 + <p className="rounded-lg border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> 5126 + Keep this drawer open, finish account setup in Bluesky, then continue to enter credentials. 5127 + </p> 4810 5128 </div> 4811 5129 ) : null} 4812 5130 4813 5131 {addAccountStep === 3 ? ( 4814 5132 <div className="space-y-4 animate-fade-in"> 4815 5133 <div className="space-y-1"> 4816 - <p className="text-sm font-semibold">Target Bluesky account</p> 4817 - <p className="text-xs text-muted-foreground">Use an app password for the destination account.</p> 5134 + <p className="text-sm font-semibold">Enter Bluesky credentials</p> 5135 + <p className="text-xs text-muted-foreground">Support includes custom PDS/service URLs.</p> 4818 5136 </div> 4819 5137 <div className="space-y-2"> 4820 5138 <Label htmlFor="add-account-bsky-identifier">Bluesky Identifier</Label> ··· 4849 5167 placeholder="https://bsky.social" 4850 5168 /> 4851 5169 </div> 5170 + <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 5171 + <div className="flex items-center justify-between gap-2"> 5172 + <p className="font-medium">Credential check</p> 5173 + <Button 5174 + variant="outline" 5175 + size="sm" 5176 + type="button" 5177 + disabled={isCredentialValidationBusy} 5178 + onClick={() => { 5179 + void validateAddAccountCredentials(); 5180 + }} 5181 + > 5182 + {isCredentialValidationBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} 5183 + Validate 5184 + </Button> 5185 + </div> 5186 + {validatedBskyCredentials ? ( 5187 + <p className="text-xs text-muted-foreground"> 5188 + Authenticated as @{validatedBskyCredentials.handle} on {validatedBskyCredentials.serviceUrl}. 5189 + </p> 5190 + ) : ( 5191 + <p className="text-xs text-muted-foreground"> 5192 + Validate now, or use Next to validate automatically. 5193 + </p> 5194 + )} 5195 + </div> 4852 5196 </div> 4853 5197 ) : null} 4854 5198 4855 5199 {addAccountStep === 4 ? ( 4856 5200 <div className="space-y-4 animate-fade-in"> 4857 5201 <div className="space-y-1"> 4858 - <p className="text-sm font-semibold">Review and create</p> 4859 - <p className="text-xs text-muted-foreground">Confirm details before saving this mapping.</p> 5202 + <p className="text-sm font-semibold">Verify email and create mapping</p> 5203 + <p className="text-xs text-muted-foreground"> 5204 + Verify email in Bluesky, then create mapping to auto-sync name, bio, avatar, and banner. 5205 + </p> 5206 + </div> 5207 + <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 5208 + <p> 5209 + <span className="font-medium">Email status:</span>{' '} 5210 + {validatedBskyCredentials?.emailConfirmed ? 'confirmed' : 'not confirmed yet'} 5211 + </p> 5212 + <Button asChild variant="outline" size="sm"> 5213 + <a 5214 + href={validatedBskyCredentials?.settingsUrl || 'https://bsky.app/settings/account'} 5215 + target="_blank" 5216 + rel="noreferrer" 5217 + > 5218 + Open Bluesky account settings 5219 + <ArrowUpRight className="ml-2 h-4 w-4" /> 5220 + </a> 5221 + </Button> 5222 + </div> 5223 + <div className="space-y-2"> 5224 + <Label htmlFor="add-account-owner">Owner (Optional)</Label> 5225 + <Input 5226 + id="add-account-owner" 5227 + value={newMapping.owner} 5228 + onChange={(event) => { 5229 + setNewMapping((previous) => ({ ...previous, owner: event.target.value })); 5230 + }} 5231 + placeholder="jack" 5232 + /> 5233 + </div> 5234 + <div className="space-y-2"> 5235 + <Label>Use Existing Folder (Optional)</Label> 5236 + {reusableGroupOptions.length === 0 ? ( 5237 + <p className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground"> 5238 + No folders yet. Create one below or from the Accounts tab. 5239 + </p> 5240 + ) : ( 5241 + <div className="flex flex-wrap gap-2"> 5242 + {reusableGroupOptions.map((group) => { 5243 + const selected = getGroupKey(newMapping.groupName) === group.key; 5244 + return ( 5245 + <button 5246 + key={`preset-group-${group.key}`} 5247 + className={cn( 5248 + 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs transition-colors', 5249 + selected 5250 + ? 'border-foreground bg-foreground text-background' 5251 + : 'border-border bg-background text-foreground hover:bg-muted', 5252 + )} 5253 + onClick={() => applyGroupPresetToNewMapping(group.key)} 5254 + type="button" 5255 + > 5256 + <span>{group.emoji}</span> 5257 + <span>{group.name}</span> 5258 + </button> 5259 + ); 5260 + })} 5261 + </div> 5262 + )} 5263 + </div> 5264 + <div className="grid gap-3 sm:grid-cols-[1fr_auto]"> 5265 + <div className="space-y-2"> 5266 + <Label htmlFor="add-account-group-name">Folder / Group Name (Optional)</Label> 5267 + <Input 5268 + id="add-account-group-name" 5269 + value={newMapping.groupName} 5270 + onChange={(event) => { 5271 + setNewMapping((previous) => ({ ...previous, groupName: event.target.value })); 5272 + }} 5273 + placeholder="Gaming, News, Sports..." 5274 + /> 5275 + </div> 5276 + <div className="space-y-2"> 5277 + <Label htmlFor="add-account-group-emoji">Emoji</Label> 5278 + <Input 5279 + id="add-account-group-emoji" 5280 + value={newMapping.groupEmoji} 5281 + onChange={(event) => { 5282 + setNewMapping((previous) => ({ ...previous, groupEmoji: event.target.value })); 5283 + }} 5284 + maxLength={8} 5285 + placeholder={DEFAULT_GROUP_EMOJI} 5286 + /> 5287 + </div> 4860 5288 </div> 4861 5289 <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm"> 4862 5290 <p> ··· 4870 5298 <span className="font-medium">Bluesky Target:</span> {newMapping.bskyIdentifier || '--'} 4871 5299 </p> 4872 5300 <p> 5301 + <span className="font-medium">Mirror Source:</span>{' '} 5302 + {selectedMirrorSourceUsername ? `@${selectedMirrorSourceUsername}` : '--'} 5303 + </p> 5304 + {selectedMirrorPreview ? ( 5305 + <p className="whitespace-pre-wrap text-xs text-muted-foreground"> 5306 + {selectedMirrorPreview.mirroredDescription} 5307 + </p> 5308 + ) : null} 5309 + <p> 4873 5310 <span className="font-medium">Folder:</span>{' '} 4874 5311 {newMapping.groupName.trim() 4875 5312 ? `${newMapping.groupEmoji.trim() || DEFAULT_GROUP_EMOJI} ${newMapping.groupName.trim()}` ··· 4886 5323 Back 4887 5324 </Button> 4888 5325 {addAccountStep < ADD_ACCOUNT_STEP_COUNT ? ( 4889 - <Button onClick={advanceAddAccountStep}> 5326 + <Button 5327 + onClick={advanceAddAccountStep} 5328 + disabled={isBusy || isMirrorPreviewLoading || isCredentialValidationBusy} 5329 + > 4890 5330 Next 4891 5331 <ChevronRight className="ml-2 h-4 w-4" /> 4892 5332 </Button>