Social Annotations in the Atmosphere
15
fork

Configure Feed

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

feat: typeahead login component

+450 -3
+1
packages/core/package.json
··· 18 18 "dependencies": { 19 19 "@atcute/oauth-browser-client": "^1.0.27", 20 20 "@atproto/api": "^0.17.3", 21 + "actor-typeahead": "^0.1.2", 21 22 "dom-anchor-text-position": "^5.0.0", 22 23 "dom-anchor-text-quote": "^4.0.2" 23 24 },
+42 -1
packages/core/src/components/sidebar-styles.ts
··· 227 227 padding: 12px 16px; 228 228 } 229 229 230 + /* At-symbol container - holds both @ text and selected avatar */ 230 231 .at-symbol { 232 + display: flex; 233 + align-items: center; 234 + justify-content: center; 235 + width: 24px; 236 + height: 24px; 237 + flex-shrink: 0; 238 + margin-right: 8px; 239 + } 240 + 241 + .at-text { 231 242 color: #999; 232 243 font-size: 16px; 233 - margin-right: 4px; 234 244 user-select: none; 245 + } 246 + 247 + .selected-avatar { 248 + width: 24px; 249 + height: 24px; 250 + border-radius: 50%; 251 + object-fit: cover; 252 + } 253 + 254 + /* Actor typeahead component customization */ 255 + actor-typeahead { 256 + flex: 1; 257 + --color-background: #ffffff; 258 + --color-border: #d0d0d0; 259 + --color-hover: rgba(45, 80, 22, 0.08); 260 + --color-shadow: transparent; 261 + --radius: 2px; 262 + --padding-menu: 4px; 263 + } 264 + 265 + actor-typeahead::part(menu) { 266 + border-style: dashed; 267 + margin-top: 8px; 268 + } 269 + 270 + actor-typeahead::part(user) { 271 + font-size: 14px; 272 + } 273 + 274 + actor-typeahead::part(handle) { 275 + color: #333; 235 276 } 236 277 237 278 .handle-input {
+342
packages/core/src/sidebar/__tests__/login-typeahead.test.ts
··· 1 + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 2 + import { Sidebar } from '../index'; 3 + import type { StorageAdapter } from '../../storage'; 4 + 5 + // Mock storage adapter - return null for session key to simulate logged-out state 6 + const createMockStorage = (): StorageAdapter => ({ 7 + get: vi.fn().mockImplementation((key: string | string[]) => { 8 + // Return null for oauth session to simulate logged-out state 9 + if (key === 'synthesis-oauth:session') { 10 + return Promise.resolve(null); 11 + } 12 + // Return empty arrays/objects for other keys 13 + return Promise.resolve({}); 14 + }), 15 + set: vi.fn().mockResolvedValue(undefined), 16 + onChange: vi.fn(), 17 + }); 18 + 19 + // Mock OAuth launcher 20 + const createMockLauncher = () => ({ 21 + openPopup: vi.fn().mockResolvedValue(undefined), 22 + }); 23 + 24 + // Mock config 25 + const mockConfig = { 26 + oauth: { 27 + clientId: 'test-client', 28 + redirectUri: 'http://localhost/callback', 29 + }, 30 + pds: { 31 + backendUrl: 'http://localhost:8080', 32 + }, 33 + }; 34 + 35 + // Helper to wait for async operations 36 + const waitFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 37 + 38 + describe('Login Form Typeahead Integration', () => { 39 + let container: HTMLElement; 40 + let storage: StorageAdapter; 41 + let sidebar: Sidebar; 42 + 43 + beforeEach(() => { 44 + container = document.createElement('div'); 45 + document.body.appendChild(container); 46 + storage = createMockStorage(); 47 + 48 + // Mock fetch for both actor-typeahead API calls and our profile lookups 49 + // Note: actor-typeahead passes a URL object, not a string 50 + global.fetch = vi.fn().mockImplementation((input: string | URL) => { 51 + const urlStr = typeof input === 'string' ? input : input.toString(); 52 + // Handle actor-typeahead's searchActorsTypeahead API 53 + if (urlStr.includes('searchActorsTypeahead')) { 54 + return Promise.resolve({ 55 + ok: true, 56 + json: () => Promise.resolve({ actors: [] }), 57 + }); 58 + } 59 + // Default: reject to indicate no specific mock was set up 60 + return Promise.reject(new Error('No mock for: ' + urlStr)); 61 + }); 62 + }); 63 + 64 + afterEach(() => { 65 + document.body.removeChild(container); 66 + vi.restoreAllMocks(); 67 + }); 68 + 69 + const initSidebar = async () => { 70 + sidebar = new Sidebar( 71 + container, 72 + storage, 73 + createMockLauncher(), 74 + mockConfig 75 + ); 76 + // Wait for async initialization (renderInterface completes) 77 + // Need to wait longer for all async operations to settle 78 + await waitFor(100); 79 + }; 80 + 81 + const triggerLoginForm = async () => { 82 + // Wait for DOM to be ready 83 + await waitFor(20); 84 + 85 + // Click the login trigger button to show login form 86 + const loginTrigger = container.querySelector('#login-trigger-btn') as HTMLButtonElement; 87 + expect(loginTrigger).not.toBeNull(); 88 + loginTrigger.click(); 89 + 90 + // Wait for login form to render 91 + await waitFor(20); 92 + }; 93 + 94 + describe('Login form structure', () => { 95 + it('should wrap handle input with actor-typeahead component', async () => { 96 + await initSidebar(); 97 + await triggerLoginForm(); 98 + 99 + const typeahead = container.querySelector('actor-typeahead'); 100 + expect(typeahead).not.toBeNull(); 101 + 102 + const inputInsideTypeahead = container.querySelector('actor-typeahead input#handle-input'); 103 + expect(inputInsideTypeahead).not.toBeNull(); 104 + }); 105 + 106 + it('should have an at-symbol container with both text and avatar elements', async () => { 107 + await initSidebar(); 108 + await triggerLoginForm(); 109 + 110 + const atSymbol = container.querySelector('#at-symbol'); 111 + expect(atSymbol).not.toBeNull(); 112 + 113 + const atText = container.querySelector('.at-text'); 114 + expect(atText).not.toBeNull(); 115 + expect(atText?.textContent).toBe('@'); 116 + 117 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 118 + expect(selectedAvatar).not.toBeNull(); 119 + expect(selectedAvatar.style.display).toBe('none'); 120 + }); 121 + 122 + it('should initially show @ symbol and hide avatar', async () => { 123 + await initSidebar(); 124 + await triggerLoginForm(); 125 + 126 + const atText = container.querySelector('.at-text') as HTMLElement; 127 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 128 + 129 + // @ should be visible (not display: none) 130 + expect(atText.style.display).not.toBe('none'); 131 + // Avatar should be hidden 132 + expect(selectedAvatar.style.display).toBe('none'); 133 + }); 134 + }); 135 + 136 + describe('Avatar swap on user selection', () => { 137 + it('should show avatar and hide @ when a user with avatar is selected', async () => { 138 + await initSidebar(); 139 + await triggerLoginForm(); 140 + 141 + // Override fetch to return profile with avatar for getProfile calls 142 + (global.fetch as ReturnType<typeof vi.fn>).mockImplementation((input: string | URL) => { 143 + const url = typeof input === 'string' ? input : input.toString(); 144 + if (url.includes('searchActorsTypeahead')) { 145 + return Promise.resolve({ 146 + ok: true, 147 + json: () => Promise.resolve({ actors: [] }), 148 + }); 149 + } 150 + if (url.includes('getProfile')) { 151 + return Promise.resolve({ 152 + ok: true, 153 + json: () => 154 + Promise.resolve({ 155 + did: 'did:plc:test123', 156 + handle: 'alice.bsky.social', 157 + avatar: 'https://cdn.bsky.app/avatar/alice.jpg', 158 + }), 159 + }); 160 + } 161 + return Promise.reject(new Error('No mock for: ' + url)); 162 + }); 163 + 164 + const handleInput = container.querySelector('#handle-input') as HTMLInputElement; 165 + 166 + // Simulate user selecting "alice.bsky.social" from typeahead 167 + handleInput.value = 'alice.bsky.social'; 168 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 169 + 170 + // Wait for async profile fetch 171 + await waitFor(100); 172 + 173 + const atText = container.querySelector('.at-text') as HTMLElement; 174 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 175 + 176 + expect(selectedAvatar.style.display).toBe('block'); 177 + expect(selectedAvatar.src).toBe('https://cdn.bsky.app/avatar/alice.jpg'); 178 + expect(atText.style.display).toBe('none'); 179 + }); 180 + 181 + it('should keep @ symbol visible when user has no avatar', async () => { 182 + await initSidebar(); 183 + await triggerLoginForm(); 184 + 185 + // Override fetch to return profile without avatar 186 + (global.fetch as ReturnType<typeof vi.fn>).mockImplementation((input: string | URL) => { 187 + const url = typeof input === 'string' ? input : input.toString(); 188 + if (url.includes('searchActorsTypeahead')) { 189 + return Promise.resolve({ 190 + ok: true, 191 + json: () => Promise.resolve({ actors: [] }), 192 + }); 193 + } 194 + if (url.includes('getProfile')) { 195 + return Promise.resolve({ 196 + ok: true, 197 + json: () => 198 + Promise.resolve({ 199 + did: 'did:plc:noavatar', 200 + handle: 'noavatar.bsky.social', 201 + // No avatar field 202 + }), 203 + }); 204 + } 205 + return Promise.reject(new Error('No mock for: ' + url)); 206 + }); 207 + 208 + const handleInput = container.querySelector('#handle-input') as HTMLInputElement; 209 + 210 + handleInput.value = 'noavatar.bsky.social'; 211 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 212 + 213 + await waitFor(100); 214 + 215 + const atText = container.querySelector('.at-text') as HTMLElement; 216 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 217 + 218 + // Should fallback to @ symbol 219 + expect(atText.style.display).not.toBe('none'); 220 + expect(selectedAvatar.style.display).toBe('none'); 221 + }); 222 + 223 + it('should reset to @ symbol when input is cleared', async () => { 224 + await initSidebar(); 225 + await triggerLoginForm(); 226 + 227 + // Override fetch to return profile with avatar 228 + (global.fetch as ReturnType<typeof vi.fn>).mockImplementation((input: string | URL) => { 229 + const url = typeof input === 'string' ? input : input.toString(); 230 + if (url.includes('searchActorsTypeahead')) { 231 + return Promise.resolve({ 232 + ok: true, 233 + json: () => Promise.resolve({ actors: [] }), 234 + }); 235 + } 236 + if (url.includes('getProfile')) { 237 + return Promise.resolve({ 238 + ok: true, 239 + json: () => 240 + Promise.resolve({ 241 + did: 'did:plc:test123', 242 + handle: 'alice.bsky.social', 243 + avatar: 'https://cdn.bsky.app/avatar/alice.jpg', 244 + }), 245 + }); 246 + } 247 + return Promise.reject(new Error('No mock for: ' + url)); 248 + }); 249 + 250 + const handleInput = container.querySelector('#handle-input') as HTMLInputElement; 251 + 252 + // Select a user 253 + handleInput.value = 'alice.bsky.social'; 254 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 255 + await waitFor(100); 256 + 257 + // Now clear the input 258 + handleInput.value = ''; 259 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 260 + await waitFor(50); 261 + 262 + const atText = container.querySelector('.at-text') as HTMLElement; 263 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 264 + 265 + // Should reset to @ symbol 266 + expect(atText.style.display).not.toBe('none'); 267 + expect(selectedAvatar.style.display).toBe('none'); 268 + }); 269 + 270 + it('should keep @ symbol when profile fetch fails', async () => { 271 + await initSidebar(); 272 + await triggerLoginForm(); 273 + 274 + // Override fetch to fail for profile calls 275 + (global.fetch as ReturnType<typeof vi.fn>).mockImplementation((input: string | URL) => { 276 + const url = typeof input === 'string' ? input : input.toString(); 277 + if (url.includes('searchActorsTypeahead')) { 278 + return Promise.resolve({ 279 + ok: true, 280 + json: () => Promise.resolve({ actors: [] }), 281 + }); 282 + } 283 + if (url.includes('getProfile')) { 284 + return Promise.reject(new Error('Network error')); 285 + } 286 + return Promise.reject(new Error('No mock for: ' + url)); 287 + }); 288 + 289 + const handleInput = container.querySelector('#handle-input') as HTMLInputElement; 290 + 291 + handleInput.value = 'failing.handle.test'; 292 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 293 + 294 + await waitFor(100); 295 + 296 + const atText = container.querySelector('.at-text') as HTMLElement; 297 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 298 + 299 + // Should fallback to @ symbol 300 + expect(atText.style.display).not.toBe('none'); 301 + expect(selectedAvatar.style.display).toBe('none'); 302 + }); 303 + 304 + it('should not fetch profile for partial handles without dots', async () => { 305 + await initSidebar(); 306 + await triggerLoginForm(); 307 + 308 + // Track fetch calls for getProfile specifically 309 + const fetchCalls: string[] = []; 310 + (global.fetch as ReturnType<typeof vi.fn>).mockImplementation((input: string | URL) => { 311 + const url = typeof input === 'string' ? input : input.toString(); 312 + fetchCalls.push(url); 313 + if (url.includes('searchActorsTypeahead')) { 314 + return Promise.resolve({ 315 + ok: true, 316 + json: () => Promise.resolve({ actors: [] }), 317 + }); 318 + } 319 + return Promise.reject(new Error('No mock for: ' + url)); 320 + }); 321 + 322 + const handleInput = container.querySelector('#handle-input') as HTMLInputElement; 323 + 324 + // Type a partial handle without a dot 325 + handleInput.value = 'alice'; 326 + handleInput.dispatchEvent(new Event('input', { bubbles: true })); 327 + 328 + await waitFor(100); 329 + 330 + // Should not have called getProfile for incomplete handle 331 + const profileCalls = fetchCalls.filter((url) => url.includes('getProfile')); 332 + expect(profileCalls.length).toBe(0); 333 + 334 + const atText = container.querySelector('.at-text') as HTMLElement; 335 + const selectedAvatar = container.querySelector('#selected-avatar') as HTMLImageElement; 336 + 337 + // Should still show @ symbol 338 + expect(atText.style.display).not.toBe('none'); 339 + expect(selectedAvatar.style.display).toBe('none'); 340 + }); 341 + }); 342 + });
+57 -2
packages/core/src/sidebar/index.ts
··· 12 12 import { registerComponents } from '../components'; 13 13 import type { SeamsAnnotationCard } from '../components'; 14 14 15 + // Import actor-typeahead web component (auto-registers itself) 16 + import 'actor-typeahead'; 17 + 15 18 export interface SidebarConfig { 16 19 oauth: OAuthConfig; 17 20 pds: { ··· 93 96 <div class="login-container"> 94 97 <h2>Login to Seams</h2> 95 98 <div class="input-wrapper"> 96 - <span class="at-symbol">@</span> 97 - <input type="text" id="handle-input" class="handle-input" placeholder="you.bsky.social" /> 99 + <span class="at-symbol" id="at-symbol"> 100 + <span class="at-text">@</span> 101 + <img class="selected-avatar" id="selected-avatar" style="display: none;" /> 102 + </span> 103 + <actor-typeahead id="handle-typeahead"> 104 + <input type="text" id="handle-input" class="handle-input" placeholder="you.bsky.social" /> 105 + </actor-typeahead> 98 106 </div> 99 107 <button id="login-btn">Login with ATProto</button> 100 108 <div id="auth-status"></div> ··· 106 114 const handleInput = this.container.querySelector('#handle-input') as HTMLInputElement; 107 115 const loginBtn = this.container.querySelector('#login-btn'); 108 116 const authStatus = this.container.querySelector('#auth-status'); 117 + const atText = this.container.querySelector('.at-text') as HTMLElement; 118 + const selectedAvatar = this.container.querySelector('#selected-avatar') as HTMLImageElement; 119 + 120 + // Track the last fetched handle to avoid duplicate fetches 121 + let lastFetchedHandle = ''; 122 + 123 + // Handle avatar swap when user selects/types a handle 124 + const updateAvatar = async (handle: string) => { 125 + // Only fetch for handles that look complete (contain a dot) 126 + if (!handle || !handle.includes('.')) { 127 + // Reset to @ symbol 128 + if (atText) atText.style.display = 'block'; 129 + if (selectedAvatar) selectedAvatar.style.display = 'none'; 130 + lastFetchedHandle = ''; 131 + return; 132 + } 133 + 134 + // Skip if we already fetched this handle 135 + if (handle === lastFetchedHandle) return; 136 + lastFetchedHandle = handle; 137 + 138 + try { 139 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); 140 + if (!res.ok) throw new Error('Profile not found'); 141 + const profile = await res.json(); 142 + 143 + if (profile.avatar) { 144 + selectedAvatar.src = profile.avatar; 145 + selectedAvatar.style.display = 'block'; 146 + if (atText) atText.style.display = 'none'; 147 + } else { 148 + // User has no avatar, keep @ symbol 149 + if (atText) atText.style.display = 'block'; 150 + if (selectedAvatar) selectedAvatar.style.display = 'none'; 151 + } 152 + } catch { 153 + // Fallback to @ symbol on any error 154 + if (atText) atText.style.display = 'block'; 155 + if (selectedAvatar) selectedAvatar.style.display = 'none'; 156 + } 157 + }; 158 + 159 + // Listen for input changes (fires when user types or selects from typeahead) 160 + handleInput?.addEventListener('input', () => { 161 + const handle = handleInput.value.trim(); 162 + updateAvatar(handle); 163 + }); 109 164 110 165 const handleLogin = async () => { 111 166 let handle = handleInput?.value.trim();
+8
pnpm-lock.yaml
··· 57 57 '@atproto/api': 58 58 specifier: ^0.17.3 59 59 version: 0.17.3 60 + actor-typeahead: 61 + specifier: ^0.1.2 62 + version: 0.1.2 60 63 dom-anchor-text-position: 61 64 specifier: ^5.0.0 62 65 version: 5.0.0 ··· 1144 1147 resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 1145 1148 engines: {node: '>=0.4.0'} 1146 1149 hasBin: true 1150 + 1151 + actor-typeahead@0.1.2: 1152 + resolution: {integrity: sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A==} 1147 1153 1148 1154 adm-zip@0.5.16: 1149 1155 resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} ··· 3862 3868 '@zeit/schemas@2.36.0': {} 3863 3869 3864 3870 acorn@8.15.0: {} 3871 + 3872 + actor-typeahead@0.1.2: {} 3865 3873 3866 3874 adm-zip@0.5.16: {} 3867 3875