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.

Fix PM2 Bun launch compatibility and add fediverse bridge bulk UX

j4ckxyz be3d05b9 53e0be0c

+708 -156
+21 -1
README.md
··· 494 494 ### Option B: manage PM2 directly 495 495 496 496 ```bash 497 - pm2 start dist/index.js --name tweets-2-bsky --interpreter bun 497 + pm2 start "$HOME/.bun/bin/bun" --name tweets-2-bsky --cwd "$PWD" -- dist/index.js 498 498 pm2 logs tweets-2-bsky 499 499 pm2 restart tweets-2-bsky --update-env 500 500 pm2 save 501 + ``` 502 + 503 + Do not use `--interpreter bun` with `dist/index.js` on PM2 installs that cannot `require()` async ESM modules. Use Bun as the process command instead (example above). 504 + 505 + ### PM2 migration help (older manual installs) 506 + 507 + If you manually created PM2 processes on older versions, migrate once to the Bun binary launcher: 508 + 509 + ```bash 510 + pm2 delete tweets-2-bsky || true 511 + pm2 delete twitter-mirror || true 512 + pm2 start "$HOME/.bun/bin/bun" --name tweets-2-bsky --cwd "$PWD" -- dist/index.js 513 + pm2 save 514 + ``` 515 + 516 + If your existing process must keep the legacy name: 517 + 518 + ```bash 519 + pm2 start "$HOME/.bun/bin/bun" --name twitter-mirror --cwd "$PWD" -- dist/index.js 501 520 ``` 502 521 503 522 ### Option C: no PM2 (nohup) ··· 531 550 - rebuilds native modules when runtime/dependencies changed 532 551 - builds server + web dashboard 533 552 - restarts existing runtime for PM2 **or** nohup mode 553 + - normalizes PM2 runtime to Bun binary launcher mode (avoids Bun interpreter crash loops on some PM2 builds) 534 554 - preserves local `config.json` and `.env` with backup/restore 535 555 536 556 Useful update flags:
+20 -3
TROUBLESHOOTING.md
··· 17 17 ``` 18 18 19 19 ### PM2 interpreter mismatch 20 - If PM2 logs show command/runtime errors after an update (for example old `npm`/`node` interpreter paths): 20 + If PM2 logs show command/runtime errors after an update (for example stale interpreter paths): 21 + 22 + Common error signature: 23 + 24 + ```text 25 + TypeError: require() async module ".../dist/index.js" is unsupported. use "await import()" instead. 26 + ``` 21 27 22 28 1. Run the repair script: 23 29 ```bash 24 30 chmod +x repair_pm2.sh 25 - ./repair_pm2.sh 26 - ``` 31 + ./repair_pm2.sh 32 + ``` 33 + 2. If needed, manually recreate PM2 with Bun as the process command: 34 + ```bash 35 + pm2 delete tweets-2-bsky || true 36 + pm2 delete twitter-mirror || true 37 + pm2 start "$HOME/.bun/bin/bun" --name tweets-2-bsky --cwd "$PWD" -- dist/index.js 38 + pm2 save 39 + ``` 40 + 3. Old crash lines remain in PM2 logs until log rotation/flush. Clear them if needed: 41 + ```bash 42 + pm2 flush 43 + ``` 27 44 28 45 ### `bun: command not found` 29 46 If Bun is missing on a source install host:
+4 -7
install.sh
··· 406 406 fi 407 407 408 408 if pm2 describe "$APP_NAME" >/dev/null 2>&1; then 409 - echo "[pm2] Restarting existing process with updated env: $APP_NAME" 410 - pm2 restart "$APP_NAME" --update-env --interpreter "$BUN_BIN" || { 411 - echo "[pm2] Restart failed, recreating process with Bun interpreter" 412 - pm2 delete "$APP_NAME" || true 413 - pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --interpreter "$BUN_BIN" --update-env 414 - } 409 + echo "[pm2] Recreating existing process with Bun binary launcher: $APP_NAME" 410 + pm2 delete "$APP_NAME" || true 415 411 else 416 412 echo "[pm2] Starting new process: $APP_NAME (cwd=$SCRIPT_DIR, script=dist/index.js)" 417 - pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --interpreter "$BUN_BIN" --update-env 418 413 fi 414 + 415 + pm2 start "$BUN_BIN" --name "$APP_NAME" --cwd "$SCRIPT_DIR" --update-env -- dist/index.js 419 416 420 417 echo "[pm2] Saving PM2 process list" 421 418 pm2 save || true
+5 -2
repair_pm2.sh
··· 2 2 3 3 set -euo pipefail 4 4 5 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 + cd "$SCRIPT_DIR" 7 + 5 8 echo "🔧 Repairing PM2 process environment..." 6 9 7 10 if ! command -v pm2 >/dev/null 2>&1; then ··· 71 74 echo "Deleting process..." 72 75 pm2 delete "$PROCESS_NAME" || true 73 76 74 - echo "Starting process with fresh environment using Bun interpreter..." 75 - pm2 start dist/index.js --name "$PROCESS_NAME" --interpreter "$BUN_BIN" 77 + echo "Starting process with fresh environment using Bun binary launcher..." 78 + pm2 start "$BUN_BIN" --name "$PROCESS_NAME" --cwd "$SCRIPT_DIR" --update-env -- dist/index.js 76 79 77 80 echo "Saving PM2 list..." 78 81 pm2 save
+46 -17
src/profile-mirror.ts
··· 93 93 announcementCid: string; 94 94 } 95 95 96 + export interface FediverseBridgeStatusResult { 97 + bsky: BlueskyCredentialValidation; 98 + bridgeAccountHandle: string; 99 + bridged: boolean; 100 + } 101 + 96 102 const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 97 103 98 104 const normalizeOptionalString = (value: unknown): string | undefined => { ··· 389 395 throw new Error('Failed to fetch Twitter profile.'); 390 396 }; 391 397 392 - export const validateBlueskyCredentials = async (args: { 398 + const loginBlueskyAgent = async (args: { 393 399 bskyIdentifier: string; 394 400 bskyPassword: string; 395 401 bskyServiceUrl?: string; 396 - }): Promise<BlueskyCredentialValidation> => { 402 + }): Promise<{ agent: BskyAgent; credentials: BlueskyCredentialValidation }> => { 397 403 const identifier = normalizeOptionalString(args.bskyIdentifier); 398 404 const password = normalizeOptionalString(args.bskyPassword); 399 405 if (!identifier || !password) { ··· 406 412 407 413 const sessionResponse = await agent.com.atproto.server.getSession(); 408 414 const session = sessionResponse.data; 415 + 409 416 return { 410 - did: session.did, 411 - handle: session.handle, 412 - email: session.email, 413 - emailConfirmed: Boolean(session.emailConfirmed), 414 - serviceUrl, 415 - settingsUrl: BSKY_SETTINGS_URL, 417 + agent, 418 + credentials: { 419 + did: session.did, 420 + handle: session.handle, 421 + email: session.email, 422 + emailConfirmed: Boolean(session.emailConfirmed), 423 + serviceUrl, 424 + settingsUrl: BSKY_SETTINGS_URL, 425 + }, 416 426 }; 427 + }; 428 + 429 + export const validateBlueskyCredentials = async (args: { 430 + bskyIdentifier: string; 431 + bskyPassword: string; 432 + bskyServiceUrl?: string; 433 + }): Promise<BlueskyCredentialValidation> => { 434 + const { credentials } = await loginBlueskyAgent(args); 435 + return credentials; 417 436 }; 418 437 419 438 export const applyProfileMirrorSyncState = <T extends MappingProfileSyncState>( ··· 502 521 return false; 503 522 }; 504 523 524 + export const getFediverseBridgeStatus = async (args: { 525 + bskyIdentifier: string; 526 + bskyPassword: string; 527 + bskyServiceUrl?: string; 528 + }): Promise<FediverseBridgeStatusResult> => { 529 + const { agent, credentials } = await loginBlueskyAgent(args); 530 + 531 + const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE); 532 + const bridged = await hasFollowRecordForDid(agent, bridgeProfile.did); 533 + 534 + return { 535 + bsky: credentials, 536 + bridgeAccountHandle: bridgeProfile.handle, 537 + bridged, 538 + }; 539 + }; 540 + 505 541 const uploadProfileImage = async (agent: BskyAgent, url: string, kind: ProfileImageKind): Promise<BlobRef> => { 506 542 const response = await axios.get<ArrayBuffer>(url, { 507 543 responseType: 'arraybuffer', ··· 576 612 warnings.push('No Twitter banner found for this profile.'); 577 613 } 578 614 579 - const shouldUpdateProfile = 580 - changed.displayName || changed.description || Boolean(avatarBlob) || Boolean(bannerBlob); 615 + const shouldUpdateProfile = changed.displayName || changed.description || Boolean(avatarBlob) || Boolean(bannerBlob); 581 616 582 617 if (shouldUpdateProfile) { 583 618 await agent.upsertProfile((existing) => ({ ··· 605 640 bskyPassword: string; 606 641 bskyServiceUrl?: string; 607 642 }): Promise<FediverseBridgeResult> => { 608 - const bsky = await validateBlueskyCredentials(args); 643 + const { agent, credentials: bsky } = await loginBlueskyAgent(args); 609 644 const accountProfile = await fetchPublicProfile(bsky.did || bsky.handle); 610 645 const createdAtRaw = normalizeOptionalString(accountProfile.createdAt); 611 646 if (!createdAtRaw) { ··· 622 657 const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000)); 623 658 throw new Error(`Account must be at least 7 days old before bridging (currently ${ageDays} day(s)).`); 624 659 } 625 - 626 - const agent = new BskyAgent({ service: bsky.serviceUrl }); 627 - await agent.login({ 628 - identifier: args.bskyIdentifier, 629 - password: args.bskyPassword, 630 - }); 631 660 632 661 const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE); 633 662 const alreadyFollowing = await hasFollowRecordForDid(agent, bridgeProfile.did);
+101 -1
src/server.ts
··· 26 26 applyProfileMirrorSyncState, 27 27 bridgeBlueskyAccountToFediverse, 28 28 fetchTwitterMirrorProfile, 29 + getFediverseBridgeStatus, 29 30 syncBlueskyProfileFromTwitter, 30 31 validateBlueskyCredentials, 31 32 } from './profile-mirror.js'; ··· 58 59 const APPVIEW_PROFILE_CHUNK_SIZE = 25; 59 60 const APPVIEW_MAX_ATTEMPTS = 2; 60 61 const APPVIEW_RETRY_DELAY_MS = 700; 62 + const FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE = 5; 61 63 62 64 function loadPersistedJwtSecret(): string | undefined { 63 65 if (!fs.existsSync(JWT_SECRET_FILE_PATH)) { ··· 124 126 avatar?: string; 125 127 description?: string; 126 128 createdAt?: string; 129 + } 130 + 131 + interface FediverseBridgeStatusView { 132 + bridged: boolean; 133 + checkedAt: string; 134 + error?: string; 127 135 } 128 136 129 137 interface EnrichedPostMedia { ··· 1044 1052 return usernames; 1045 1053 }; 1046 1054 1055 + const parseMappingIds = (value: unknown): string[] => { 1056 + if (!Array.isArray(value)) { 1057 + return []; 1058 + } 1059 + 1060 + const seen = new Set<string>(); 1061 + const ids: string[] = []; 1062 + for (const candidate of value) { 1063 + if (typeof candidate !== 'string') { 1064 + continue; 1065 + } 1066 + const normalized = candidate.trim(); 1067 + if (!normalized || seen.has(normalized)) { 1068 + continue; 1069 + } 1070 + seen.add(normalized); 1071 + ids.push(normalized); 1072 + } 1073 + 1074 + return ids; 1075 + }; 1076 + 1047 1077 const resolveProfileSyncSourceUsername = (args: { 1048 1078 twitterUsernames: string[]; 1049 1079 requestedSource?: unknown; ··· 2061 2091 const sourceWasExplicitlyProvided = req.body?.profileSyncSourceUsername !== undefined; 2062 2092 const usernamesWereUpdated = req.body?.twitterUsernames !== undefined; 2063 2093 2064 - if (twitterUsernames.length > 1 && !profileSyncSourceUsername && (sourceWasExplicitlyProvided || usernamesWereUpdated)) { 2094 + if ( 2095 + twitterUsernames.length > 1 && 2096 + !profileSyncSourceUsername && 2097 + (sourceWasExplicitlyProvided || usernamesWereUpdated) 2098 + ) { 2065 2099 res.status(400).json({ 2066 2100 error: 'Select which Twitter source should drive Bluesky profile sync for multi-source mappings.', 2067 2101 }); ··· 2180 2214 } catch (error) { 2181 2215 res.status(400).json({ error: getErrorMessage(error, 'Failed to bridge account to the fediverse.') }); 2182 2216 } 2217 + }); 2218 + 2219 + app.post('/api/mappings/fediverse-bridge-status', authenticateToken, async (req: any, res) => { 2220 + const config = getConfig(); 2221 + const visibleMappings = getVisibleMappings(config, req.user); 2222 + const visibleMappingsById = new Map(visibleMappings.map((mapping) => [mapping.id, mapping] as const)); 2223 + 2224 + const requestedIds = parseMappingIds(req.body?.mappingIds); 2225 + const idsToCheck = (requestedIds.length > 0 ? requestedIds : visibleMappings.map((mapping) => mapping.id)) 2226 + .filter((id) => visibleMappingsById.has(id)) 2227 + .slice(0, 200); 2228 + 2229 + if (idsToCheck.length === 0) { 2230 + res.json({}); 2231 + return; 2232 + } 2233 + 2234 + const statuses: Record<string, FediverseBridgeStatusView> = {}; 2235 + for (const chunk of chunkArray(idsToCheck, FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE)) { 2236 + const chunkResults = await Promise.all( 2237 + chunk.map(async (id) => { 2238 + const mapping = visibleMappingsById.get(id); 2239 + if (!mapping) { 2240 + return { 2241 + id, 2242 + status: { 2243 + bridged: false, 2244 + checkedAt: new Date().toISOString(), 2245 + error: 'Mapping not visible to current user.', 2246 + } satisfies FediverseBridgeStatusView, 2247 + }; 2248 + } 2249 + 2250 + try { 2251 + const result = await getFediverseBridgeStatus({ 2252 + bskyIdentifier: mapping.bskyIdentifier, 2253 + bskyPassword: mapping.bskyPassword, 2254 + bskyServiceUrl: mapping.bskyServiceUrl, 2255 + }); 2256 + 2257 + return { 2258 + id, 2259 + status: { 2260 + bridged: result.bridged, 2261 + checkedAt: new Date().toISOString(), 2262 + } satisfies FediverseBridgeStatusView, 2263 + }; 2264 + } catch (error) { 2265 + return { 2266 + id, 2267 + status: { 2268 + bridged: false, 2269 + checkedAt: new Date().toISOString(), 2270 + error: getErrorMessage(error, 'Failed to check fediverse bridge status.'), 2271 + } satisfies FediverseBridgeStatusView, 2272 + }; 2273 + } 2274 + }), 2275 + ); 2276 + 2277 + for (const result of chunkResults) { 2278 + statuses[result.id] = result.status; 2279 + } 2280 + } 2281 + 2282 + res.json(statuses); 2183 2283 }); 2184 2284 2185 2285 app.delete('/api/mappings/:id', authenticateToken, (req: any, res) => {
+12 -18
update.sh
··· 397 397 command -v pm2 >/dev/null 2>&1 && pm2 describe "$name" >/dev/null 2>&1 398 398 } 399 399 400 + start_pm2_process_with_bun() { 401 + local name="$1" 402 + pm2 delete "$name" >/dev/null 2>&1 || true 403 + pm2 start "$BUN_BIN" --name "$name" --cwd "$SCRIPT_DIR" --update-env -- dist/index.js 404 + } 405 + 400 406 nohup_process_running() { 401 407 if [[ ! -f "$PID_FILE" ]]; then 402 408 return 1 ··· 438 444 439 445 if [[ "$has_app" -eq 1 && "$has_legacy" -eq 1 ]]; then 440 446 echo "ℹ️ Found both PM2 processes ($APP_NAME and $LEGACY_APP_NAME). Consolidating to $APP_NAME..." 441 - echo "[pm2] Restarting $APP_NAME with updated environment" 442 - pm2 restart "$APP_NAME" --update-env --interpreter "$BUN_BIN" || { 443 - echo "[pm2] Restart failed. Recreating $APP_NAME" 444 - pm2 delete "$APP_NAME" || true 445 - pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --interpreter "$BUN_BIN" --update-env 446 - } 447 + echo "[pm2] Recreating $APP_NAME with Bun binary launcher" 448 + start_pm2_process_with_bun "$APP_NAME" 447 449 echo "[pm2] Removing duplicate legacy process $LEGACY_APP_NAME" 448 450 pm2 delete "$LEGACY_APP_NAME" || true 449 451 echo "[pm2] Saving PM2 process list" ··· 453 455 fi 454 456 455 457 if [[ "$has_app" -eq 1 ]]; then 456 - echo "[pm2] Restarting $APP_NAME with updated environment" 457 - pm2 restart "$APP_NAME" --update-env --interpreter "$BUN_BIN" || { 458 - echo "⚠️ PM2 restart failed for $APP_NAME. Recreating process..." 459 - pm2 delete "$APP_NAME" || true 460 - pm2 start dist/index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --interpreter "$BUN_BIN" --update-env 461 - } 458 + echo "[pm2] Recreating $APP_NAME with Bun binary launcher" 459 + start_pm2_process_with_bun "$APP_NAME" 462 460 echo "[pm2] Saving PM2 process list" 463 461 pm2 save || true 464 462 echo "✅ Restarted PM2 process: $APP_NAME" ··· 466 464 fi 467 465 468 466 if [[ "$has_legacy" -eq 1 ]]; then 469 - echo "[pm2] Restarting legacy process $LEGACY_APP_NAME with updated environment" 470 - pm2 restart "$LEGACY_APP_NAME" --update-env --interpreter "$BUN_BIN" || { 471 - echo "⚠️ PM2 restart failed for $LEGACY_APP_NAME. Recreating it..." 472 - pm2 delete "$LEGACY_APP_NAME" || true 473 - pm2 start dist/index.js --name "$LEGACY_APP_NAME" --cwd "$SCRIPT_DIR" --interpreter "$BUN_BIN" --update-env 474 - } 467 + echo "[pm2] Recreating legacy process $LEGACY_APP_NAME with Bun binary launcher" 468 + start_pm2_process_with_bun "$LEGACY_APP_NAME" 475 469 echo "[pm2] Saving PM2 process list" 476 470 pm2 save || true 477 471 echo "✅ Restarted PM2 process: $LEGACY_APP_NAME"
+499 -107
web/src/App.tsx
··· 186 186 createdAt?: string; 187 187 } 188 188 189 + interface FediverseBridgeStatusView { 190 + bridged: boolean; 191 + checkedAt: string; 192 + error?: string; 193 + } 194 + 189 195 interface PendingBackfill { 190 196 id: string; 191 197 limit?: number; ··· 774 780 const [syncingProfileMappingId, setSyncingProfileMappingId] = useState<string | null>(null); 775 781 const [isSyncAllProfilesBusy, setIsSyncAllProfilesBusy] = useState(false); 776 782 const [bridgingMappingId, setBridgingMappingId] = useState<string | null>(null); 783 + const [isBridgeAllBusy, setIsBridgeAllBusy] = useState(false); 784 + const [fediverseBridgeStatusByMappingId, setFediverseBridgeStatusByMappingId] = useState< 785 + Record<string, FediverseBridgeStatusView> 786 + >({}); 777 787 const [editForm, setEditForm] = useState<MappingFormState>(defaultMappingForm); 778 788 const [editTwitterUsers, setEditTwitterUsers] = useState<string[]>([]); 779 789 const [editTwitterInput, setEditTwitterInput] = useState(''); ··· 829 839 const postsSearchRequestRef = useRef(0); 830 840 const statusRequestRef = useRef(0); 831 841 const statusMutationRef = useRef(0); 842 + const fediverseBridgeStatusByMappingIdRef = useRef<Record<string, FediverseBridgeStatusView>>({}); 843 + const bridgeStatusFetchRef = useRef<Promise<Record<string, FediverseBridgeStatusView> | null> | null>(null); 832 844 833 845 const isAdmin = me?.isAdmin ?? false; 834 846 const effectivePermissions = useMemo<UserPermissions>(() => normalizePermissions(me?.permissions), [me?.permissions]); ··· 842 854 const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]); 843 855 844 856 useEffect(() => { 857 + const debugApiLogging = window.localStorage.getItem('debug-api') === '1'; 858 + if (!debugApiLogging) { 859 + return; 860 + } 861 + 845 862 const requestInterceptor = axios.interceptors.request.use((config) => { 846 863 console.debug('[tweets-2-bsky:web] api-request', { 847 864 method: config.method?.toUpperCase(), ··· 915 932 setSyncingProfileMappingId(null); 916 933 setIsSyncAllProfilesBusy(false); 917 934 setBridgingMappingId(null); 935 + setIsBridgeAllBusy(false); 936 + setFediverseBridgeStatusByMappingId({}); 918 937 setEditTwitterUsers([]); 919 938 setNewGroupName(''); 920 939 setNewGroupEmoji(DEFAULT_GROUP_EMOJI); ··· 951 970 [handleLogout, showNotice], 952 971 ); 953 972 973 + useEffect(() => { 974 + fediverseBridgeStatusByMappingIdRef.current = fediverseBridgeStatusByMappingId; 975 + }, [fediverseBridgeStatusByMappingId]); 976 + 954 977 const fetchBootstrapStatus = useCallback(async () => { 955 978 try { 956 979 const response = await axios.get<BootstrapStatus>('/api/auth/bootstrap-status'); ··· 1062 1085 }, [authHeaders, handleAuthFailure, isAdmin]); 1063 1086 1064 1087 const fetchProfiles = useCallback( 1065 - async (actors: string[]) => { 1088 + async (actors: string[]): Promise<Record<string, BskyProfileView> | null> => { 1066 1089 if (!authHeaders) { 1067 - return; 1090 + return null; 1068 1091 } 1069 1092 1070 1093 const normalizedActors = [...new Set(actors.map(normalizeTwitterUsername).filter((actor) => actor.length > 0))]; 1071 1094 if (normalizedActors.length === 0) { 1072 1095 setProfilesByActor({}); 1073 - return; 1096 + return {}; 1074 1097 } 1075 1098 1076 1099 try { ··· 1079 1102 { actors: normalizedActors }, 1080 1103 { headers: authHeaders }, 1081 1104 ); 1082 - setProfilesByActor(response.data || {}); 1105 + const nextProfiles = response.data || {}; 1106 + setProfilesByActor(nextProfiles); 1107 + return nextProfiles; 1083 1108 } catch (error) { 1084 1109 handleAuthFailure(error, 'Failed to resolve Bluesky profiles.'); 1110 + return null; 1085 1111 } 1086 1112 }, 1087 1113 [authHeaders, handleAuthFailure], 1088 1114 ); 1089 1115 1090 - const fetchData = useCallback(async () => { 1091 - if (!authHeaders) { 1092 - return; 1093 - } 1116 + const fetchFediverseBridgeStatuses = useCallback( 1117 + async ( 1118 + mappingData: AccountMapping[], 1119 + options?: { 1120 + force?: boolean; 1121 + }, 1122 + ): Promise<Record<string, FediverseBridgeStatusView> | null> => { 1123 + if (!authHeaders) { 1124 + return null; 1125 + } 1094 1126 1095 - try { 1096 - const [meResponse, mappingsResponse, groupsResponse] = await Promise.all([ 1097 - axios.get<AuthUser>('/api/me', { headers: authHeaders }), 1098 - axios.get<AccountMapping[]>('/api/mappings', { headers: authHeaders }), 1099 - axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }), 1100 - ]); 1127 + const mappingIds = [...new Set(mappingData.map((mapping) => mapping.id).filter((id) => id.length > 0))]; 1128 + if (mappingIds.length === 0) { 1129 + setFediverseBridgeStatusByMappingId({}); 1130 + return {}; 1131 + } 1101 1132 1102 - const profile = meResponse.data; 1103 - const mappingData = Array.isArray(mappingsResponse.data) ? mappingsResponse.data : []; 1104 - const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; 1105 - setMe({ 1106 - ...profile, 1107 - permissions: normalizePermissions(profile.permissions), 1108 - }); 1109 - setMappings(mappingData); 1110 - setGroups(groupData); 1111 - setEmailForm((previous) => ({ 1112 - ...previous, 1113 - currentEmail: profile.email || '', 1114 - })); 1115 - const versionResponse = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 1116 - setRuntimeVersion(versionResponse.data); 1133 + if (!options?.force && bridgeStatusFetchRef.current) { 1134 + return bridgeStatusFetchRef.current; 1135 + } 1117 1136 1118 - if (profile.isAdmin) { 1119 - const [twitterResponse, aiResponse, updateStatusResponse, usersResponse] = await Promise.all([ 1120 - axios.get<TwitterConfig>('/api/twitter-config', { headers: authHeaders }), 1121 - axios.get<AIConfig>('/api/ai-config', { headers: authHeaders }), 1122 - axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }), 1123 - axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }), 1137 + const request = (async () => { 1138 + try { 1139 + const response = await axios.post<Record<string, FediverseBridgeStatusView>>( 1140 + '/api/mappings/fediverse-bridge-status', 1141 + { mappingIds }, 1142 + { headers: authHeaders }, 1143 + ); 1144 + const nextStatuses = response.data || {}; 1145 + setFediverseBridgeStatusByMappingId((previous) => { 1146 + const next = { ...previous }; 1147 + for (const id of mappingIds) { 1148 + delete next[id]; 1149 + } 1150 + for (const [id, status] of Object.entries(nextStatuses)) { 1151 + next[id] = status; 1152 + } 1153 + return next; 1154 + }); 1155 + return nextStatuses; 1156 + } catch (error) { 1157 + if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403)) { 1158 + handleLogout(); 1159 + return null; 1160 + } 1161 + console.warn('Failed to fetch fediverse bridge statuses.', error); 1162 + return null; 1163 + } finally { 1164 + bridgeStatusFetchRef.current = null; 1165 + } 1166 + })(); 1167 + 1168 + bridgeStatusFetchRef.current = request; 1169 + return request; 1170 + }, 1171 + [authHeaders, handleLogout], 1172 + ); 1173 + 1174 + const fetchData = useCallback( 1175 + async (options?: { refreshBridgeStatuses?: boolean }) => { 1176 + if (!authHeaders) { 1177 + return; 1178 + } 1179 + 1180 + try { 1181 + const [meResponse, mappingsResponse, groupsResponse] = await Promise.all([ 1182 + axios.get<AuthUser>('/api/me', { headers: authHeaders }), 1183 + axios.get<AccountMapping[]>('/api/mappings', { headers: authHeaders }), 1184 + axios.get<AccountGroup[]>('/api/groups', { headers: authHeaders }), 1124 1185 ]); 1125 1186 1126 - setTwitterConfig({ 1127 - authToken: twitterResponse.data.authToken || '', 1128 - ct0: twitterResponse.data.ct0 || '', 1129 - backupAuthToken: twitterResponse.data.backupAuthToken || '', 1130 - backupCt0: twitterResponse.data.backupCt0 || '', 1187 + const profile = meResponse.data; 1188 + const mappingData = Array.isArray(mappingsResponse.data) ? mappingsResponse.data : []; 1189 + const groupData = Array.isArray(groupsResponse.data) ? groupsResponse.data : []; 1190 + setMe({ 1191 + ...profile, 1192 + permissions: normalizePermissions(profile.permissions), 1131 1193 }); 1194 + setMappings(mappingData); 1195 + setGroups(groupData); 1196 + setEmailForm((previous) => ({ 1197 + ...previous, 1198 + currentEmail: profile.email || '', 1199 + })); 1200 + const versionResponse = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 1201 + setRuntimeVersion(versionResponse.data); 1132 1202 1133 - setAiConfig({ 1134 - provider: aiResponse.data.provider || 'gemini', 1135 - apiKey: aiResponse.data.apiKey || '', 1136 - model: aiResponse.data.model || '', 1137 - baseUrl: aiResponse.data.baseUrl || '', 1203 + if (profile.isAdmin) { 1204 + const [twitterResponse, aiResponse, updateStatusResponse, usersResponse] = await Promise.all([ 1205 + axios.get<TwitterConfig>('/api/twitter-config', { headers: authHeaders }), 1206 + axios.get<AIConfig>('/api/ai-config', { headers: authHeaders }), 1207 + axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }), 1208 + axios.get<ManagedUser[]>('/api/admin/users', { headers: authHeaders }), 1209 + ]); 1210 + 1211 + setTwitterConfig({ 1212 + authToken: twitterResponse.data.authToken || '', 1213 + ct0: twitterResponse.data.ct0 || '', 1214 + backupAuthToken: twitterResponse.data.backupAuthToken || '', 1215 + backupCt0: twitterResponse.data.backupCt0 || '', 1216 + }); 1217 + 1218 + setAiConfig({ 1219 + provider: aiResponse.data.provider || 'gemini', 1220 + apiKey: aiResponse.data.apiKey || '', 1221 + model: aiResponse.data.model || '', 1222 + baseUrl: aiResponse.data.baseUrl || '', 1223 + }); 1224 + setUpdateStatus(updateStatusResponse.data); 1225 + setManagedUsers(Array.isArray(usersResponse.data) ? usersResponse.data : []); 1226 + } else { 1227 + setUpdateStatus(null); 1228 + setManagedUsers([]); 1229 + } 1230 + 1231 + const mappingIds = new Set(mappingData.map((mapping) => mapping.id)); 1232 + setFediverseBridgeStatusByMappingId((previous) => { 1233 + const next = Object.fromEntries( 1234 + Object.entries(previous).filter(([mappingId]) => mappingIds.has(mappingId)), 1235 + ) as Record<string, FediverseBridgeStatusView>; 1236 + return next; 1138 1237 }); 1139 - setUpdateStatus(updateStatusResponse.data); 1140 - setManagedUsers(Array.isArray(usersResponse.data) ? usersResponse.data : []); 1141 - } else { 1142 - setUpdateStatus(null); 1143 - setManagedUsers([]); 1144 - } 1145 1238 1146 - await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); 1147 - await fetchProfiles(mappingData.map((mapping) => mapping.bskyIdentifier)); 1148 - } catch (error) { 1149 - handleAuthFailure(error, 'Failed to load dashboard data.'); 1150 - } 1151 - }, [authHeaders, fetchEnrichedPosts, fetchProfiles, fetchRecentActivity, fetchStatus, handleAuthFailure]); 1239 + await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); 1240 + 1241 + void fetchProfiles(mappingData.map((mapping) => mapping.bskyIdentifier)); 1242 + 1243 + const shouldRefreshBridgeStatuses = 1244 + options?.refreshBridgeStatuses === true || 1245 + mappingData.some((mapping) => fediverseBridgeStatusByMappingIdRef.current[mapping.id] === undefined); 1246 + if (shouldRefreshBridgeStatuses) { 1247 + void fetchFediverseBridgeStatuses(mappingData, { force: options?.refreshBridgeStatuses === true }); 1248 + } 1249 + } catch (error) { 1250 + handleAuthFailure(error, 'Failed to load dashboard data.'); 1251 + } 1252 + }, 1253 + [ 1254 + authHeaders, 1255 + fetchEnrichedPosts, 1256 + fetchFediverseBridgeStatuses, 1257 + fetchProfiles, 1258 + fetchRecentActivity, 1259 + fetchStatus, 1260 + handleAuthFailure, 1261 + ], 1262 + ); 1152 1263 1153 1264 useEffect(() => { 1154 1265 localStorage.setItem('theme-mode', themeMode); ··· 1210 1321 return; 1211 1322 } 1212 1323 1213 - void fetchData(); 1324 + void fetchData({ refreshBridgeStatuses: true }); 1214 1325 }, [token, fetchBootstrapStatus, fetchData]); 1215 1326 1216 1327 useEffect(() => { ··· 1224 1335 return; 1225 1336 } 1226 1337 1338 + const statusPollIntervalMs = activeTab === 'accounts' ? 7000 : 3000; 1339 + const shouldPollActivity = activeTab === 'overview' || activeTab === 'activity'; 1340 + const shouldPollPosts = activeTab === 'overview' || activeTab === 'posts'; 1341 + 1227 1342 const statusInterval = window.setInterval(() => { 1228 1343 void fetchStatus(); 1229 - }, 2000); 1344 + }, statusPollIntervalMs); 1230 1345 1231 - const activityInterval = window.setInterval(() => { 1232 - void fetchRecentActivity(); 1233 - }, 7000); 1346 + const activityInterval = shouldPollActivity 1347 + ? window.setInterval(() => { 1348 + void fetchRecentActivity(); 1349 + }, 7000) 1350 + : null; 1234 1351 1235 - const postsInterval = window.setInterval(() => { 1236 - void fetchEnrichedPosts(); 1237 - }, 12000); 1352 + const postsInterval = shouldPollPosts 1353 + ? window.setInterval(() => { 1354 + void fetchEnrichedPosts(); 1355 + }, 12000) 1356 + : null; 1238 1357 1239 1358 return () => { 1240 1359 window.clearInterval(statusInterval); 1241 - window.clearInterval(activityInterval); 1242 - window.clearInterval(postsInterval); 1360 + if (activityInterval !== null) { 1361 + window.clearInterval(activityInterval); 1362 + } 1363 + if (postsInterval !== null) { 1364 + window.clearInterval(postsInterval); 1365 + } 1243 1366 }; 1244 - }, [token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); 1367 + }, [activeTab, token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); 1245 1368 1246 1369 useEffect(() => { 1247 1370 if (!token) { ··· 1261 1384 }, [token, isAdmin, fetchRuntimeVersion, fetchUpdateStatus]); 1262 1385 1263 1386 useEffect(() => { 1264 - if (!status?.nextCheckTime) { 1387 + if (activeTab !== 'overview' || !status?.nextCheckTime) { 1265 1388 setCountdown('--'); 1266 1389 return; 1267 1390 } ··· 1284 1407 return () => { 1285 1408 window.clearInterval(timer); 1286 1409 }; 1287 - }, [status?.nextCheckTime]); 1410 + }, [activeTab, status?.nextCheckTime]); 1288 1411 1289 1412 useEffect(() => { 1290 1413 return () => { ··· 1329 1452 return; 1330 1453 } 1331 1454 1332 - const candidates = [...new Set(editTwitterUsers.map(normalizeTwitterUsername).filter((username) => username.length > 0))]; 1455 + const candidates = [ 1456 + ...new Set(editTwitterUsers.map(normalizeTwitterUsername).filter((username) => username.length > 0)), 1457 + ]; 1333 1458 const selected = normalizeTwitterUsername(editForm.profileSyncSourceUsername || ''); 1334 1459 if (candidates.length === 0) { 1335 1460 if (editForm.profileSyncSourceUsername !== '') { ··· 1439 1564 } 1440 1565 return mappings.filter((mapping) => mapping.createdByUserId === accountsCreatorFilter); 1441 1566 }, [accountsCreatorFilter, isAdmin, mappings]); 1567 + const bridgedMappingsForView = useMemo( 1568 + () => 1569 + [...accountMappingsForView] 1570 + .filter((mapping) => fediverseBridgeStatusByMappingId[mapping.id]?.bridged === true) 1571 + .sort((a, b) => a.bskyIdentifier.localeCompare(b.bskyIdentifier)), 1572 + [accountMappingsForView, fediverseBridgeStatusByMappingId], 1573 + ); 1574 + const bridgeEligibleUnbridgedMappings = useMemo( 1575 + () => 1576 + accountMappingsForView 1577 + .filter( 1578 + (mapping) => 1579 + canManageAllMappings || 1580 + (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), 1581 + ) 1582 + .filter((mapping) => { 1583 + if (fediverseBridgeStatusByMappingId[mapping.id]?.bridged === true) { 1584 + return false; 1585 + } 1586 + const profile = profilesByActor[normalizeTwitterUsername(mapping.bskyIdentifier)]; 1587 + return canBridgeToFediverse(profile?.createdAt); 1588 + }), 1589 + [ 1590 + accountMappingsForView, 1591 + canManageAllMappings, 1592 + canManageOwnMappings, 1593 + fediverseBridgeStatusByMappingId, 1594 + me?.id, 1595 + profilesByActor, 1596 + ], 1597 + ); 1442 1598 const groupedMappings = useMemo(() => { 1443 1599 const groups = new Map<string, { key: string; name: string; emoji: string; mappings: AccountMapping[] }>(); 1444 1600 for (const option of groupOptions) { ··· 2024 2180 try { 2025 2181 await axios.delete(`/api/mappings/${mappingId}`, { headers: authHeaders }); 2026 2182 setMappings((prev) => prev.filter((mapping) => mapping.id !== mappingId)); 2183 + setFediverseBridgeStatusByMappingId((previous) => { 2184 + if (!(mappingId in previous)) { 2185 + return previous; 2186 + } 2187 + const next = { ...previous }; 2188 + delete next[mappingId]; 2189 + return next; 2190 + }); 2027 2191 showNotice('success', 'Mapping deleted.'); 2028 2192 await fetchData(); 2029 2193 } catch (error) { ··· 2145 2309 if (!authHeaders) { 2146 2310 return; 2147 2311 } 2312 + if (isBridgeAllBusy) { 2313 + showNotice('info', 'Bridge-all is currently running. Please wait.'); 2314 + return; 2315 + } 2148 2316 2149 2317 const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2150 2318 if (candidates.length === 0) { ··· 2257 2425 } 2258 2426 }; 2259 2427 2260 - const handleBridgeToFediverse = async (mapping: AccountMapping) => { 2428 + const bridgeMappingToFediverse = async ( 2429 + mapping: AccountMapping, 2430 + options?: { 2431 + confirm?: boolean; 2432 + showNoticeOnResult?: boolean; 2433 + }, 2434 + ): Promise<{ ok: boolean; newlyBridged: boolean; skippedAsAlreadyBridged?: boolean }> => { 2261 2435 if (!authHeaders) { 2262 - return; 2436 + return { ok: false, newlyBridged: false }; 2263 2437 } 2264 2438 if (!canManageMapping(mapping)) { 2265 - showNotice('error', 'You do not have permission to bridge this mapping.'); 2266 - return; 2439 + if (options?.showNoticeOnResult !== false) { 2440 + showNotice('error', 'You do not have permission to bridge this mapping.'); 2441 + } 2442 + return { ok: false, newlyBridged: false }; 2443 + } 2444 + 2445 + const knownStatus = fediverseBridgeStatusByMappingId[mapping.id]; 2446 + if (knownStatus?.bridged === true) { 2447 + if (options?.showNoticeOnResult !== false) { 2448 + showNotice('info', `${mapping.bskyIdentifier} is already bridged.`); 2449 + } 2450 + return { ok: true, newlyBridged: false, skippedAsAlreadyBridged: true }; 2267 2451 } 2268 2452 2269 2453 const profile = getProfileForActor(mapping.bskyIdentifier); 2270 2454 if (!canBridgeToFediverse(profile?.createdAt)) { 2271 - showNotice('error', 'This Bluesky account must be at least 7 days old before bridging.'); 2272 - return; 2455 + if (options?.showNoticeOnResult !== false) { 2456 + showNotice('error', 'This Bluesky account must be at least 7 days old before bridging.'); 2457 + } 2458 + return { ok: false, newlyBridged: false }; 2273 2459 } 2274 2460 2275 - const confirmed = window.confirm( 2276 - `Bridge ${mapping.bskyIdentifier} to the fediverse now? This follows @ap.brid.gy and posts the bridge announcement.`, 2277 - ); 2278 - if (!confirmed) { 2279 - return; 2461 + if (options?.confirm !== false) { 2462 + const confirmed = window.confirm( 2463 + `Bridge ${mapping.bskyIdentifier} to the fediverse now? This follows @ap.brid.gy and posts the bridge announcement.`, 2464 + ); 2465 + if (!confirmed) { 2466 + return { ok: false, newlyBridged: false }; 2467 + } 2280 2468 } 2281 2469 2282 2470 setBridgingMappingId(mapping.id); ··· 2286 2474 {}, 2287 2475 { headers: authHeaders }, 2288 2476 ); 2477 + setFediverseBridgeStatusByMappingId((previous) => ({ 2478 + ...previous, 2479 + [mapping.id]: { 2480 + bridged: true, 2481 + checkedAt: new Date().toISOString(), 2482 + }, 2483 + })); 2484 + 2485 + if (options?.showNoticeOnResult !== false) { 2486 + showNotice( 2487 + 'success', 2488 + `Fediverse bridge enabled: ${response.data?.fediverseAddress || `@${mapping.bskyIdentifier}@bsky.brid.gy`}`, 2489 + ); 2490 + } 2491 + 2492 + return { ok: true, newlyBridged: true }; 2493 + } catch (error) { 2494 + if (options?.showNoticeOnResult !== false) { 2495 + handleAuthFailure(error, 'Failed to bridge this account to the fediverse.'); 2496 + } 2497 + return { ok: false, newlyBridged: false }; 2498 + } finally { 2499 + setBridgingMappingId((previous) => (previous === mapping.id ? null : previous)); 2500 + } 2501 + }; 2502 + 2503 + const handleBridgeToFediverse = async (mapping: AccountMapping) => { 2504 + const outcome = await bridgeMappingToFediverse(mapping, { 2505 + confirm: true, 2506 + showNoticeOnResult: true, 2507 + }); 2508 + if (outcome.ok && outcome.newlyBridged) { 2509 + await fetchRecentActivity(); 2510 + } 2511 + }; 2512 + 2513 + const handleBridgeAllEligible = async () => { 2514 + if (!authHeaders) { 2515 + return; 2516 + } 2517 + if (isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)) { 2518 + showNotice('info', 'Profile sync is currently running. Please wait before bridge-all.'); 2519 + return; 2520 + } 2521 + 2522 + const manageable = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2523 + if (manageable.length === 0) { 2524 + showNotice('info', 'No accounts available for fediverse bridge.'); 2525 + return; 2526 + } 2527 + 2528 + const refreshedStatuses = await fetchFediverseBridgeStatuses(manageable, { force: true }); 2529 + const refreshedProfiles = await fetchProfiles(manageable.map((mapping) => mapping.bskyIdentifier)); 2530 + const statusLookup = { 2531 + ...fediverseBridgeStatusByMappingId, 2532 + ...(refreshedStatuses || {}), 2533 + }; 2534 + const profileLookup = { 2535 + ...profilesByActor, 2536 + ...(refreshedProfiles || {}), 2537 + }; 2538 + 2539 + const alreadyBridged = manageable.filter((mapping) => statusLookup[mapping.id]?.bridged === true); 2540 + const eligible = manageable.filter((mapping) => { 2541 + if (statusLookup[mapping.id]?.bridged === true) { 2542 + return false; 2543 + } 2544 + const profile = profileLookup[normalizeTwitterUsername(mapping.bskyIdentifier)]; 2545 + return canBridgeToFediverse(profile?.createdAt); 2546 + }); 2547 + 2548 + if (eligible.length === 0) { 2289 2549 showNotice( 2290 - 'success', 2291 - `Fediverse bridge enabled: ${response.data?.fediverseAddress || `@${mapping.bskyIdentifier}@bsky.brid.gy`}`, 2550 + 'info', 2551 + alreadyBridged.length > 0 2552 + ? 'No new accounts to bridge. Eligible accounts are already bridged.' 2553 + : 'No eligible unbridged accounts found (accounts must be at least 7 days old).', 2554 + ); 2555 + return; 2556 + } 2557 + 2558 + const confirmed = window.confirm( 2559 + `Bridge ${eligible.length} eligible unbridged account(s) now? This follows @ap.brid.gy and posts the bridge announcement for each account.`, 2560 + ); 2561 + if (!confirmed) { 2562 + return; 2563 + } 2564 + 2565 + setIsBridgeAllBusy(true); 2566 + const newlyBridgedLabels: string[] = []; 2567 + let failedCount = 0; 2568 + 2569 + try { 2570 + for (const mapping of eligible) { 2571 + const outcome = await bridgeMappingToFediverse(mapping, { 2572 + confirm: false, 2573 + showNoticeOnResult: false, 2574 + }); 2575 + if (outcome.ok && outcome.newlyBridged) { 2576 + newlyBridgedLabels.push(`@${mapping.bskyIdentifier}`); 2577 + } else { 2578 + failedCount += 1; 2579 + } 2580 + } 2581 + 2582 + if (newlyBridgedLabels.length > 0) { 2583 + await fetchRecentActivity(); 2584 + } 2585 + 2586 + const preview = newlyBridgedLabels.slice(0, 6).join(', '); 2587 + const more = newlyBridgedLabels.length > 6 ? ` +${newlyBridgedLabels.length - 6} more` : ''; 2588 + const summary = 2589 + newlyBridgedLabels.length > 0 2590 + ? `Newly bridged (${newlyBridgedLabels.length}): ${preview}${more}.` 2591 + : 'No new accounts were bridged.'; 2592 + 2593 + showNotice( 2594 + failedCount > 0 ? 'info' : 'success', 2595 + `${summary} Already bridged: ${alreadyBridged.length}. Failed: ${failedCount}.`, 2292 2596 ); 2293 - await fetchRecentActivity(); 2294 - } catch (error) { 2295 - handleAuthFailure(error, 'Failed to bridge this account to the fediverse.'); 2296 2597 } finally { 2297 - setBridgingMappingId((previous) => (previous === mapping.id ? null : previous)); 2598 + setIsBridgeAllBusy(false); 2298 2599 } 2299 2600 }; 2300 2601 ··· 2784 3085 groupName: mapping.groupName || '', 2785 3086 groupEmoji: mapping.groupEmoji || '📁', 2786 3087 profileSyncSourceUsername: 2787 - mapping.profileSyncSourceUsername || (mapping.twitterUsernames.length === 1 ? mapping.twitterUsernames[0] || '' : ''), 3088 + mapping.profileSyncSourceUsername || 3089 + (mapping.twitterUsernames.length === 1 ? mapping.twitterUsernames[0] || '' : ''), 2788 3090 }); 2789 3091 setEditTwitterUsers(mapping.twitterUsernames); 2790 3092 setEditTwitterInput(''); ··· 3527 3829 <Button 3528 3830 size="sm" 3529 3831 variant="outline" 3530 - disabled={isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)} 3832 + disabled={isSyncAllProfilesBusy || isBridgeAllBusy || Boolean(syncingProfileMappingId)} 3531 3833 onClick={() => { 3532 3834 void handleSyncAllProfilesFromTwitter(); 3533 3835 }} 3534 3836 > 3535 - {isSyncAllProfilesBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />} 3837 + {isSyncAllProfilesBusy ? ( 3838 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3839 + ) : ( 3840 + <RefreshCw className="mr-2 h-4 w-4" /> 3841 + )} 3536 3842 Sync all 3843 + </Button> 3844 + <Button 3845 + size="sm" 3846 + variant="outline" 3847 + disabled={isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)} 3848 + onClick={() => { 3849 + void handleBridgeAllEligible(); 3850 + }} 3851 + > 3852 + {isBridgeAllBusy ? ( 3853 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3854 + ) : ( 3855 + <Link2 className="mr-2 h-4 w-4" /> 3856 + )} 3857 + Bridge all 3537 3858 </Button> 3538 3859 <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 3539 3860 </div> ··· 3632 3953 </div> 3633 3954 </div> 3634 3955 3956 + <div className="rounded-lg border border-border/70 bg-muted/20 p-3"> 3957 + <div className="flex flex-wrap items-center justify-between gap-2"> 3958 + <div> 3959 + <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> 3960 + Fediverse Bridge 3961 + </p> 3962 + <p className="text-sm text-muted-foreground"> 3963 + {bridgedMappingsForView.length} bridged - {bridgeEligibleUnbridgedMappings.length} eligible to 3964 + bridge now 3965 + </p> 3966 + </div> 3967 + <Badge variant="outline">{bridgedMappingsForView.length} bridged</Badge> 3968 + </div> 3969 + {bridgedMappingsForView.length > 0 ? ( 3970 + <div className="mt-2 flex flex-wrap gap-1.5"> 3971 + {bridgedMappingsForView.slice(0, 24).map((mapping) => { 3972 + const profile = getProfileForActor(mapping.bskyIdentifier); 3973 + const handle = profile?.handle || mapping.bskyIdentifier; 3974 + return ( 3975 + <Badge key={`bridged-list-${mapping.id}`} variant="success"> 3976 + <Link2 className="mr-1 h-3 w-3" />@{handle} 3977 + </Badge> 3978 + ); 3979 + })} 3980 + {bridgedMappingsForView.length > 24 ? ( 3981 + <Badge variant="outline">+{bridgedMappingsForView.length - 24} more</Badge> 3982 + ) : null} 3983 + </div> 3984 + ) : ( 3985 + <p className="mt-2 text-xs text-muted-foreground">No bridged accounts yet.</p> 3986 + )} 3987 + </div> 3988 + 3635 3989 {filteredGroupedMappings.length === 0 ? ( 3636 3990 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 3637 3991 {normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} ··· 3720 4074 const profileUrl = `https://bsky.app/profile/${profileHandle}`; 3721 4075 const canManageThisMapping = canManageMapping(mapping); 3722 4076 const canUseFediverseBridge = canBridgeToFediverse(profile?.createdAt); 4077 + const bridgeStatus = fediverseBridgeStatusByMappingId[mapping.id]; 4078 + const isFediverseBridged = bridgeStatus?.bridged === true; 3723 4079 const bridging = bridgingMappingId === mapping.id; 3724 4080 const mappingGroup = getMappingGroupMeta(mapping); 3725 4081 const syncingProfile = syncingProfileMappingId === mapping.id; ··· 3795 4151 </div> 3796 4152 </td> 3797 4153 <td className="px-2 py-3 align-top"> 3798 - {active ? ( 3799 - <Badge variant="warning">Backfilling</Badge> 3800 - ) : queued ? ( 3801 - <Badge variant="warning"> 3802 - Queued {queuePosition ? `#${queuePosition}` : ''} 3803 - </Badge> 3804 - ) : ( 3805 - <Badge variant="success">Active</Badge> 3806 - )} 4154 + <div className="flex flex-wrap items-center gap-1.5"> 4155 + {active ? ( 4156 + <Badge variant="warning">Backfilling</Badge> 4157 + ) : queued ? ( 4158 + <Badge variant="warning"> 4159 + Queued {queuePosition ? `#${queuePosition}` : ''} 4160 + </Badge> 4161 + ) : ( 4162 + <Badge variant="success">Active</Badge> 4163 + )} 4164 + {isFediverseBridged ? ( 4165 + <Badge variant="success"> 4166 + <Link2 className="mr-1 h-3 w-3" /> 4167 + Bridged 4168 + </Badge> 4169 + ) : null} 4170 + </div> 3807 4171 </td> 3808 4172 <td className="px-2 py-3 align-top"> 3809 4173 <div className="flex flex-wrap justify-end gap-1"> ··· 3813 4177 disabled={ 3814 4178 !canManageThisMapping || 3815 4179 !canManageGroupsPermission || 4180 + isBridgeAllBusy || 3816 4181 isSyncAllProfilesBusy || 3817 4182 Boolean(syncingProfileMappingId) 3818 4183 } ··· 3841 4206 className={cn(selectClassName, 'h-9 w-44 px-2 py-1 text-xs')} 3842 4207 value={mapping.profileSyncSourceUsername || ''} 3843 4208 disabled={ 4209 + isBridgeAllBusy || 3844 4210 isSyncAllProfilesBusy || 3845 4211 Boolean(syncingProfileMappingId) || 3846 4212 Boolean(bridgingMappingId) ··· 3851 4217 > 3852 4218 <option value="">Select sync source</option> 3853 4219 {mapping.twitterUsernames.map((username) => ( 3854 - <option key={`sync-source-${mapping.id}-${username}`} value={username}> 4220 + <option 4221 + key={`sync-source-${mapping.id}-${username}`} 4222 + value={username} 4223 + > 3855 4224 @{username} 3856 4225 </option> 3857 4226 ))} ··· 3860 4229 <Button 3861 4230 variant="outline" 3862 4231 size="sm" 4232 + disabled={isBridgeAllBusy || Boolean(syncingProfileMappingId)} 3863 4233 onClick={() => startEditMapping(mapping)} 3864 4234 > 3865 4235 Edit ··· 3868 4238 variant="outline" 3869 4239 size="sm" 3870 4240 disabled={ 4241 + isBridgeAllBusy || 3871 4242 Boolean(syncingProfileMappingId) || 3872 4243 isSyncAllProfilesBusy || 3873 4244 Boolean(bridgingMappingId) ··· 3883 4254 )} 3884 4255 Sync Profile 3885 4256 </Button> 3886 - {canUseFediverseBridge ? ( 4257 + {canUseFediverseBridge && !isFediverseBridged ? ( 3887 4258 <Button 3888 4259 variant="outline" 3889 4260 size="sm" 3890 4261 disabled={ 4262 + isBridgeAllBusy || 3891 4263 Boolean(bridgingMappingId) || 3892 4264 Boolean(syncingProfileMappingId) || 3893 4265 isSyncAllProfilesBusy ··· 3909 4281 <Button 3910 4282 variant="outline" 3911 4283 size="sm" 3912 - disabled={Boolean(syncingProfileMappingId) || isSyncAllProfilesBusy} 4284 + disabled={ 4285 + isBridgeAllBusy || 4286 + Boolean(syncingProfileMappingId) || 4287 + isSyncAllProfilesBusy 4288 + } 3913 4289 onClick={() => { 3914 4290 void requestBackfill(mapping.id, 'normal'); 3915 4291 }} ··· 3920 4296 <Button 3921 4297 variant="ghost" 3922 4298 size="sm" 3923 - disabled={Boolean(syncingProfileMappingId) || isSyncAllProfilesBusy} 4299 + disabled={ 4300 + isBridgeAllBusy || 4301 + Boolean(syncingProfileMappingId) || 4302 + isSyncAllProfilesBusy 4303 + } 3924 4304 onClick={() => { 3925 4305 void cancelQueuedBackfill(mapping.id); 3926 4306 }} ··· 3932 4312 <Button 3933 4313 variant="subtle" 3934 4314 size="sm" 3935 - disabled={Boolean(syncingProfileMappingId) || isSyncAllProfilesBusy} 4315 + disabled={ 4316 + isBridgeAllBusy || 4317 + Boolean(syncingProfileMappingId) || 4318 + isSyncAllProfilesBusy 4319 + } 3936 4320 onClick={() => { 3937 4321 void requestBackfill(mapping.id, 'reset'); 3938 4322 }} ··· 3946 4330 <Button 3947 4331 variant="destructive" 3948 4332 size="sm" 3949 - disabled={Boolean(syncingProfileMappingId) || isSyncAllProfilesBusy} 4333 + disabled={ 4334 + isBridgeAllBusy || 4335 + Boolean(syncingProfileMappingId) || 4336 + isSyncAllProfilesBusy 4337 + } 3950 4338 onClick={() => { 3951 4339 void handleDeleteAllPosts(mapping.id); 3952 4340 }} ··· 3960 4348 <Button 3961 4349 variant="ghost" 3962 4350 size="sm" 3963 - disabled={Boolean(syncingProfileMappingId) || isSyncAllProfilesBusy} 4351 + disabled={ 4352 + isBridgeAllBusy || 4353 + Boolean(syncingProfileMappingId) || 4354 + isSyncAllProfilesBusy 4355 + } 3964 4356 onClick={() => { 3965 4357 void handleDeleteMapping(mapping.id); 3966 4358 }}