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 bio pull action and selected-account bulk targeting

jack 8898a871 1c24bb4e

+520 -23
+111 -4
src/profile-mirror.ts
··· 17 17 const FEDIVERSE_BRIDGE_HANDLE = 'ap.brid.gy'; 18 18 const MIN_BRIDGE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000; 19 19 const BOT_SELF_LABEL_VALUE = 'bot'; 20 + const TCO_LINK_REGEX = /https:\/\/t\.co\/[a-zA-Z0-9]+/gi; 21 + const TRACKING_QUERY_PARAM_PREFIXES = ['utm_']; 22 + const TRACKING_QUERY_PARAM_NAMES = new Set([ 23 + 'fbclid', 24 + 'gclid', 25 + 'dclid', 26 + 'yclid', 27 + 'mc_cid', 28 + 'mc_eid', 29 + 'mkt_tok', 30 + 'igshid', 31 + 'ref', 32 + 'ref_src', 33 + 'ref_url', 34 + 'source', 35 + 's', 36 + 'si', 37 + ]); 20 38 21 39 type ProfileImageKind = 'avatar' | 'banner'; 22 40 ··· 209 227 210 228 const normalizeWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim(); 211 229 230 + const shouldStripTrackingParam = (rawName: string): boolean => { 231 + const name = rawName.trim().toLowerCase(); 232 + if (!name) { 233 + return false; 234 + } 235 + 236 + if (TRACKING_QUERY_PARAM_NAMES.has(name)) { 237 + return true; 238 + } 239 + 240 + return TRACKING_QUERY_PARAM_PREFIXES.some((prefix) => name.startsWith(prefix)); 241 + }; 242 + 243 + const stripTrackingParamsFromUrl = (rawUrl: string): string => { 244 + try { 245 + const parsed = new URL(rawUrl); 246 + const names = [...parsed.searchParams.keys()]; 247 + for (const name of names) { 248 + if (shouldStripTrackingParam(name)) { 249 + parsed.searchParams.delete(name); 250 + } 251 + } 252 + return parsed.toString(); 253 + } catch { 254 + return rawUrl; 255 + } 256 + }; 257 + 258 + const resolveRedirectUrl = (response: unknown): string | undefined => { 259 + if (!isRecord(response)) { 260 + return undefined; 261 + } 262 + const request = isRecord(response.request) ? response.request : undefined; 263 + const res = request && isRecord(request.res) ? request.res : undefined; 264 + return normalizeOptionalString(res?.responseUrl); 265 + }; 266 + 267 + const expandShortUrl = async (shortUrl: string): Promise<string> => { 268 + try { 269 + const head = await axios.head(shortUrl, { 270 + maxRedirects: 8, 271 + timeout: 8_000, 272 + validateStatus: (status) => status >= 200 && status < 400, 273 + }); 274 + return resolveRedirectUrl(head) || shortUrl; 275 + } catch { 276 + try { 277 + const get = await axios.get(shortUrl, { 278 + maxRedirects: 8, 279 + timeout: 8_000, 280 + responseType: 'stream', 281 + validateStatus: (status) => status >= 200 && status < 400, 282 + }); 283 + try { 284 + get.data?.destroy?.(); 285 + } catch { 286 + // Ignore stream cleanup errors. 287 + } 288 + return resolveRedirectUrl(get) || shortUrl; 289 + } catch { 290 + return shortUrl; 291 + } 292 + } 293 + }; 294 + 295 + const expandAndNormalizeTwitterBioLinks = async (biography?: string): Promise<string | undefined> => { 296 + const bio = normalizeOptionalString(biography); 297 + if (!bio) { 298 + return undefined; 299 + } 300 + 301 + let expandedBio = bio; 302 + const matches = expandedBio.match(TCO_LINK_REGEX) || []; 303 + const uniqueMatches = [...new Set(matches)]; 304 + for (const tcoUrl of uniqueMatches) { 305 + const resolvedUrl = await expandShortUrl(tcoUrl); 306 + const normalizedUrl = stripTrackingParamsFromUrl(resolvedUrl); 307 + if (!normalizedUrl || normalizedUrl === tcoUrl) { 308 + continue; 309 + } 310 + expandedBio = expandedBio.split(tcoUrl).join(normalizedUrl); 311 + } 312 + 313 + return normalizeOptionalString(expandedBio); 314 + }; 315 + 212 316 const normalizeTwitterAvatarUrl = (url?: string): string | undefined => { 213 317 if (!url) return undefined; 214 318 return url.replace('_normal.', '_400x400.'); ··· 410 514 const profile = await fetchTwitterProfileWithCookies(username, cookieSet); 411 515 const resolvedUsername = normalizeTwitterUsername(profile.username || username); 412 516 const cleanedName = normalizeOptionalString(profile.name); 413 - const cleanedBio = normalizeOptionalString(profile.biography); 517 + const cleanedBio = await expandAndNormalizeTwitterBioLinks(profile.biography); 414 518 415 519 return { 416 520 username: resolvedUsername, ··· 732 836 bskyPassword: string; 733 837 bskyServiceUrl?: string; 734 838 previousSync?: ProfileMirrorSyncState; 839 + syncDisplayName?: boolean; 735 840 syncDescription?: boolean; 841 + syncAvatar?: boolean; 842 + syncBanner?: boolean; 736 843 }): Promise<MirrorProfileSyncResult> => { 737 844 const twitterProfile = await fetchTwitterMirrorProfile(args.twitterUsername); 738 845 const nextMirrorState = buildMirrorStateFromTwitterProfile(twitterProfile); 739 846 const rawChanged = hasMirrorStateChanges(args.previousSync, nextMirrorState); 740 847 const changed = { 741 - displayName: rawChanged.displayName, 848 + displayName: (args.syncDisplayName ?? true) ? rawChanged.displayName : false, 742 849 description: (args.syncDescription ?? true) ? rawChanged.description : false, 743 - avatar: rawChanged.avatar, 744 - banner: rawChanged.banner, 850 + avatar: (args.syncAvatar ?? true) ? rawChanged.avatar : false, 851 + banner: (args.syncBanner ?? true) ? rawChanged.banner : false, 745 852 }; 746 853 const bsky = await validateBlueskyCredentials({ 747 854 bskyIdentifier: args.bskyIdentifier,
+65
src/server.ts
··· 2334 2334 } 2335 2335 }); 2336 2336 2337 + app.post('/api/mappings/:id/pull-twitter-bio', authenticateToken, async (req: any, res) => { 2338 + const { id } = req.params; 2339 + const config = getConfig(); 2340 + const mappingIndex = config.mappings.findIndex((entry) => entry.id === id); 2341 + const mapping = config.mappings[mappingIndex]; 2342 + 2343 + if (mappingIndex === -1 || !mapping) { 2344 + res.status(404).json({ error: 'Mapping not found' }); 2345 + return; 2346 + } 2347 + 2348 + if (!canManageMapping(req.user, mapping)) { 2349 + res.status(403).json({ error: 'You do not have permission to update this mapping.' }); 2350 + return; 2351 + } 2352 + 2353 + const sourceTwitterUsername = resolveProfileSyncSourceUsername({ 2354 + twitterUsernames: mapping.twitterUsernames, 2355 + requestedSource: req.body?.sourceTwitterUsername, 2356 + fallbackSource: mapping.profileSyncSourceUsername, 2357 + }); 2358 + 2359 + if (!sourceTwitterUsername) { 2360 + res.status(400).json({ error: 'Mapping has no Twitter source usernames.' }); 2361 + return; 2362 + } 2363 + 2364 + try { 2365 + const result = await syncBlueskyProfileFromTwitter({ 2366 + twitterUsername: sourceTwitterUsername, 2367 + bskyIdentifier: mapping.bskyIdentifier, 2368 + bskyPassword: mapping.bskyPassword, 2369 + bskyServiceUrl: mapping.bskyServiceUrl, 2370 + previousSync: getMappingMirrorSyncState(mapping), 2371 + syncDisplayName: false, 2372 + syncDescription: true, 2373 + syncAvatar: false, 2374 + syncBanner: false, 2375 + }); 2376 + 2377 + const updatedMapping = applyProfileMirrorSyncState(mapping, sourceTwitterUsername, result); 2378 + config.mappings[mappingIndex] = updatedMapping; 2379 + saveConfig(config); 2380 + 2381 + for (const key of [ 2382 + normalizeActor(updatedMapping.bskyIdentifier), 2383 + normalizeActor(result.bsky.handle), 2384 + normalizeActor(result.bsky.did), 2385 + ]) { 2386 + if (key) { 2387 + profileCache.delete(key); 2388 + } 2389 + } 2390 + 2391 + res.json({ 2392 + success: true, 2393 + sourceTwitterUsername, 2394 + mapping: sanitizeMapping(updatedMapping, createUserLookupById(config), req.user), 2395 + ...result, 2396 + }); 2397 + } catch (error) { 2398 + res.status(400).json({ error: getErrorMessage(error, 'Failed to pull Twitter bio.') }); 2399 + } 2400 + }); 2401 + 2337 2402 app.post('/api/mappings/bot-label-all', authenticateToken, async (req: any, res) => { 2338 2403 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2339 2404 res.status(403).json({ error: 'You do not have permission to update mappings.' });
+344 -19
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 + type BulkAccountsAction = 48 + | 'sync_profiles' 49 + | 'pull_twitter_bio' 50 + | 'bridge_all' 51 + | 'apply_bot_label' 52 + | 'append_bot_name'; 48 53 49 54 type AppState = 'idle' | 'checking' | 'backfilling' | 'pacing' | 'processing'; 50 55 ··· 810 815 const [isMirrorPreviewLoading, setIsMirrorPreviewLoading] = useState(false); 811 816 const [isCredentialValidationBusy, setIsCredentialValidationBusy] = useState(false); 812 817 const [syncingProfileMappingId, setSyncingProfileMappingId] = useState<string | null>(null); 818 + const [pullingBioMappingId, setPullingBioMappingId] = useState<string | null>(null); 813 819 const [isSyncAllProfilesBusy, setIsSyncAllProfilesBusy] = useState(false); 820 + const [isPullBioAllBusy, setIsPullBioAllBusy] = useState(false); 814 821 const [isBotLabelAllBusy, setIsBotLabelAllBusy] = useState(false); 815 822 const [isAppendBotNameAllBusy, setIsAppendBotNameAllBusy] = useState(false); 816 823 const [bridgingMappingId, setBridgingMappingId] = useState<string | null>(null); ··· 846 853 const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); 847 854 const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); 848 855 const [accountsBulkAction, setAccountsBulkAction] = useState<BulkAccountsAction>('sync_profiles'); 856 + const [accountsBulkScope, setAccountsBulkScope] = useState<'all' | 'selected'>('all'); 857 + const [selectedAccountMappingIds, setSelectedAccountMappingIds] = useState<string[]>([]); 849 858 const [visibleRowsByGroupKey, setVisibleRowsByGroupKey] = useState<Record<string, number>>({}); 850 859 const [showAccountAvatars, setShowAccountAvatars] = useState(false); 851 860 const [showAccountBios, setShowAccountBios] = useState(false); ··· 1633 1642 ); 1634 1643 const isAnyBulkAccountsActionBusy = 1635 1644 isSyncAllProfilesBusy || 1645 + isPullBioAllBusy || 1636 1646 isBridgeAllBusy || 1637 1647 isBotLabelAllBusy || 1638 1648 isAppendBotNameAllBusy || 1639 1649 Boolean(syncingProfileMappingId) || 1650 + Boolean(pullingBioMappingId) || 1640 1651 Boolean(bridgingMappingId); 1641 1652 const bridgedMappingsForView = useMemo( 1642 1653 () => ··· 1855 1866 (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), 1856 1867 [canManageAllMappings, canManageOwnMappings, me?.id], 1857 1868 ); 1869 + const manageableAccountMappingsForView = useMemo( 1870 + () => accountMappingsForView.filter((mapping) => canManageMapping(mapping)), 1871 + [accountMappingsForView, canManageMapping], 1872 + ); 1873 + const manageableMappingIdsForView = useMemo( 1874 + () => new Set(manageableAccountMappingsForView.map((mapping) => mapping.id)), 1875 + [manageableAccountMappingsForView], 1876 + ); 1877 + const selectedAccountMappingIdSet = useMemo(() => new Set(selectedAccountMappingIds), [selectedAccountMappingIds]); 1878 + const selectedManageableMappingsForView = useMemo( 1879 + () => manageableAccountMappingsForView.filter((mapping) => selectedAccountMappingIdSet.has(mapping.id)), 1880 + [manageableAccountMappingsForView, selectedAccountMappingIdSet], 1881 + ); 1882 + const selectedManageableMappingsCount = selectedManageableMappingsForView.length; 1883 + const filteredManageableMappingIds = useMemo(() => { 1884 + const seen = new Set<string>(); 1885 + for (const group of filteredGroupedMappings) { 1886 + for (const mapping of group.mappings) { 1887 + if (seen.has(mapping.id) || !canManageMapping(mapping)) { 1888 + continue; 1889 + } 1890 + seen.add(mapping.id); 1891 + } 1892 + } 1893 + return [...seen]; 1894 + }, [canManageMapping, filteredGroupedMappings]); 1895 + const allFilteredManageableSelected = 1896 + filteredManageableMappingIds.length > 0 && 1897 + filteredManageableMappingIds.every((mappingId) => selectedAccountMappingIdSet.has(mappingId)); 1898 + useEffect(() => { 1899 + setSelectedAccountMappingIds((previous) => { 1900 + const next = previous.filter((mappingId) => manageableMappingIdsForView.has(mappingId)); 1901 + if (next.length === previous.length) { 1902 + return previous; 1903 + } 1904 + return next; 1905 + }); 1906 + }, [manageableMappingIdsForView]); 1858 1907 const twitterConfigured = Boolean(twitterConfig.authToken && twitterConfig.ct0); 1859 1908 const aiConfigured = Boolean(aiConfig.apiKey); 1860 1909 const sectionDefaultExpanded = useMemo<Record<SettingsSection, boolean>>( ··· 2400 2449 }); 2401 2450 }; 2402 2451 2403 - const handleSyncAllProfilesFromTwitter = async () => { 2452 + const pullTwitterBioForMapping = async ( 2453 + mapping: AccountMapping, 2454 + options?: { 2455 + confirm?: boolean; 2456 + showNoticeOnResult?: boolean; 2457 + refreshAfter?: boolean; 2458 + }, 2459 + ): Promise<{ ok: boolean; skipped: boolean }> => { 2460 + if (!authHeaders) { 2461 + return { ok: false, skipped: false }; 2462 + } 2463 + if (!canManageMapping(mapping)) { 2464 + if (options?.showNoticeOnResult !== false) { 2465 + showNotice('error', 'You do not have permission to update this mapping.'); 2466 + } 2467 + return { ok: false, skipped: false }; 2468 + } 2469 + 2470 + const sourceTwitterUsername = resolveProfileSyncSource(mapping, options?.showNoticeOnResult === false); 2471 + if (!sourceTwitterUsername) { 2472 + return { ok: false, skipped: false }; 2473 + } 2474 + 2475 + if (options?.confirm !== false) { 2476 + const confirmed = window.confirm( 2477 + `Pull Twitter bio from @${sourceTwitterUsername}? This updates only the bio/description on Bluesky.`, 2478 + ); 2479 + if (!confirmed) { 2480 + return { ok: false, skipped: false }; 2481 + } 2482 + } 2483 + 2484 + setPullingBioMappingId(mapping.id); 2485 + try { 2486 + const response = await axios.post<MirrorProfileSyncResult>( 2487 + `/api/mappings/${mapping.id}/pull-twitter-bio`, 2488 + { sourceTwitterUsername }, 2489 + { headers: authHeaders }, 2490 + ); 2491 + 2492 + const result = response.data; 2493 + const warnings = result?.warnings || []; 2494 + const skipped = Boolean(result?.skipped); 2495 + 2496 + if (result?.mapping) { 2497 + setMappings((previous) => previous.map((entry) => (entry.id === mapping.id ? result.mapping || entry : entry))); 2498 + } 2499 + 2500 + if (options?.showNoticeOnResult !== false) { 2501 + if (skipped) { 2502 + showNotice('info', `No bio changes detected for @${sourceTwitterUsername}.`); 2503 + } else if (warnings.length > 0) { 2504 + showNotice( 2505 + 'info', 2506 + `Bio pulled from @${sourceTwitterUsername} with ${warnings.length} warning(s). First warning: ${warnings[0]}`, 2507 + ); 2508 + } else { 2509 + showNotice('success', `Bio pulled from @${sourceTwitterUsername}.`); 2510 + } 2511 + } 2512 + 2513 + if (options?.refreshAfter !== false) { 2514 + await fetchData(); 2515 + } 2516 + 2517 + return { ok: true, skipped }; 2518 + } catch (error) { 2519 + if (options?.showNoticeOnResult !== false) { 2520 + handleAuthFailure(error, 'Failed to pull Twitter bio.'); 2521 + } 2522 + return { ok: false, skipped: false }; 2523 + } finally { 2524 + setPullingBioMappingId((previous) => (previous === mapping.id ? null : previous)); 2525 + } 2526 + }; 2527 + 2528 + const handlePullTwitterBio = async (mapping: AccountMapping) => { 2529 + await pullTwitterBioForMapping(mapping, { 2530 + confirm: true, 2531 + showNoticeOnResult: true, 2532 + refreshAfter: true, 2533 + }); 2534 + }; 2535 + 2536 + const resolveBulkAccountTargets = (actionLabel: string): AccountMapping[] => { 2537 + const fallbackTargets = manageableAccountMappingsForView; 2538 + if (accountsBulkScope !== 'selected') { 2539 + return fallbackTargets; 2540 + } 2541 + 2542 + if (selectedManageableMappingsForView.length > 0) { 2543 + return selectedManageableMappingsForView; 2544 + } 2545 + 2546 + showNotice('info', `No selected accounts available for ${actionLabel}.`); 2547 + return []; 2548 + }; 2549 + 2550 + const handleSyncAllProfilesFromTwitter = async (targetsOverride?: AccountMapping[]) => { 2404 2551 if (!authHeaders) { 2405 2552 return; 2406 2553 } ··· 2409 2556 return; 2410 2557 } 2411 2558 2412 - const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2559 + const candidates = targetsOverride && targetsOverride.length > 0 ? targetsOverride : manageableAccountMappingsForView; 2413 2560 if (candidates.length === 0) { 2414 2561 showNotice('info', 'No accounts available for profile sync.'); 2415 2562 return; ··· 2454 2601 } 2455 2602 }; 2456 2603 2457 - const handleAddBotLabelToAllAccounts = async () => { 2604 + const handlePullTwitterBioForAccounts = async (targetsOverride?: AccountMapping[]) => { 2605 + if (!authHeaders) { 2606 + return; 2607 + } 2608 + if (isAnyBulkAccountsActionBusy) { 2609 + showNotice('info', 'A bulk accounts action is already running. Please wait.'); 2610 + return; 2611 + } 2612 + 2613 + const candidates = targetsOverride && targetsOverride.length > 0 ? targetsOverride : manageableAccountMappingsForView; 2614 + if (candidates.length === 0) { 2615 + showNotice('info', 'No accounts available for Twitter bio pull.'); 2616 + return; 2617 + } 2618 + 2619 + const confirmed = window.confirm( 2620 + `Pull Twitter bio for ${candidates.length} account(s), one at a time? This updates only Bluesky description.`, 2621 + ); 2622 + if (!confirmed) { 2623 + return; 2624 + } 2625 + 2626 + setIsPullBioAllBusy(true); 2627 + let syncedCount = 0; 2628 + let skippedCount = 0; 2629 + let failedCount = 0; 2630 + 2631 + try { 2632 + for (const mapping of candidates) { 2633 + const outcome = await pullTwitterBioForMapping(mapping, { 2634 + confirm: false, 2635 + showNoticeOnResult: false, 2636 + refreshAfter: false, 2637 + }); 2638 + 2639 + if (!outcome.ok) { 2640 + failedCount += 1; 2641 + continue; 2642 + } 2643 + if (outcome.skipped) { 2644 + skippedCount += 1; 2645 + } else { 2646 + syncedCount += 1; 2647 + } 2648 + } 2649 + 2650 + await fetchData(); 2651 + showNotice( 2652 + failedCount > 0 ? 'info' : 'success', 2653 + `Pull bio complete: ${syncedCount} updated, ${skippedCount} unchanged, ${failedCount} failed.`, 2654 + ); 2655 + } finally { 2656 + setIsPullBioAllBusy(false); 2657 + } 2658 + }; 2659 + 2660 + const handleAddBotLabelToAllAccounts = async (targetsOverride?: AccountMapping[]) => { 2458 2661 if (!authHeaders) { 2459 2662 return; 2460 2663 } ··· 2463 2666 return; 2464 2667 } 2465 2668 2466 - const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2669 + const candidates = targetsOverride && targetsOverride.length > 0 ? targetsOverride : manageableAccountMappingsForView; 2467 2670 if (candidates.length === 0) { 2468 2671 showNotice('info', 'No accounts available for bot label update.'); 2469 2672 return; ··· 2499 2702 } 2500 2703 }; 2501 2704 2502 - const handleAppendBotNameToAllAccounts = async () => { 2705 + const handleAppendBotNameToAllAccounts = async (targetsOverride?: AccountMapping[]) => { 2503 2706 if (!authHeaders) { 2504 2707 return; 2505 2708 } ··· 2508 2711 return; 2509 2712 } 2510 2713 2511 - const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2714 + const candidates = targetsOverride && targetsOverride.length > 0 ? targetsOverride : manageableAccountMappingsForView; 2512 2715 if (candidates.length === 0) { 2513 2716 showNotice('info', 'No accounts available for display-name suffix update.'); 2514 2717 return; ··· 2545 2748 }; 2546 2749 2547 2750 const handleApplyAllAccountsAction = async () => { 2751 + const actionLabel = 2752 + accountsBulkAction === 'sync_profiles' 2753 + ? 'profile sync' 2754 + : accountsBulkAction === 'pull_twitter_bio' 2755 + ? 'Twitter bio pull' 2756 + : accountsBulkAction === 'bridge_all' 2757 + ? 'fediverse bridge' 2758 + : accountsBulkAction === 'apply_bot_label' 2759 + ? 'bot label update' 2760 + : 'display-name suffix update'; 2761 + const targets = resolveBulkAccountTargets(actionLabel); 2762 + if (targets.length === 0) { 2763 + return; 2764 + } 2765 + 2548 2766 if (accountsBulkAction === 'sync_profiles') { 2549 - await handleSyncAllProfilesFromTwitter(); 2767 + await handleSyncAllProfilesFromTwitter(targets); 2768 + return; 2769 + } 2770 + if (accountsBulkAction === 'pull_twitter_bio') { 2771 + await handlePullTwitterBioForAccounts(targets); 2550 2772 return; 2551 2773 } 2552 2774 if (accountsBulkAction === 'bridge_all') { 2553 - await handleBridgeAllEligible(); 2775 + await handleBridgeAllEligible(targets); 2554 2776 return; 2555 2777 } 2556 2778 if (accountsBulkAction === 'apply_bot_label') { 2557 - await handleAddBotLabelToAllAccounts(); 2779 + await handleAddBotLabelToAllAccounts(targets); 2558 2780 return; 2559 2781 } 2560 - await handleAppendBotNameToAllAccounts(); 2782 + await handleAppendBotNameToAllAccounts(targets); 2783 + }; 2784 + 2785 + const toggleAccountMappingSelection = (mappingId: string, checked: boolean) => { 2786 + setSelectedAccountMappingIds((previous) => { 2787 + if (checked) { 2788 + if (previous.includes(mappingId)) { 2789 + return previous; 2790 + } 2791 + return [...previous, mappingId]; 2792 + } 2793 + return previous.filter((id) => id !== mappingId); 2794 + }); 2795 + }; 2796 + 2797 + const toggleSelectAllFilteredManageable = (checked: boolean) => { 2798 + setSelectedAccountMappingIds((previous) => { 2799 + if (!checked) { 2800 + return previous.filter((mappingId) => !filteredManageableMappingIds.includes(mappingId)); 2801 + } 2802 + const seen = new Set(previous); 2803 + for (const mappingId of filteredManageableMappingIds) { 2804 + seen.add(mappingId); 2805 + } 2806 + return [...seen]; 2807 + }); 2808 + }; 2809 + 2810 + const clearSelectedAccountMappings = () => { 2811 + setSelectedAccountMappingIds([]); 2561 2812 }; 2562 2813 2563 2814 const showMoreRowsForGroup = useCallback((groupKey: string) => { ··· 2693 2944 } 2694 2945 }; 2695 2946 2696 - const handleBridgeAllEligible = async () => { 2947 + const handleBridgeAllEligible = async (targetsOverride?: AccountMapping[]) => { 2697 2948 if (!authHeaders) { 2698 2949 return; 2699 2950 } 2700 - if (isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)) { 2701 - showNotice('info', 'Profile sync is currently running. Please wait before bridge-all.'); 2951 + if (isSyncAllProfilesBusy || isPullBioAllBusy || Boolean(syncingProfileMappingId) || Boolean(pullingBioMappingId)) { 2952 + showNotice('info', 'Profile/bio sync is currently running. Please wait before bridge-all.'); 2702 2953 return; 2703 2954 } 2704 2955 2705 - const manageable = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2956 + const manageable = targetsOverride && targetsOverride.length > 0 ? targetsOverride : manageableAccountMappingsForView; 2706 2957 if (manageable.length === 0) { 2707 2958 showNotice('info', 'No accounts available for fediverse bridge.'); 2708 2959 return; ··· 4054 4305 disabled={isAnyBulkAccountsActionBusy} 4055 4306 onChange={(event) => setAccountsBulkAction(event.target.value as BulkAccountsAction)} 4056 4307 > 4057 - <option value="sync_profiles">Apply profile sync to all accounts</option> 4308 + <option value="sync_profiles">Apply profile sync to accounts</option> 4309 + <option value="pull_twitter_bio">Pull Twitter bio to accounts</option> 4058 4310 <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> 4311 + <option value="apply_bot_label">Apply automated-account label to accounts</option> 4312 + <option value="append_bot_name">Apply {'{bot}'} display-name suffix to accounts</option> 4313 + </select> 4314 + <select 4315 + className={cn(selectClassName, 'h-9 w-[200px] px-2 py-1 text-xs')} 4316 + value={accountsBulkScope} 4317 + disabled={isAnyBulkAccountsActionBusy} 4318 + onChange={(event) => setAccountsBulkScope(event.target.value as 'all' | 'selected')} 4319 + > 4320 + <option value="all">Target all manageable</option> 4321 + <option value="selected">Target selected only</option> 4061 4322 </select> 4062 4323 <Button 4063 4324 size="sm" 4064 4325 variant="outline" 4065 - disabled={isAnyBulkAccountsActionBusy} 4326 + disabled={isAnyBulkAccountsActionBusy || (accountsBulkScope === 'selected' && selectedManageableMappingsCount === 0)} 4066 4327 onClick={() => { 4067 4328 void handleApplyAllAccountsAction(); 4068 4329 }} 4069 4330 > 4070 4331 {isAnyBulkAccountsActionBusy ? ( 4071 4332 <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 4333 + ) : accountsBulkAction === 'pull_twitter_bio' ? ( 4334 + <Download className="mr-2 h-4 w-4" /> 4072 4335 ) : accountsBulkAction === 'bridge_all' ? ( 4073 4336 <Link2 className="mr-2 h-4 w-4" /> 4074 4337 ) : accountsBulkAction === 'sync_profiles' ? ( ··· 4078 4341 )} 4079 4342 {accountsBulkAction === 'sync_profiles' 4080 4343 ? 'Apply sync all' 4344 + : accountsBulkAction === 'pull_twitter_bio' 4345 + ? 'Apply pull bio' 4081 4346 : accountsBulkAction === 'bridge_all' 4082 4347 ? 'Apply bridge all' 4083 4348 : accountsBulkAction === 'apply_bot_label' ··· 4092 4357 </Badge> 4093 4358 ) : null} 4094 4359 <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 4360 + <Badge variant={selectedManageableMappingsCount > 0 ? 'success' : 'outline'}> 4361 + {selectedManageableMappingsCount} selected 4362 + </Badge> 4095 4363 <Badge variant="outline">{botLabeledMappingsForView.length} bot-labeled</Badge> 4096 4364 </div> 4097 4365 </div> ··· 4172 4440 <Button 4173 4441 size="sm" 4174 4442 variant="outline" 4443 + disabled={filteredManageableMappingIds.length === 0} 4444 + onClick={() => toggleSelectAllFilteredManageable(!allFilteredManageableSelected)} 4445 + > 4446 + {allFilteredManageableSelected 4447 + ? `Unselect matched (${filteredManageableMappingIds.length})` 4448 + : `Select matched (${filteredManageableMappingIds.length})`} 4449 + </Button> 4450 + <Button 4451 + size="sm" 4452 + variant="outline" 4453 + disabled={selectedManageableMappingsCount === 0} 4454 + onClick={clearSelectedAccountMappings} 4455 + > 4456 + Clear selected 4457 + </Button> 4458 + <Button 4459 + size="sm" 4460 + variant="outline" 4175 4461 onClick={() => setShowAccountAvatars((previous) => !previous)} 4176 4462 > 4177 4463 {showAccountAvatars ? 'Hide avatars (faster)' : 'Show avatars'} ··· 4329 4615 const bridging = bridgingMappingId === mapping.id; 4330 4616 const mappingGroup = getMappingGroupMeta(mapping); 4331 4617 const syncingProfile = syncingProfileMappingId === mapping.id; 4618 + const pullingBio = pullingBioMappingId === mapping.id; 4619 + const isSelectedForBulk = selectedAccountMappingIdSet.has(mapping.id); 4332 4620 4333 4621 return ( 4334 4622 <tr ··· 4429 4717 </td> 4430 4718 <td className="px-2 py-3 align-top"> 4431 4719 <div className="flex flex-wrap justify-end gap-1"> 4720 + {canManageThisMapping ? ( 4721 + <label className="inline-flex items-center gap-1 rounded border border-border/70 px-2 py-1 text-xs text-muted-foreground"> 4722 + <input 4723 + type="checkbox" 4724 + className="h-3.5 w-3.5 rounded border-border" 4725 + checked={isSelectedForBulk} 4726 + disabled={isAnyBulkAccountsActionBusy} 4727 + onChange={(event) => 4728 + toggleAccountMappingSelection(mapping.id, event.target.checked) 4729 + } 4730 + /> 4731 + Select 4732 + </label> 4733 + ) : null} 4432 4734 <select 4433 4735 className={cn(selectClassName, 'h-9 w-44 px-2 py-1 text-xs')} 4434 4736 value={mappingGroup.key} ··· 4505 4807 isBridgeAllBusy || 4506 4808 isAnyBulkAccountsActionBusy || 4507 4809 Boolean(syncingProfileMappingId) || 4810 + Boolean(pullingBioMappingId) || 4508 4811 isSyncAllProfilesBusy || 4509 4812 Boolean(bridgingMappingId) 4510 4813 } ··· 4518 4821 <RefreshCw className="mr-1 h-4 w-4" /> 4519 4822 )} 4520 4823 Sync Profile 4824 + </Button> 4825 + <Button 4826 + variant="outline" 4827 + size="sm" 4828 + disabled={ 4829 + isBridgeAllBusy || 4830 + isAnyBulkAccountsActionBusy || 4831 + Boolean(syncingProfileMappingId) || 4832 + Boolean(pullingBioMappingId) || 4833 + isSyncAllProfilesBusy || 4834 + Boolean(bridgingMappingId) 4835 + } 4836 + onClick={() => { 4837 + void handlePullTwitterBio(mapping); 4838 + }} 4839 + > 4840 + {pullingBio ? ( 4841 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 4842 + ) : ( 4843 + <Download className="mr-1 h-4 w-4" /> 4844 + )} 4845 + Pull Twitter Bio 4521 4846 </Button> 4522 4847 {canUseFediverseBridge && !isFediverseBridged ? ( 4523 4848 <Button