alf: the atproto Latency Fabric alf.fly.dev/
10
fork

Configure Feed

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

Fix demo loading spinner hang

- Wrap boot handler body in try/catch so render() always runs, even on unexpected errors
- Add 8s timeout to oauthClient.init() via Promise.race to prevent indefinite hang
- Add 5s AbortSignal timeout to fetch('/api/config')
- Use optional chaining in updateScheduleFormVisibility show() helper to avoid crash on missing elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

blaine.bsky.social 902d9836 b7b3bf19

verified
+530 -31
+530 -31
demo/client/index.ts
··· 13 13 let session: OAuthSession | null = null; 14 14 let postsInterval: ReturnType<typeof setInterval> | null = null; 15 15 let lastPosts: unknown[] = []; 16 + let lastSchedules: unknown[] = []; 16 17 let editingUri: string | null = null; 17 18 let userLabel = ''; 19 + let activeTab: 'timed' | 'webhook' | 'recurring' = 'timed'; 20 + let outerTab: 'create' | 'drafts' | 'delivered' = 'create'; 18 21 19 22 // --------------------------------------------------------------------------- 20 23 // Helpers ··· 89 92 const did = session.sub; 90 93 userLabel = await resolveUserLabel(did); 91 94 92 - // Check whether ALF has an active session for this user 93 95 let alfAuthorized = false; 94 96 try { 95 97 const response = await alfFetch('/oauth/status'); ··· 113 115 document.getElementById('did-label-3')!.textContent = userLabel; 114 116 showView('view-authorized'); 115 117 await loadPosts(); 118 + await loadSchedules(); 116 119 if (postsInterval) clearInterval(postsInterval); 117 - postsInterval = setInterval(loadPosts, 5000); 120 + postsInterval = setInterval(async () => { 121 + await loadPosts(); 122 + await loadSchedules(); 123 + }, 5000); 118 124 } 119 125 120 126 // --------------------------------------------------------------------------- ··· 140 146 141 147 try { 142 148 await oauthClient!.signInRedirect(handle); 143 - // signInRedirect navigates away, so nothing below this runs 144 149 } catch (err) { 145 150 const message = err instanceof Error ? err.message : String(err); 146 151 errEl.textContent = message || 'Login failed.'; ··· 195 200 setPreset(null, 'preset-draft'); 196 201 }); 197 202 198 - // Clear active state when user manually edits the time input 199 203 input.addEventListener('input', () => { 200 204 presets.forEach(btn => btn.classList.remove('active')); 201 205 }); ··· 248 252 timeInput.value = scheduledAt ? isoToDatetimeLocal(scheduledAt) : ''; 249 253 } 250 254 251 - formTitle.textContent = 'Edit Post'; 255 + switchOuterTab('create'); 256 + switchTab('timed'); 257 + formTitle.textContent = '✎ Editing post'; 258 + formTitle.classList.remove('hidden'); 252 259 scheduleBtn.textContent = 'Save changes'; 253 260 cancelBtn.classList.remove('hidden'); 254 261 255 - // Clear preset active state — the loaded time doesn't match a preset 256 262 document.querySelectorAll<HTMLButtonElement>('.btn-preset').forEach(b => b.classList.remove('active')); 257 - 258 - // Scroll the form into view 259 263 formTitle.scrollIntoView({ behavior: 'smooth', block: 'start' }); 260 - 261 264 renderPosts(); 262 265 } 263 266 ··· 265 268 editingUri = null; 266 269 (document.getElementById('post-text') as HTMLTextAreaElement).value = ''; 267 270 (document.getElementById('scheduled-at') as HTMLInputElement).value = ''; 268 - document.getElementById('form-title')!.textContent = 'Schedule a Post'; 269 - (document.getElementById('schedule-btn') as HTMLButtonElement).textContent = 'Schedule Post'; 271 + (document.getElementById('form-title') as HTMLElement).classList.add('hidden'); 270 272 document.getElementById('cancel-edit-btn')!.classList.add('hidden'); 271 273 document.querySelectorAll<HTMLButtonElement>('.btn-preset').forEach(b => b.classList.remove('active')); 274 + switchTab(activeTab); 272 275 renderPosts(); 273 276 } 274 277 ··· 294 297 const urlRegex = /https?:\/\/[^\s\]>)'"<]+/g; 295 298 let m: RegExpExecArray | null; 296 299 while ((m = urlRegex.exec(text)) !== null) { 297 - const url = m[0].replace(/[.,;:!?'")\]]+$/, ''); // trim trailing punctuation 300 + const url = m[0].replace(/[.,;:!?'")\]]+$/, ''); 298 301 const byteStart = byteOffset(m.index); 299 302 const byteEnd = byteStart + encoder.encode(url).length; 300 303 facets.push({ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] }); ··· 330 333 } 331 334 332 335 // --------------------------------------------------------------------------- 336 + // Tab switching 337 + // --------------------------------------------------------------------------- 338 + 339 + function switchOuterTab(tab: 'create' | 'drafts' | 'delivered'): void { 340 + outerTab = tab; 341 + document.querySelectorAll('[data-outer-tab]').forEach(t => t.classList.remove('active')); 342 + document.querySelector(`[data-outer-tab="${tab}"]`)?.classList.add('active'); 343 + (document.getElementById('outer-pane-create') as HTMLElement).classList.toggle('hidden', tab !== 'create'); 344 + (document.getElementById('outer-pane-drafts') as HTMLElement).classList.toggle('hidden', tab !== 'drafts'); 345 + (document.getElementById('outer-pane-delivered') as HTMLElement).classList.toggle('hidden', tab !== 'delivered'); 346 + if (tab === 'drafts' || tab === 'delivered') void loadPosts(); 347 + } 348 + 349 + function switchTab(tab: 'timed' | 'webhook' | 'recurring'): void { 350 + activeTab = tab; 351 + 352 + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 353 + document.querySelector(`.tab[data-tab="${tab}"]`)?.classList.add('active'); 354 + 355 + const isPost = tab === 'timed' || tab === 'webhook'; 356 + (document.getElementById('post-form-fields') as HTMLElement).classList.toggle('hidden', !isPost); 357 + (document.getElementById('timed-schedule-section') as HTMLElement).classList.toggle('hidden', tab !== 'timed'); 358 + (document.getElementById('webhook-note') as HTMLElement).classList.toggle('hidden', tab !== 'webhook'); 359 + (document.getElementById('recurring-section') as HTMLElement).classList.toggle('hidden', tab !== 'recurring'); 360 + 361 + const btn = document.getElementById('schedule-btn') as HTMLButtonElement; 362 + if (btn && !editingUri) { 363 + btn.textContent = tab === 'webhook' ? 'Create Webhook Draft' : 'Schedule Post'; 364 + } 365 + } 366 + 367 + // --------------------------------------------------------------------------- 333 368 // Schedule / update post 334 369 // --------------------------------------------------------------------------- 335 370 ··· 338 373 const cancelBtn = document.getElementById('cancel-edit-btn') as HTMLButtonElement; 339 374 340 375 cancelBtn.addEventListener('click', () => cancelEdit()); 376 + 377 + document.querySelectorAll('[data-outer-tab]').forEach(tabEl => { 378 + tabEl.addEventListener('click', () => { 379 + switchOuterTab((tabEl as HTMLElement).dataset.outerTab as 'create' | 'drafts' | 'delivered'); 380 + }); 381 + }); 382 + 383 + document.querySelectorAll('[data-tab]').forEach(tabEl => { 384 + tabEl.addEventListener('click', () => { 385 + switchTab((tabEl as HTMLElement).dataset.tab as 'timed' | 'webhook' | 'recurring'); 386 + }); 387 + }); 341 388 342 389 btn.addEventListener('click', async () => { 343 390 if (editingUri) { ··· 352 399 const btn = document.getElementById('schedule-btn') as HTMLButtonElement; 353 400 const text = (document.getElementById('post-text') as HTMLTextAreaElement).value.trim(); 354 401 const scheduledAtValue = (document.getElementById('scheduled-at') as HTMLInputElement).value; 402 + const isTriggerMode = activeTab === 'webhook'; 355 403 const imageInput = document.getElementById('image-input') as HTMLInputElement; 356 404 const imageFile = imageInput.files?.[0] ?? null; 357 405 const successEl = document.getElementById('schedule-success') as HTMLElement; ··· 394 442 }; 395 443 } 396 444 397 - btn.textContent = 'Scheduling...'; 445 + btn.textContent = isTriggerMode ? 'Creating...' : 'Scheduling...'; 398 446 const headers: Record<string, string> = { 'Content-Type': 'application/json' }; 399 - if (scheduledAtValue) { 447 + if (isTriggerMode) { 448 + headers['x-trigger'] = 'webhook'; 449 + } else if (scheduledAtValue) { 400 450 headers['x-scheduled-at'] = new Date(scheduledAtValue).toISOString(); 401 451 } 402 452 ··· 419 469 }), 420 470 }); 421 471 422 - const data = await response.json() as { uri?: string; error?: string; message?: string }; 472 + const data = await response.json() as { uri?: string; triggerUrl?: string; error?: string; message?: string }; 423 473 424 474 if (!response.ok) { 425 475 errEl.textContent = data.error || data.message || 'Failed to schedule post.'; 426 476 errEl.classList.remove('hidden'); 427 477 } else { 428 - successEl.textContent = `Post scheduled! URI: ${data.uri || JSON.stringify(data)}`; 478 + if (data.triggerUrl) { 479 + const safeUrl = escHtml(data.triggerUrl); 480 + successEl.innerHTML = `Draft created! Call this URL (POST) to publish on demand:<br><code style="display:block;word-break:break-all;font-size:0.78rem;margin-top:0.3rem;padding:0.3rem 0;">${safeUrl}</code>`; 481 + } else { 482 + successEl.textContent = `Post scheduled! URI: ${data.uri ?? ''}`; 483 + } 429 484 successEl.classList.remove('hidden'); 430 485 (document.getElementById('post-text') as HTMLTextAreaElement).value = ''; 431 486 (document.getElementById('scheduled-at') as HTMLInputElement).value = ''; ··· 434 489 (document.getElementById('image-preview-wrap') as HTMLElement).classList.add('hidden'); 435 490 await loadPosts(); 436 491 } 437 - } catch (err) { 438 - const errEl2 = document.getElementById('schedule-error') as HTMLElement; 439 - errEl2.textContent = 'Network error.'; 440 - errEl2.classList.remove('hidden'); 492 + } catch (_) { 493 + errEl.textContent = 'Network error.'; 494 + errEl.classList.remove('hidden'); 441 495 } finally { 442 496 btn.disabled = false; 443 - btn.textContent = 'Schedule Post'; 497 + btn.textContent = activeTab === 'webhook' ? 'Create Webhook Draft' : 'Schedule Post'; 444 498 } 445 499 } 446 500 ··· 515 569 lastPosts = (data as any).posts || (data as any).drafts || []; 516 570 } 517 571 renderPosts(); 572 + renderDelivered(); 518 573 } catch (_) { 519 574 listEl.innerHTML = '<div class="empty-state">Error loading posts.</div>'; 520 575 } 521 576 } 522 577 578 + function wireCopyTrigger(container: Element): void { 579 + container.querySelectorAll('[data-action="copy-trigger"]').forEach(btn => { 580 + btn.addEventListener('click', () => { 581 + const url = (btn as HTMLElement).dataset.url!; 582 + void navigator.clipboard.writeText(url).then(() => { 583 + (btn as HTMLElement).textContent = 'Copied!'; 584 + setTimeout(() => { (btn as HTMLElement).textContent = 'Copy'; }, 2000); 585 + }); 586 + }); 587 + }); 588 + } 589 + 523 590 function renderPosts(): void { 524 591 const listEl = document.getElementById('posts-list')!; 525 - if (!Array.isArray(lastPosts) || lastPosts.length === 0) { 592 + const active = Array.isArray(lastPosts) 593 + ? lastPosts.filter((p: any) => p.status !== 'published') 594 + : []; 595 + if (active.length === 0) { 526 596 listEl.innerHTML = '<div class="empty-state">No scheduled posts yet.</div>'; 527 597 return; 528 598 } 529 599 530 - listEl.innerHTML = lastPosts.map(post => renderPostCard(post as Record<string, any>)).join(''); 600 + listEl.innerHTML = active.map(post => renderPostCard(post as Record<string, any>)).join(''); 531 601 532 602 listEl.querySelectorAll('[data-action="publish"]').forEach(btn => { 533 603 btn.addEventListener('click', () => publishPost((btn as HTMLElement).dataset.uri!)); ··· 538 608 listEl.querySelectorAll('[data-action="edit"]').forEach(btn => { 539 609 btn.addEventListener('click', () => startEdit((btn as HTMLElement).dataset.uri!)); 540 610 }); 611 + wireCopyTrigger(listEl); 612 + } 613 + 614 + function renderDelivered(): void { 615 + const listEl = document.getElementById('delivered-list')!; 616 + const delivered = Array.isArray(lastPosts) 617 + ? lastPosts.filter((p: any) => p.status === 'published') 618 + : []; 619 + if (delivered.length === 0) { 620 + listEl.innerHTML = '<div class="empty-state">No delivered posts yet.</div>'; 621 + return; 622 + } 623 + listEl.innerHTML = delivered.map(post => renderPostCard(post as Record<string, any>)).join(''); 624 + wireCopyTrigger(listEl); 541 625 } 542 626 543 627 function renderPostCard(post: Record<string, any>): string { ··· 563 647 564 648 const bskyUrl = status === 'published' ? atUriToBskyUrl(uri) : null; 565 649 650 + const scheduleIdBadge = post.scheduleId 651 + ? `<span class="badge badge-recurring" title="Part of recurring schedule">recurring</span>` 652 + : ''; 653 + 654 + const triggerUrlHtml = post.triggerUrl 655 + ? `<div class="trigger-url-box"> 656 + <span class="trigger-url-label">Trigger URL:</span> 657 + <code class="trigger-url-value">${escHtml(post.triggerUrl as string)}</code> 658 + <button class="btn btn-outline" data-action="copy-trigger" data-url="${escHtml(post.triggerUrl as string)}" style="padding:0.2rem 0.5rem;font-size:0.72rem;flex-shrink:0;">Copy</button> 659 + </div>` 660 + : ''; 661 + 566 662 return ` 567 663 <div class="post-item${isEditing ? ' post-item-editing' : ''}"> 568 664 <div class="post-item-header"> 569 665 <span class="badge ${badgeCls}">${status}</span> 666 + ${scheduleIdBadge} 570 667 ${isEditing ? '<span style="font-size:0.72rem;color:var(--indigo);font-weight:600;margin-left:auto;">editing ↑</span>' : ''} 571 668 </div> 572 669 <div class="post-text">${escHtml(preview) || '<em style="color:var(--text-faint)">(no text)</em>'}</div> 573 670 <div class="post-meta">${scheduledTime}</div> 671 + ${triggerUrlHtml} 574 672 <div class="post-actions"> 575 673 ${bskyUrl ? `<a href="${bskyUrl}" target="_blank" rel="noopener" class="post-bsky-link">View on Bluesky →</a>` : ''} 576 674 ${canEdit && !isEditing ? ` ··· 617 715 } 618 716 619 717 // --------------------------------------------------------------------------- 718 + // Recurring Schedules 719 + // --------------------------------------------------------------------------- 720 + 721 + async function loadSchedules(): Promise<void> { 722 + const listEl = document.getElementById('schedules-list'); 723 + if (!listEl) return; 724 + try { 725 + const response = await alfFetch( 726 + `/xrpc/town.roundabout.scheduledPosts.listSchedules?repo=${encodeURIComponent(session!.sub)}`, 727 + ); 728 + if (!response.ok) { 729 + listEl.innerHTML = '<div class="empty-state">Could not load schedules.</div>'; 730 + return; 731 + } 732 + const data = await response.json() as { schedules?: unknown[] }; 733 + lastSchedules = data.schedules ?? []; 734 + renderSchedules(); 735 + } catch (_) { 736 + listEl.innerHTML = '<div class="empty-state">Error loading schedules.</div>'; 737 + } 738 + } 739 + 740 + function renderSchedules(): void { 741 + const listEl = document.getElementById('schedules-list'); 742 + if (!listEl) return; 743 + if (!Array.isArray(lastSchedules) || lastSchedules.length === 0) { 744 + listEl.innerHTML = '<div class="empty-state">No recurring schedules yet.</div>'; 745 + return; 746 + } 747 + 748 + listEl.innerHTML = lastSchedules.map(s => renderScheduleCard(s as Record<string, any>)).join(''); 749 + 750 + listEl.querySelectorAll('[data-action="pause-schedule"]').forEach(btn => { 751 + btn.addEventListener('click', () => pauseSchedule((btn as HTMLElement).dataset.id!)); 752 + }); 753 + listEl.querySelectorAll('[data-action="resume-schedule"]').forEach(btn => { 754 + btn.addEventListener('click', () => resumeSchedule((btn as HTMLElement).dataset.id!)); 755 + }); 756 + listEl.querySelectorAll('[data-action="delete-schedule"]').forEach(btn => { 757 + btn.addEventListener('click', () => deleteScheduleItem((btn as HTMLElement).dataset.id!)); 758 + }); 759 + } 760 + 761 + function describeRule(rule: Record<string, any>, timezone: string): string { 762 + const time = (rule.time as Record<string, any>) || {}; 763 + const hour: number = time.hour ?? 0; 764 + const minute: number = time.minute ?? 0; 765 + const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; 766 + const tz = timezone || (time.timezone as string) || 'UTC'; 767 + 768 + if (rule.type === 'daily') { 769 + const interval: number = rule.interval ?? 1; 770 + return interval === 1 771 + ? `Daily at ${timeStr} ${tz}` 772 + : `Every ${interval} days at ${timeStr} ${tz}`; 773 + } 774 + if (rule.type === 'weekly') { 775 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 776 + const days = ((rule.daysOfWeek as number[]) ?? []).map((d: number) => dayNames[d] ?? String(d)).join(', '); 777 + return `Weekly on ${days} at ${timeStr} ${tz}`; 778 + } 779 + if (rule.type === 'monthly_on_day') { 780 + return `Monthly on day ${rule.dayOfMonth as number} at ${timeStr} ${tz}`; 781 + } 782 + if (rule.type === 'monthly_nth_weekday') { 783 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 784 + const nth = (rule.nth as number) === -1 ? 'last' : `${rule.nth as number}th`; 785 + return `Monthly, ${nth} ${dayNames[rule.weekday as number] ?? String(rule.weekday)} at ${timeStr} ${tz}`; 786 + } 787 + if (rule.type === 'monthly_last_business_day') { 788 + return `Monthly, last business day at ${timeStr} ${tz}`; 789 + } 790 + if (rule.type === 'yearly_on_month_day') { 791 + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 792 + const monthName = monthNames[(rule.month as number) - 1] ?? String(rule.month); 793 + return `Yearly on ${monthName} ${rule.dayOfMonth as number} at ${timeStr} ${tz}`; 794 + } 795 + if (rule.type === 'yearly_nth_weekday') { 796 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 797 + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 798 + const nth = (rule.nth as number) === -1 ? 'last' : `${rule.nth as number}th`; 799 + const monthName = monthNames[(rule.month as number) - 1] ?? String(rule.month); 800 + return `Yearly, ${nth} ${dayNames[rule.weekday as number] ?? String(rule.weekday)} of ${monthName} at ${timeStr} ${tz}`; 801 + } 802 + if (rule.type === 'quarterly_last_weekday') { 803 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 804 + return `Quarterly, last ${dayNames[rule.weekday as number] ?? String(rule.weekday)} at ${timeStr} ${tz}`; 805 + } 806 + if (rule.type === 'once') { 807 + return `Once at ${new Date(rule.datetime as string).toLocaleString()}`; 808 + } 809 + return `${rule.type as string} schedule`; 810 + } 811 + 812 + function renderScheduleCard(schedule: Record<string, any>): string { 813 + const status: string = schedule.status || 'active'; 814 + const statusBadge: Record<string, string> = { 815 + active: 'badge-published', 816 + paused: 'badge-draft', 817 + cancelled: 'badge-pending', 818 + completed: 'badge-scheduled', 819 + error: 'badge-failed', 820 + }; 821 + const badgeCls = statusBadge[status] || 'badge-pending'; 822 + 823 + const rule = (schedule.recurrenceRule as Record<string, any>) || {}; 824 + const ruleCore = (rule.rule as Record<string, any>) || {}; 825 + const ruleDesc = describeRule(ruleCore, schedule.timezone as string); 826 + 827 + const fireCount: number = schedule.fireCount ?? 0; 828 + const lastFired = schedule.lastFiredAt 829 + ? new Date(schedule.lastFiredAt as string).toLocaleString() 830 + : null; 831 + 832 + const id: string = schedule.id || ''; 833 + const staticText: string = (schedule.record as Record<string, any> | undefined)?.text ?? ''; 834 + const preview = staticText ? (staticText.length > 80 ? staticText.slice(0, 80) + '…' : staticText) : ''; 835 + 836 + return ` 837 + <div class="schedule-item"> 838 + <div class="post-item-header"> 839 + <span class="badge ${badgeCls}">${escHtml(status)}</span> 840 + <span style="font-size:0.82rem;color:var(--text);font-weight:500;">${escHtml(ruleDesc)}</span> 841 + </div> 842 + ${preview ? `<div class="post-text" style="font-size:0.82rem;">${escHtml(preview)}</div>` : ''} 843 + ${schedule.contentUrl ? `<div class="post-meta">Dynamic content: <code>${escHtml(schedule.contentUrl as string)}</code></div>` : ''} 844 + <div class="post-meta"> 845 + Fired ${fireCount} time${fireCount === 1 ? '' : 's'}${lastFired ? ` · Last: ${lastFired}` : ''} 846 + </div> 847 + <div class="post-actions"> 848 + ${status === 'active' ? `<button class="btn btn-outline" data-action="pause-schedule" data-id="${escHtml(id)}">Pause</button>` : ''} 849 + ${status === 'paused' ? `<button class="btn btn-outline" data-action="resume-schedule" data-id="${escHtml(id)}">Resume</button>` : ''} 850 + ${status !== 'cancelled' && status !== 'completed' ? `<button class="btn btn-danger" data-action="delete-schedule" data-id="${escHtml(id)}">Delete</button>` : ''} 851 + </div> 852 + </div>`; 853 + } 854 + 855 + async function pauseSchedule(id: string): Promise<void> { 856 + try { 857 + const response = await alfFetch('/xrpc/town.roundabout.scheduledPosts.updateSchedule', { 858 + method: 'POST', 859 + headers: { 'Content-Type': 'application/json' }, 860 + body: JSON.stringify({ id, status: 'paused' }), 861 + }); 862 + const data = await response.json() as { error?: string }; 863 + if (!response.ok) alert(data.error || 'Failed to pause schedule.'); 864 + await loadSchedules(); 865 + } catch (_) { 866 + alert('Network error.'); 867 + } 868 + } 869 + 870 + async function resumeSchedule(id: string): Promise<void> { 871 + try { 872 + const response = await alfFetch('/xrpc/town.roundabout.scheduledPosts.updateSchedule', { 873 + method: 'POST', 874 + headers: { 'Content-Type': 'application/json' }, 875 + body: JSON.stringify({ id, status: 'active' }), 876 + }); 877 + const data = await response.json() as { error?: string }; 878 + if (!response.ok) alert(data.error || 'Failed to resume schedule.'); 879 + await loadSchedules(); 880 + await loadPosts(); 881 + } catch (_) { 882 + alert('Network error.'); 883 + } 884 + } 885 + 886 + async function deleteScheduleItem(id: string): Promise<void> { 887 + if (!confirm('Delete this recurring schedule and cancel its pending draft?')) return; 888 + try { 889 + const response = await alfFetch('/xrpc/town.roundabout.scheduledPosts.deleteSchedule', { 890 + method: 'POST', 891 + headers: { 'Content-Type': 'application/json' }, 892 + body: JSON.stringify({ id }), 893 + }); 894 + const data = await response.json() as { error?: string }; 895 + if (!response.ok) alert(data.error || 'Failed to delete schedule.'); 896 + await loadSchedules(); 897 + await loadPosts(); 898 + } catch (_) { 899 + alert('Network error.'); 900 + } 901 + } 902 + 903 + const INTERVAL_UNITS: Record<string, string> = { 904 + daily: 'days', 905 + weekly: 'weeks', 906 + monthly: 'months', 907 + quarterly: 'quarters', 908 + yearly: 'years', 909 + }; 910 + 911 + function updateScheduleFormVisibility(): void { 912 + const type = (document.getElementById('sched-type') as HTMLSelectElement).value; 913 + const monthlyPattern = (document.getElementById('sched-monthly-pattern') as HTMLSelectElement).value; 914 + const yearlyPattern = (document.getElementById('sched-yearly-pattern') as HTMLSelectElement).value; 915 + 916 + const show = (id: string, visible: boolean) => 917 + document.getElementById(id)?.classList.toggle('hidden', !visible); 918 + 919 + // Interval unit label 920 + const unitEl = document.getElementById('sched-interval-unit'); 921 + if (unitEl) unitEl.textContent = INTERVAL_UNITS[type] ?? ''; 922 + 923 + // Hide the nth-col (ordinal) for quarterly — it only has "last" 924 + const nthCol = document.getElementById('sched-nth-col'); 925 + if (nthCol) nthCol.classList.toggle('hidden', type === 'quarterly'); 926 + 927 + show('sched-weekly-opts', type === 'weekly'); 928 + show('sched-monthly-opts', type === 'monthly'); 929 + show('sched-yearly-opts', type === 'yearly'); 930 + 931 + // Month selector: yearly only 932 + show('sched-month-row', type === 'yearly'); 933 + 934 + // Day of month: monthly on_day OR yearly on_month_day 935 + const showDom = (type === 'monthly' && monthlyPattern === 'on_day') || 936 + (type === 'yearly' && yearlyPattern === 'on_month_day'); 937 + show('sched-dom-row', showDom); 938 + 939 + // Nth + weekday: monthly nth_weekday, quarterly (all), yearly nth_weekday 940 + const showNthWeekday = (type === 'monthly' && monthlyPattern === 'nth_weekday') || 941 + type === 'quarterly' || 942 + (type === 'yearly' && yearlyPattern === 'nth_weekday'); 943 + show('sched-nth-weekday-row', showNthWeekday); 944 + } 945 + 946 + function wireCreateScheduleForm(): void { 947 + const typeSelect = document.getElementById('sched-type') as HTMLSelectElement; 948 + const monthlyPatternSelect = document.getElementById('sched-monthly-pattern') as HTMLSelectElement; 949 + const yearlyPatternSelect = document.getElementById('sched-yearly-pattern') as HTMLSelectElement; 950 + 951 + typeSelect.addEventListener('change', updateScheduleFormVisibility); 952 + monthlyPatternSelect.addEventListener('change', updateScheduleFormVisibility); 953 + yearlyPatternSelect.addEventListener('change', updateScheduleFormVisibility); 954 + 955 + // Set initial state 956 + updateScheduleFormVisibility(); 957 + 958 + document.getElementById('create-schedule-btn')!.addEventListener('click', performCreateSchedule); 959 + } 960 + 961 + async function performCreateSchedule(): Promise<void> { 962 + const btn = document.getElementById('create-schedule-btn') as HTMLButtonElement; 963 + const successEl = document.getElementById('sched-success') as HTMLElement; 964 + const errEl = document.getElementById('sched-error') as HTMLElement; 965 + 966 + successEl.classList.add('hidden'); 967 + errEl.classList.add('hidden'); 968 + 969 + const text = (document.getElementById('sched-text') as HTMLTextAreaElement).value.trim(); 970 + const type = (document.getElementById('sched-type') as HTMLSelectElement).value; 971 + const interval = parseInt((document.getElementById('sched-interval') as HTMLInputElement).value, 10) || 1; 972 + const monthlyPattern = (document.getElementById('sched-monthly-pattern') as HTMLSelectElement).value; 973 + const yearlyPattern = (document.getElementById('sched-yearly-pattern') as HTMLSelectElement).value; 974 + const hour = parseInt((document.getElementById('sched-hour') as HTMLInputElement).value, 10); 975 + const minute = parseInt((document.getElementById('sched-minute') as HTMLInputElement).value, 10); 976 + const timezone = (document.getElementById('sched-tz') as HTMLSelectElement).value; 977 + 978 + if (!text) { 979 + errEl.textContent = 'Post text is required.'; 980 + errEl.classList.remove('hidden'); 981 + return; 982 + } 983 + 984 + const timeSpec = { type: 'wall_time', hour, minute, timezone }; 985 + const intervalOpt = interval > 1 ? { interval } : {}; 986 + let ruleCore: Record<string, unknown>; 987 + 988 + if (type === 'daily') { 989 + ruleCore = { type: 'daily', ...intervalOpt, time: timeSpec }; 990 + 991 + } else if (type === 'weekly') { 992 + const checkedDays = Array.from( 993 + document.querySelectorAll<HTMLInputElement>('input[name="sched-day"]:checked'), 994 + ).map(cb => parseInt(cb.value, 10)); 995 + if (checkedDays.length === 0) { 996 + errEl.textContent = 'Select at least one day of the week.'; 997 + errEl.classList.remove('hidden'); 998 + return; 999 + } 1000 + ruleCore = { type: 'weekly', ...intervalOpt, daysOfWeek: checkedDays, time: timeSpec }; 1001 + 1002 + } else if (type === 'monthly') { 1003 + if (monthlyPattern === 'on_day') { 1004 + const dom = parseInt((document.getElementById('sched-dom') as HTMLInputElement).value, 10); 1005 + ruleCore = { type: 'monthly_on_day', ...intervalOpt, dayOfMonth: dom, time: timeSpec }; 1006 + } else if (monthlyPattern === 'nth_weekday') { 1007 + const nth = parseInt((document.getElementById('sched-nth') as HTMLSelectElement).value, 10); 1008 + const weekday = parseInt((document.getElementById('sched-weekday') as HTMLSelectElement).value, 10); 1009 + ruleCore = { type: 'monthly_nth_weekday', ...intervalOpt, nth, weekday, time: timeSpec }; 1010 + } else { 1011 + // last_business_day 1012 + ruleCore = { type: 'monthly_last_business_day', ...intervalOpt, time: timeSpec }; 1013 + } 1014 + 1015 + } else if (type === 'quarterly') { 1016 + const weekday = parseInt((document.getElementById('sched-weekday') as HTMLSelectElement).value, 10); 1017 + ruleCore = { type: 'quarterly_last_weekday', ...intervalOpt, weekday, time: timeSpec }; 1018 + 1019 + } else if (type === 'yearly') { 1020 + const month = parseInt((document.getElementById('sched-month') as HTMLSelectElement).value, 10); 1021 + if (yearlyPattern === 'on_month_day') { 1022 + const dom = parseInt((document.getElementById('sched-dom') as HTMLInputElement).value, 10); 1023 + ruleCore = { type: 'yearly_on_month_day', ...intervalOpt, month, dayOfMonth: dom, time: timeSpec }; 1024 + } else { 1025 + const nth = parseInt((document.getElementById('sched-nth') as HTMLSelectElement).value, 10); 1026 + const weekday = parseInt((document.getElementById('sched-weekday') as HTMLSelectElement).value, 10); 1027 + ruleCore = { type: 'yearly_nth_weekday', ...intervalOpt, month, nth, weekday, time: timeSpec }; 1028 + } 1029 + 1030 + } else { 1031 + ruleCore = { type, time: timeSpec }; 1032 + } 1033 + 1034 + const recurrenceRule = { rule: ruleCore }; 1035 + const facets = await detectFacets(text); 1036 + const record: Record<string, unknown> = { 1037 + $type: 'app.bsky.feed.post', 1038 + text, 1039 + createdAt: new Date().toISOString(), 1040 + }; 1041 + if (facets.length > 0) record.facets = facets; 1042 + 1043 + btn.disabled = true; 1044 + btn.textContent = 'Creating...'; 1045 + 1046 + try { 1047 + const response = await alfFetch('/xrpc/town.roundabout.scheduledPosts.createSchedule', { 1048 + method: 'POST', 1049 + headers: { 'Content-Type': 'application/json' }, 1050 + body: JSON.stringify({ collection: 'app.bsky.feed.post', recurrenceRule, timezone, record }), 1051 + }); 1052 + 1053 + const data = await response.json() as { schedule?: Record<string, any>; error?: string; message?: string }; 1054 + 1055 + if (!response.ok) { 1056 + errEl.textContent = data.error || data.message || 'Failed to create schedule.'; 1057 + errEl.classList.remove('hidden'); 1058 + } else { 1059 + successEl.textContent = 'Schedule created! First draft queued.'; 1060 + successEl.classList.remove('hidden'); 1061 + (document.getElementById('sched-text') as HTMLTextAreaElement).value = ''; 1062 + await loadSchedules(); 1063 + await loadPosts(); 1064 + } 1065 + } catch (_) { 1066 + errEl.textContent = 'Network error.'; 1067 + errEl.classList.remove('hidden'); 1068 + } finally { 1069 + btn.disabled = false; 1070 + btn.textContent = 'Create Schedule'; 1071 + } 1072 + } 1073 + 1074 + // --------------------------------------------------------------------------- 1075 + // Re-authorize ALF (refresh server-side OAuth session without losing drafts) 1076 + // --------------------------------------------------------------------------- 1077 + 1078 + function wireReauth(): void { 1079 + const btn = document.getElementById('reauth-btn') as HTMLButtonElement; 1080 + btn.addEventListener('click', () => { 1081 + if (!session) return; 1082 + const did = session.sub; 1083 + const redirectBack = encodeURIComponent(`${window.location.origin}/?authorized=true`); 1084 + window.location.href = `${alfUrl}/oauth/authorize?handle=${encodeURIComponent(did)}&redirect_uri=${redirectBack}`; 1085 + }); 1086 + } 1087 + 1088 + // --------------------------------------------------------------------------- 1089 + // Sign out (revoke OAuth session, preserve drafts) 1090 + // --------------------------------------------------------------------------- 1091 + 1092 + function wireSignOut(): void { 1093 + const btn = document.getElementById('signout-btn') as HTMLButtonElement; 1094 + btn.addEventListener('click', async () => { 1095 + btn.disabled = true; 1096 + btn.textContent = 'Signing out...'; 1097 + try { 1098 + await oauthClient!.revoke(session!.sub); 1099 + } catch (_) { 1100 + // best-effort 1101 + } 1102 + session = null; 1103 + if (postsInterval) clearInterval(postsInterval); 1104 + hideAllViews(); 1105 + showView('view-login'); 1106 + document.getElementById('loading')!.classList.add('hidden'); 1107 + }); 1108 + } 1109 + 1110 + // --------------------------------------------------------------------------- 620 1111 // Delete account 621 1112 // --------------------------------------------------------------------------- 622 1113 ··· 649 1140 // --------------------------------------------------------------------------- 650 1141 651 1142 document.addEventListener('DOMContentLoaded', async () => { 1143 + try { 652 1144 // 1. Fetch ALF URL from /api/config 653 1145 try { 654 - const cfg = await fetch('/api/config').then(r => r.json()) as { alfUrl?: string }; 1146 + const cfg = await fetch('/api/config', { signal: AbortSignal.timeout(5000) }).then(r => r.json()) as { alfUrl?: string }; 655 1147 alfUrl = cfg.alfUrl || ''; 656 1148 } catch (_) { 657 1149 alfUrl = ''; 658 1150 } 659 1151 660 1152 // 2. Create BrowserOAuthClient. 661 - // On localhost use the RFC 8252 loopback pattern (no metadata document needed). 662 - // On a real domain use a hosted client metadata document so the PDS can 663 - // redirect back to the actual origin instead of 127.0.0.1. 664 1153 const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname); 665 1154 let oauthClientMetadata: Parameters<typeof BrowserOAuthClient>[0]['clientMetadata']; 666 1155 let allowHttp: boolean; ··· 707 1196 wireTimePresets(); 708 1197 wireImagePicker(); 709 1198 wireScheduleButton(); 1199 + wireCreateScheduleForm(); 1200 + wireReauth(); 1201 + wireSignOut(); 710 1202 wireDeleteAccount(); 711 1203 712 1204 // 4. Call client.init() — detects OAuth callback params or restores existing session 713 1205 let initResult: { session: OAuthSession; state?: string } | undefined; 714 1206 try { 715 - initResult = await oauthClient.init(); 1207 + initResult = await Promise.race([ 1208 + oauthClient.init(), 1209 + new Promise<never>((_, reject) => 1210 + setTimeout(() => reject(new Error('OAuth init timed out')), 8000), 1211 + ), 1212 + ]); 716 1213 } catch (_) { 717 1214 initResult = undefined; 718 1215 } 719 1216 720 1217 if (initResult) { 721 1218 session = initResult.session; 722 - 723 - // 5. If session came from an OAuth callback ('state' in result), clean the URL 724 1219 if ('state' in initResult) { 725 1220 history.replaceState({}, '', '/?authorized=true'); 726 1221 } ··· 735 1230 setTimeout(() => banner.classList.add('hidden'), 5000); 736 1231 } 737 1232 738 - // 6. Render appropriate view 1233 + } catch (_) { 1234 + // unexpected boot error — fall through so render() always runs 1235 + } 1236 + 1237 + // 6. Render appropriate view (always runs, even if boot fails) 739 1238 await render(); 740 1239 });