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.
11
fork

Configure Feed

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

Add bulk bot suffix action and optimize accounts table

jack 1c24bb4e 3d92a6bb

+333 -58
+65
src/profile-mirror.ts
··· 106 106 hasBotLabel: true; 107 107 } 108 108 109 + export interface EnsureDisplayNameBotSuffixResult { 110 + bsky: BlueskyCredentialValidation; 111 + updated: boolean; 112 + displayName: string; 113 + } 114 + 109 115 const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 110 116 111 117 const normalizeOptionalString = (value: unknown): string | undefined => { ··· 533 539 bsky: credentials, 534 540 updated: true, 535 541 hasBotLabel: true, 542 + }; 543 + }; 544 + 545 + export const ensureBlueskyDisplayNameBotSuffix = async (args: { 546 + bskyIdentifier: string; 547 + bskyPassword: string; 548 + bskyServiceUrl?: string; 549 + }): Promise<EnsureDisplayNameBotSuffixResult> => { 550 + const { agent, credentials } = await loginBlueskyAgent(args); 551 + const repo = agent.session?.did || credentials.did; 552 + if (!repo) { 553 + throw new Error('Missing Bluesky session DID.'); 554 + } 555 + 556 + let existingProfileRecord: Record<string, unknown> = { 557 + $type: 'app.bsky.actor.profile', 558 + }; 559 + 560 + try { 561 + const response = await agent.com.atproto.repo.getRecord({ 562 + repo, 563 + collection: 'app.bsky.actor.profile', 564 + rkey: 'self', 565 + }); 566 + if (isRecord(response.data?.value)) { 567 + existingProfileRecord = { ...response.data.value }; 568 + } 569 + } catch (error) { 570 + const message = error instanceof Error ? error.message : String(error); 571 + const looksLikeMissingRecord = /not found|record.*not.*found|could not locate/i.test(message); 572 + if (!looksLikeMissingRecord) { 573 + throw error; 574 + } 575 + } 576 + 577 + const currentDisplayName = normalizeOptionalString(existingProfileRecord.displayName); 578 + const nextDisplayName = buildMirroredDisplayName(currentDisplayName, credentials.handle); 579 + const currentNormalized = normalizeWhitespace(currentDisplayName || ''); 580 + const updated = currentNormalized !== nextDisplayName; 581 + 582 + if (updated) { 583 + const nextProfileRecord: Record<string, unknown> = { 584 + ...existingProfileRecord, 585 + $type: 'app.bsky.actor.profile', 586 + displayName: nextDisplayName, 587 + }; 588 + 589 + await agent.com.atproto.repo.putRecord({ 590 + repo, 591 + collection: 'app.bsky.actor.profile', 592 + rkey: 'self', 593 + record: nextProfileRecord, 594 + }); 595 + } 596 + 597 + return { 598 + bsky: credentials, 599 + updated, 600 + displayName: nextDisplayName, 536 601 }; 537 602 }; 538 603
+80
src/server.ts
··· 26 26 applyProfileMirrorSyncState, 27 27 bridgeBlueskyAccountToFediverse, 28 28 ensureBlueskyBotSelfLabel, 29 + ensureBlueskyDisplayNameBotSuffix, 29 30 fetchTwitterMirrorProfile, 30 31 syncBlueskyProfileFromTwitter, 31 32 validateBlueskyCredentials, ··· 2406 2407 total: targets.length, 2407 2408 labeled, 2408 2409 alreadyLabeled, 2410 + failed, 2411 + failedMappings, 2412 + mappings: targets.map((mapping) => sanitizeMapping(mapping, usersById, req.user)), 2413 + }); 2414 + }); 2415 + 2416 + app.post('/api/mappings/append-bot-name-all', authenticateToken, async (req: any, res) => { 2417 + if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2418 + res.status(403).json({ error: 'You do not have permission to update mappings.' }); 2419 + return; 2420 + } 2421 + 2422 + const config = getConfig(); 2423 + const usersById = createUserLookupById(config); 2424 + const manageableMappings = getVisibleMappings(config, req.user).filter((mapping) => canManageMapping(req.user, mapping)); 2425 + const requestedIds = parseMappingIds(req.body?.mappingIds); 2426 + const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; 2427 + const targets = requestedIdSet 2428 + ? manageableMappings.filter((mapping) => requestedIdSet.has(mapping.id)) 2429 + : manageableMappings; 2430 + 2431 + if (targets.length === 0) { 2432 + res.status(400).json({ error: 'No manageable mappings available for display-name update.' }); 2433 + return; 2434 + } 2435 + 2436 + let appended = 0; 2437 + let alreadyAppended = 0; 2438 + let failed = 0; 2439 + let changed = false; 2440 + const failedMappings: Array<{ id: string; bskyIdentifier: string; error: string }> = []; 2441 + 2442 + for (const mapping of targets) { 2443 + try { 2444 + const result = await ensureBlueskyDisplayNameBotSuffix({ 2445 + bskyIdentifier: mapping.bskyIdentifier, 2446 + bskyPassword: mapping.bskyPassword, 2447 + bskyServiceUrl: mapping.bskyServiceUrl, 2448 + }); 2449 + 2450 + if (result.updated) { 2451 + appended += 1; 2452 + } else { 2453 + alreadyAppended += 1; 2454 + } 2455 + 2456 + if (mapping.lastMirroredDisplayName !== result.displayName) { 2457 + mapping.lastMirroredDisplayName = result.displayName; 2458 + changed = true; 2459 + } 2460 + 2461 + for (const key of [ 2462 + normalizeActor(mapping.bskyIdentifier), 2463 + normalizeActor(result.bsky.handle), 2464 + normalizeActor(result.bsky.did), 2465 + ]) { 2466 + if (key) { 2467 + profileCache.delete(key); 2468 + } 2469 + } 2470 + } catch (error) { 2471 + failed += 1; 2472 + failedMappings.push({ 2473 + id: mapping.id, 2474 + bskyIdentifier: mapping.bskyIdentifier, 2475 + error: getErrorMessage(error, 'Failed to append display-name suffix.'), 2476 + }); 2477 + } 2478 + } 2479 + 2480 + if (changed) { 2481 + saveConfig(config); 2482 + } 2483 + 2484 + res.json({ 2485 + success: true, 2486 + total: targets.length, 2487 + appended, 2488 + alreadyAppended, 2409 2489 failed, 2410 2490 failedMappings, 2411 2491 mappings: targets.map((mapping) => sanitizeMapping(mapping, usersById, req.user)),
+188 -58
web/src/App.tsx
··· 44 44 type AuthView = 'login' | 'register'; 45 45 type DashboardTab = 'overview' | 'accounts' | 'posts' | 'activity' | 'settings'; 46 46 type SettingsSection = 'account' | 'users' | 'twitter' | 'ai' | 'data'; 47 + type BulkAccountsAction = 'sync_profiles' | 'bridge_all' | 'apply_bot_label' | 'append_bot_name'; 47 48 48 49 type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 49 50 ··· 306 307 mappings?: AccountMapping[]; 307 308 } 308 309 310 + interface BulkAppendBotNameAllResult { 311 + success: boolean; 312 + total: number; 313 + appended: number; 314 + alreadyAppended: number; 315 + failed: number; 316 + failedMappings?: Array<{ 317 + id: string; 318 + bskyIdentifier: string; 319 + error: string; 320 + }>; 321 + mappings?: AccountMapping[]; 322 + } 323 + 309 324 interface BootstrapStatus { 310 325 bootstrapOpen: boolean; 311 326 } ··· 395 410 const ADD_ACCOUNT_STEPS = ['Sources', 'Create', 'Bluesky', 'Verify & Create'] as const; 396 411 const ADD_ACCOUNT_STEP_COUNT = ADD_ACCOUNT_STEPS.length; 397 412 const ACCOUNT_SEARCH_MIN_SCORE = 22; 413 + const ACCOUNT_ROWS_BATCH_SIZE = 40; 398 414 const DEFAULT_BACKFILL_LIMIT = 15; 399 415 const FEDIVERSE_BRIDGE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000; 400 416 const DEFAULT_USER_PERMISSIONS: UserPermissions = { ··· 796 812 const [syncingProfileMappingId, setSyncingProfileMappingId] = useState<string | null>(null); 797 813 const [isSyncAllProfilesBusy, setIsSyncAllProfilesBusy] = useState(false); 798 814 const [isBotLabelAllBusy, setIsBotLabelAllBusy] = useState(false); 815 + const [isAppendBotNameAllBusy, setIsAppendBotNameAllBusy] = useState(false); 799 816 const [bridgingMappingId, setBridgingMappingId] = useState<string | null>(null); 800 817 const [isBridgeAllBusy, setIsBridgeAllBusy] = useState(false); 801 818 const [bridgeAllProgress, setBridgeAllProgress] = useState<{ ··· 828 845 }); 829 846 const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); 830 847 const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); 848 + const [accountsBulkAction, setAccountsBulkAction] = useState<BulkAccountsAction>('sync_profiles'); 849 + const [visibleRowsByGroupKey, setVisibleRowsByGroupKey] = useState<Record<string, number>>({}); 850 + const [showAccountAvatars, setShowAccountAvatars] = useState(false); 851 + const [showAccountBios, setShowAccountBios] = useState(false); 831 852 const [postsGroupFilter, setPostsGroupFilter] = useState('all'); 832 853 const [postsSearchQuery, setPostsSearchQuery] = useState(''); 833 854 const [localPostSearchResults, setLocalPostSearchResults] = useState<LocalPostSearchResult[]>([]); ··· 1610 1631 () => accountMappingsForView.filter((mapping) => mapping.hasBotLabel === true), 1611 1632 [accountMappingsForView], 1612 1633 ); 1634 + const isAnyBulkAccountsActionBusy = 1635 + isSyncAllProfilesBusy || 1636 + isBridgeAllBusy || 1637 + isBotLabelAllBusy || 1638 + isAppendBotNameAllBusy || 1639 + Boolean(syncingProfileMappingId) || 1640 + Boolean(bridgingMappingId); 1613 1641 const bridgedMappingsForView = useMemo( 1614 1642 () => 1615 1643 [...accountMappingsForView] ··· 1749 1777 () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), 1750 1778 [filteredGroupedMappings], 1751 1779 ); 1780 + useEffect(() => { 1781 + setVisibleRowsByGroupKey((previous) => { 1782 + const validGroupKeys = new Set(filteredGroupedMappings.map((group) => group.key)); 1783 + const next: Record<string, number> = {}; 1784 + 1785 + for (const [groupKey, count] of Object.entries(previous)) { 1786 + if (validGroupKeys.has(groupKey)) { 1787 + next[groupKey] = count; 1788 + } 1789 + } 1790 + 1791 + for (const group of filteredGroupedMappings) { 1792 + const defaultVisible = Math.min(group.mappings.length, ACCOUNT_ROWS_BATCH_SIZE); 1793 + const existing = next[group.key] || 0; 1794 + next[group.key] = Math.max(existing, defaultVisible); 1795 + } 1796 + 1797 + return next; 1798 + }); 1799 + }, [filteredGroupedMappings]); 1752 1800 const groupKeysForCollapse = useMemo(() => groupedMappings.map((group) => group.key), [groupedMappings]); 1753 1801 const allGroupsCollapsed = useMemo( 1754 1802 () => ··· 2410 2458 if (!authHeaders) { 2411 2459 return; 2412 2460 } 2413 - if (isBotLabelAllBusy || isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)) { 2461 + if (isAnyBulkAccountsActionBusy) { 2414 2462 showNotice('info', 'Profile or bridge actions are running. Please wait.'); 2415 2463 return; 2416 2464 } ··· 2451 2499 } 2452 2500 }; 2453 2501 2502 + const handleAppendBotNameToAllAccounts = async () => { 2503 + if (!authHeaders) { 2504 + return; 2505 + } 2506 + if (isAnyBulkAccountsActionBusy) { 2507 + showNotice('info', 'A bulk accounts action is already running. Please wait.'); 2508 + return; 2509 + } 2510 + 2511 + const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2512 + if (candidates.length === 0) { 2513 + showNotice('info', 'No accounts available for display-name suffix update.'); 2514 + return; 2515 + } 2516 + 2517 + const confirmed = window.confirm( 2518 + `Append {bot} to display names for ${candidates.length} account(s)? This only appends when missing.`, 2519 + ); 2520 + if (!confirmed) { 2521 + return; 2522 + } 2523 + 2524 + setIsAppendBotNameAllBusy(true); 2525 + try { 2526 + const response = await axios.post<BulkAppendBotNameAllResult>( 2527 + '/api/mappings/append-bot-name-all', 2528 + { mappingIds: candidates.map((mapping) => mapping.id) }, 2529 + { headers: authHeaders }, 2530 + ); 2531 + const result = response.data; 2532 + await fetchData(); 2533 + const firstFailure = result.failedMappings?.[0]; 2534 + showNotice( 2535 + result.failed > 0 ? 'info' : 'success', 2536 + `Display-name append complete: ${result.appended} updated, ${result.alreadyAppended} already set, ${result.failed} failed.${ 2537 + firstFailure ? ` First failure: ${firstFailure.bskyIdentifier} (${firstFailure.error})` : '' 2538 + }`, 2539 + ); 2540 + } catch (error) { 2541 + handleAuthFailure(error, 'Failed to append {bot} to account display names.'); 2542 + } finally { 2543 + setIsAppendBotNameAllBusy(false); 2544 + } 2545 + }; 2546 + 2547 + const handleApplyAllAccountsAction = async () => { 2548 + if (accountsBulkAction === 'sync_profiles') { 2549 + await handleSyncAllProfilesFromTwitter(); 2550 + return; 2551 + } 2552 + if (accountsBulkAction === 'bridge_all') { 2553 + await handleBridgeAllEligible(); 2554 + return; 2555 + } 2556 + if (accountsBulkAction === 'apply_bot_label') { 2557 + await handleAddBotLabelToAllAccounts(); 2558 + return; 2559 + } 2560 + await handleAppendBotNameToAllAccounts(); 2561 + }; 2562 + 2563 + const showMoreRowsForGroup = useCallback((groupKey: string) => { 2564 + setVisibleRowsByGroupKey((previous) => ({ 2565 + ...previous, 2566 + [groupKey]: (previous[groupKey] || ACCOUNT_ROWS_BATCH_SIZE) + ACCOUNT_ROWS_BATCH_SIZE, 2567 + })); 2568 + }, []); 2569 + 2454 2570 const handleUpdateProfileSyncSource = async (mapping: AccountMapping, selectedSource: string) => { 2455 2571 if (!authHeaders) { 2456 2572 return; ··· 3932 4048 Add account 3933 4049 </Button> 3934 4050 ) : null} 3935 - <Button 3936 - size="sm" 3937 - variant="outline" 3938 - disabled={ 3939 - isSyncAllProfilesBusy || isBridgeAllBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 3940 - } 3941 - onClick={() => { 3942 - void handleSyncAllProfilesFromTwitter(); 3943 - }} 4051 + <select 4052 + className={cn(selectClassName, 'h-9 w-[260px] px-2 py-1 text-xs')} 4053 + value={accountsBulkAction} 4054 + disabled={isAnyBulkAccountsActionBusy} 4055 + onChange={(event) => setAccountsBulkAction(event.target.value as BulkAccountsAction)} 3944 4056 > 3945 - {isSyncAllProfilesBusy ? ( 3946 - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3947 - ) : ( 3948 - <RefreshCw className="mr-2 h-4 w-4" /> 3949 - )} 3950 - Sync all 3951 - </Button> 4057 + <option value="sync_profiles">Apply profile sync to all accounts</option> 4058 + <option value="bridge_all">Apply fediverse bridge to eligible accounts</option> 4059 + <option value="apply_bot_label">Apply automated-account label to all accounts</option> 4060 + <option value="append_bot_name">Apply {'{bot}'} display-name suffix to all accounts</option> 4061 + </select> 3952 4062 <Button 3953 4063 size="sm" 3954 4064 variant="outline" 3955 - disabled={ 3956 - isBridgeAllBusy || isSyncAllProfilesBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 3957 - } 4065 + disabled={isAnyBulkAccountsActionBusy} 3958 4066 onClick={() => { 3959 - void handleBridgeAllEligible(); 4067 + void handleApplyAllAccountsAction(); 3960 4068 }} 3961 4069 > 3962 - {isBridgeAllBusy ? ( 4070 + {isAnyBulkAccountsActionBusy ? ( 3963 4071 <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3964 - ) : ( 4072 + ) : accountsBulkAction === 'bridge_all' ? ( 3965 4073 <Link2 className="mr-2 h-4 w-4" /> 3966 - )} 3967 - {isBridgeAllBusy && bridgeAllProgress 3968 - ? `Bridging ${bridgeAllProgress.completed}/${bridgeAllProgress.total}` 3969 - : 'Bridge all'} 3970 - </Button> 3971 - <Button 3972 - size="sm" 3973 - variant="outline" 3974 - disabled={ 3975 - isBotLabelAllBusy || isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId) 3976 - } 3977 - onClick={() => { 3978 - void handleAddBotLabelToAllAccounts(); 3979 - }} 3980 - > 3981 - {isBotLabelAllBusy ? ( 3982 - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 4074 + ) : accountsBulkAction === 'sync_profiles' ? ( 4075 + <RefreshCw className="mr-2 h-4 w-4" /> 3983 4076 ) : ( 3984 4077 <Bot className="mr-2 h-4 w-4" /> 3985 4078 )} 3986 - Add automated account label to all accounts 4079 + {accountsBulkAction === 'sync_profiles' 4080 + ? 'Apply sync all' 4081 + : accountsBulkAction === 'bridge_all' 4082 + ? 'Apply bridge all' 4083 + : accountsBulkAction === 'apply_bot_label' 4084 + ? 'Apply bot label all' 4085 + : 'Apply append {bot} all'} 3987 4086 </Button> 3988 4087 {isBridgeAllBusy && bridgeAllProgress ? ( 3989 4088 <Badge variant="outline" className="max-w-[280px] truncate"> ··· 4070 4169 ) : null} 4071 4170 </div> 4072 4171 <div className="flex flex-wrap items-end justify-end gap-2"> 4172 + <Button 4173 + size="sm" 4174 + variant="outline" 4175 + onClick={() => setShowAccountAvatars((previous) => !previous)} 4176 + > 4177 + {showAccountAvatars ? 'Hide avatars (faster)' : 'Show avatars'} 4178 + </Button> 4179 + <Button size="sm" variant="outline" onClick={() => setShowAccountBios((previous) => !previous)}> 4180 + {showAccountBios ? 'Hide bios (faster)' : 'Show bios'} 4181 + </Button> 4073 4182 {accountsViewMode === 'grouped' ? ( 4074 4183 <Button 4075 4184 size="sm" ··· 4140 4249 {filteredGroupedMappings.map((group, groupIndex) => { 4141 4250 const canCollapseGroup = accountsViewMode === 'grouped'; 4142 4251 const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false; 4252 + const visibleRows = visibleRowsByGroupKey[group.key] || ACCOUNT_ROWS_BATCH_SIZE; 4253 + const renderedMappings = group.mappings.slice(0, visibleRows); 4254 + const remainingMappingsCount = Math.max(0, group.mappings.length - renderedMappings.length); 4143 4255 4144 4256 return ( 4145 4257 <div ··· 4187 4299 No accounts in this folder yet. 4188 4300 </div> 4189 4301 ) : ( 4190 - <div className="overflow-x-auto"> 4191 - <table className="min-w-full text-left text-sm"> 4302 + <> 4303 + <div className="overflow-x-auto"> 4304 + <table className="min-w-full text-left text-sm"> 4192 4305 <thead className="border-b border-border text-xs uppercase tracking-wide text-muted-foreground"> 4193 4306 <tr> 4194 4307 <th className="px-2 py-3">Owner</th> ··· 4200 4313 </tr> 4201 4314 </thead> 4202 4315 <tbody> 4203 - {group.mappings.map((mapping) => { 4316 + {renderedMappings.map((mapping) => { 4204 4317 const queued = isBackfillQueued(mapping.id); 4205 4318 const active = isBackfillActive(mapping.id); 4206 4319 const queuePosition = getBackfillEntry(mapping.id)?.position; 4207 4320 const profile = getProfileForActor(mapping.bskyIdentifier); 4208 4321 const profileHandle = profile?.handle || mapping.bskyIdentifier; 4209 4322 const profileName = profile?.displayName || profileHandle; 4210 - const profileBio = profile?.description?.trim() || ''; 4323 + const profileBio = showAccountBios ? profile?.description?.trim() || '' : ''; 4211 4324 const profileUrl = `https://bsky.app/profile/${profileHandle}`; 4212 4325 const canManageThisMapping = canManageMapping(mapping); 4213 4326 const canUseFediverseBridge = canBridgeToFediverse(profile?.createdAt); ··· 4247 4360 </td> 4248 4361 <td className="px-2 py-3 align-top"> 4249 4362 <div className="flex items-center gap-2"> 4250 - {profile?.avatar ? ( 4363 + {showAccountAvatars && profile?.avatar ? ( 4251 4364 <img 4252 4365 className="h-8 w-8 rounded-full border border-border/70 object-cover" 4253 4366 src={profile.avatar} 4254 4367 alt={profileName} 4255 4368 loading="lazy" 4369 + decoding="async" 4370 + fetchPriority="low" 4256 4371 /> 4257 4372 ) : ( 4258 4373 <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border/70 bg-muted text-muted-foreground"> ··· 4321 4436 !canManageThisMapping || 4322 4437 !canManageGroupsPermission || 4323 4438 isBridgeAllBusy || 4324 - isBotLabelAllBusy || 4439 + isAnyBulkAccountsActionBusy || 4325 4440 isSyncAllProfilesBusy || 4326 4441 Boolean(syncingProfileMappingId) 4327 4442 } ··· 4351 4466 value={mapping.profileSyncSourceUsername || ''} 4352 4467 disabled={ 4353 4468 isBridgeAllBusy || 4354 - isBotLabelAllBusy || 4469 + isAnyBulkAccountsActionBusy || 4355 4470 isSyncAllProfilesBusy || 4356 4471 Boolean(syncingProfileMappingId) || 4357 4472 Boolean(bridgingMappingId) ··· 4375 4490 variant="outline" 4376 4491 size="sm" 4377 4492 disabled={ 4378 - isBridgeAllBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 4493 + isBridgeAllBusy || 4494 + isAnyBulkAccountsActionBusy || 4495 + Boolean(syncingProfileMappingId) 4379 4496 } 4380 4497 onClick={() => startEditMapping(mapping)} 4381 4498 > ··· 4386 4503 size="sm" 4387 4504 disabled={ 4388 4505 isBridgeAllBusy || 4389 - isBotLabelAllBusy || 4506 + isAnyBulkAccountsActionBusy || 4390 4507 Boolean(syncingProfileMappingId) || 4391 4508 isSyncAllProfilesBusy || 4392 4509 Boolean(bridgingMappingId) ··· 4408 4525 size="sm" 4409 4526 disabled={ 4410 4527 isBridgeAllBusy || 4411 - isBotLabelAllBusy || 4528 + isAnyBulkAccountsActionBusy || 4412 4529 Boolean(bridgingMappingId) || 4413 4530 Boolean(syncingProfileMappingId) || 4414 4531 isSyncAllProfilesBusy ··· 4432 4549 size="sm" 4433 4550 disabled={ 4434 4551 isBridgeAllBusy || 4435 - isBotLabelAllBusy || 4552 + isAnyBulkAccountsActionBusy || 4436 4553 Boolean(syncingProfileMappingId) || 4437 4554 isSyncAllProfilesBusy 4438 4555 } ··· 4448 4565 size="sm" 4449 4566 disabled={ 4450 4567 isBridgeAllBusy || 4451 - isBotLabelAllBusy || 4568 + isAnyBulkAccountsActionBusy || 4452 4569 Boolean(syncingProfileMappingId) || 4453 4570 isSyncAllProfilesBusy 4454 4571 } ··· 4465 4582 size="sm" 4466 4583 disabled={ 4467 4584 isBridgeAllBusy || 4468 - isBotLabelAllBusy || 4585 + isAnyBulkAccountsActionBusy || 4469 4586 Boolean(syncingProfileMappingId) || 4470 4587 isSyncAllProfilesBusy 4471 4588 } ··· 4484 4601 size="sm" 4485 4602 disabled={ 4486 4603 isBridgeAllBusy || 4487 - isBotLabelAllBusy || 4604 + isAnyBulkAccountsActionBusy || 4488 4605 Boolean(syncingProfileMappingId) || 4489 4606 isSyncAllProfilesBusy 4490 4607 } ··· 4503 4620 size="sm" 4504 4621 disabled={ 4505 4622 isBridgeAllBusy || 4506 - isBotLabelAllBusy || 4623 + isAnyBulkAccountsActionBusy || 4507 4624 Boolean(syncingProfileMappingId) || 4508 4625 isSyncAllProfilesBusy 4509 4626 } ··· 4521 4638 ); 4522 4639 })} 4523 4640 </tbody> 4524 - </table> 4525 - </div> 4641 + </table> 4642 + </div> 4643 + {remainingMappingsCount > 0 ? ( 4644 + <div className="border-t border-border/60 px-3 py-2"> 4645 + <Button 4646 + size="sm" 4647 + variant="outline" 4648 + onClick={() => showMoreRowsForGroup(group.key)} 4649 + > 4650 + Show {Math.min(ACCOUNT_ROWS_BATCH_SIZE, remainingMappingsCount)} more ( 4651 + {remainingMappingsCount} remaining) 4652 + </Button> 4653 + </div> 4654 + ) : null} 4655 + </> 4526 4656 )} 4527 4657 </div> 4528 4658 </div>