experiments in a post-browser web
10
fork

Configure Feed

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

chore: remove stowaway /home.js from repo root (accidentally added Feb 17 by nuwpntuxw)

-674
-674
home.js
··· 1 - /** 2 - * Pagestream - Vertical, chat-like navigational interface for web history 3 - * 4 - * Displays a vertical stream of page cards from visit history. 5 - * Newest at bottom (chat-like), keyboard navigable. 6 - * Click/Enter opens the URL in a page host window. 7 - */ 8 - 9 - const api = window.app; 10 - const debug = api.debug; 11 - 12 - const debounce = (fn, ms) => { 13 - let timer; 14 - return (...args) => { 15 - clearTimeout(timer); 16 - timer = setTimeout(() => fn(...args), ms); 17 - }; 18 - }; 19 - 20 - const formatRelativeTime = (timestamp) => { 21 - if (!timestamp) return ''; 22 - const now = Date.now(); 23 - const diff = now - timestamp; 24 - const seconds = Math.floor(diff / 1000); 25 - const minutes = Math.floor(seconds / 60); 26 - const hours = Math.floor(minutes / 60); 27 - const days = Math.floor(hours / 24); 28 - 29 - if (seconds < 60) return 'just now'; 30 - if (minutes < 60) return `${minutes}m ago`; 31 - if (hours < 24) return `${hours}h ago`; 32 - if (days === 1) return 'yesterday'; 33 - if (days < 7) return `${days}d ago`; 34 - return new Date(timestamp).toLocaleDateString(); 35 - }; 36 - 37 - const extractDomain = (url) => { 38 - try { 39 - return new URL(url).hostname; 40 - } catch { 41 - return url; 42 - } 43 - }; 44 - 45 - const isWebUrl = (url) => { 46 - if (!url) return false; 47 - return url.startsWith('http://') || url.startsWith('https://'); 48 - }; 49 - 50 - // ===== State ===== 51 - 52 - const ANIM_DURATION = '0.2s'; 53 - const ANIM_EASE = 'cubic-bezier(0.4, 0, 0.2, 1)'; 54 - 55 - let state = { 56 - visits: [], 57 - items: new Map(), 58 - selectedIndex: -1, 59 - filterTagId: null, 60 - filterTagName: null, 61 - isLoading: true, 62 - animating: false, 63 - openWindowId: null, 64 - openCardIndex: -1, 65 - }; 66 - 67 - window._pagestreamState = state; 68 - 69 - // ===== Data Loading ===== 70 - 71 - const loadVisits = async () => { 72 - state.isLoading = true; 73 - 74 - try { 75 - const visitsResult = await api.datastore.queryItemVisits({ limit: 100 }); 76 - if (!visitsResult.success) { 77 - console.error('[pagestream] Failed to load visits:', visitsResult.error); 78 - state.visits = []; 79 - return; 80 - } 81 - 82 - const visits = visitsResult.data || []; 83 - 84 - const itemsResult = await api.datastore.queryItems({ type: 'url' }); 85 - if (itemsResult.success && itemsResult.data) { 86 - state.items.clear(); 87 - for (const item of itemsResult.data) { 88 - state.items.set(item.id, item); 89 - } 90 - } 91 - 92 - state.visits = visits 93 - .map(visit => { 94 - const item = state.items.get(visit.itemId); 95 - if (!item || !isWebUrl(item.content)) return null; 96 - return { visit, item }; 97 - }) 98 - .filter(Boolean) 99 - .reverse(); 100 - 101 - debug && console.log('[pagestream] Loaded', state.visits.length, 'visits'); 102 - } catch (err) { 103 - console.error('[pagestream] Error loading visits:', err); 104 - state.visits = []; 105 - } finally { 106 - state.isLoading = false; 107 - } 108 - }; 109 - 110 - const loadItemTags = async (itemId) => { 111 - try { 112 - const result = await api.datastore.getItemTags(itemId); 113 - if (result.success) return result.data || []; 114 - } catch { 115 - // Ignore 116 - } 117 - return []; 118 - }; 119 - 120 - // ===== Rendering ===== 121 - 122 - const getFilteredVisits = () => { 123 - let filtered = state.visits; 124 - 125 - filtered = filtered.filter(({ item }, i, arr) => { 126 - if (i === 0) return true; 127 - return item.content !== arr[i - 1].item.content; 128 - }); 129 - 130 - return filtered; 131 - }; 132 - 133 - const getCards = () => { 134 - return Array.from(document.querySelectorAll('#stream peek-card')); 135 - }; 136 - 137 - const updateSelection = (animate = true) => { 138 - const cards = getCards(); 139 - cards.forEach((card, i) => { 140 - const isActive = (i === state.selectedIndex); 141 - card.selected = false; 142 - card.elevated = false; 143 - if (isActive) { 144 - card.classList.add('active-card'); 145 - const override = card.shadowRoot?.querySelector('.pagestream-override'); 146 - if (override) { 147 - override.textContent = ` 148 - .card, .card:hover, .card:active, .card:focus-visible { 149 - background: #3a3a3c !important; 150 - border: none !important; 151 - border-radius: 8px !important; 152 - outline: none !important; 153 - box-shadow: none !important; 154 - overflow: hidden !important; 155 - } 156 - `; 157 - } 158 - } else { 159 - card.classList.remove('active-card'); 160 - const override = card.shadowRoot?.querySelector('.pagestream-override'); 161 - if (override) { 162 - override.textContent = ` 163 - .card, .card:hover, .card:active, .card:focus-visible { 164 - background: #2c2c2e !important; 165 - border: none !important; 166 - border-radius: 8px !important; 167 - outline: none !important; 168 - box-shadow: none !important; 169 - overflow: hidden !important; 170 - } 171 - `; 172 - } 173 - } 174 - }); 175 - 176 - const selected = cards[state.selectedIndex]; 177 - if (selected) { 178 - scrollCardToCenter(selected, animate); 179 - } 180 - }; 181 - 182 - /** Scroll so that the given card is vertically centered in the stream */ 183 - const scrollCardToCenter = (card, animate = true) => { 184 - const container = document.getElementById('stream'); 185 - const cardRect = card.getBoundingClientRect(); 186 - const containerRect = container.getBoundingClientRect(); 187 - 188 - // Card center relative to container's scroll position 189 - const cardCenterInContainer = (card.offsetTop + cardRect.height / 2); 190 - const targetScrollTop = cardCenterInContainer - container.clientHeight / 2; 191 - 192 - container.scrollTo({ 193 - top: targetScrollTop, 194 - behavior: animate ? 'smooth' : 'instant' 195 - }); 196 - }; 197 - 198 - const render = (initialLoad = false) => { 199 - const container = document.getElementById('stream'); 200 - const emptyState = document.getElementById('empty-state'); 201 - const filtered = getFilteredVisits(); 202 - 203 - // Remove old cards and spacers 204 - container.querySelectorAll('peek-card, .stream-spacer').forEach(el => el.remove()); 205 - 206 - if (state.isLoading) { 207 - emptyState.textContent = 'Loading history...'; 208 - emptyState.style.display = 'flex'; 209 - return; 210 - } 211 - 212 - if (filtered.length === 0) { 213 - emptyState.textContent = 'No browsing history yet. Open a page to start your stream.'; 214 - emptyState.style.display = 'flex'; 215 - return; 216 - } 217 - 218 - emptyState.style.display = 'none'; 219 - 220 - // Add top spacer — half viewport height so first card can be centered 221 - const spacerHeight = Math.floor(container.clientHeight / 2 - 60); 222 - const topSpacer = document.createElement('div'); 223 - topSpacer.className = 'stream-spacer'; 224 - topSpacer.style.height = spacerHeight + 'px'; 225 - container.appendChild(topSpacer); 226 - 227 - filtered.forEach(({ visit, item }, index) => { 228 - const card = createVisitCard(visit, item, index); 229 - container.appendChild(card); 230 - }); 231 - 232 - // Add bottom spacer 233 - const bottomSpacer = document.createElement('div'); 234 - bottomSpacer.className = 'stream-spacer'; 235 - bottomSpacer.style.height = spacerHeight + 'px'; 236 - container.appendChild(bottomSpacer); 237 - 238 - if (state.selectedIndex < 0 || state.selectedIndex >= filtered.length) { 239 - state.selectedIndex = filtered.length - 1; 240 - } 241 - // On initial load, snap to center instantly (no smooth scroll) 242 - updateSelection(!initialLoad); 243 - }; 244 - 245 - // ===== Open URL in Page Host (with animation) ===== 246 - 247 - // Default page host size — XGA (1024x768) 248 - const PAGE_HOST_WIDTH = 1024; 249 - const PAGE_HOST_HEIGHT = 768; 250 - 251 - /** 252 - * @param {object} from - Start rect {top, left, width, height, borderRadius} 253 - * @param {object} to - End rect 254 - * @param {boolean} keepVisible - If true, don't hide ghost after animation (caller hides it) 255 - */ 256 - const animateGhost = (from, to, keepVisible = false) => { 257 - return new Promise((resolve) => { 258 - const ghost = document.getElementById('anim-ghost'); 259 - 260 - // Position at start (no transition) 261 - ghost.style.transition = 'none'; 262 - ghost.classList.remove('visible'); 263 - ghost.style.top = from.top + 'px'; 264 - ghost.style.left = from.left + 'px'; 265 - ghost.style.width = from.width + 'px'; 266 - ghost.style.height = from.height + 'px'; 267 - ghost.style.borderRadius = from.borderRadius || '8px'; 268 - ghost.offsetHeight; // force reflow 269 - 270 - ghost.classList.add('visible'); 271 - 272 - // Next frame — animate to target 273 - requestAnimationFrame(() => { 274 - const t = `${ANIM_DURATION} ${ANIM_EASE}`; 275 - ghost.style.transition = `top ${t}, left ${t}, width ${t}, height ${t}, border-radius ${t}`; 276 - 277 - requestAnimationFrame(() => { 278 - ghost.style.top = to.top + 'px'; 279 - ghost.style.left = to.left + 'px'; 280 - ghost.style.width = to.width + 'px'; 281 - ghost.style.height = to.height + 'px'; 282 - ghost.style.borderRadius = to.borderRadius || '0px'; 283 - 284 - const onDone = (e) => { 285 - if (e.target !== ghost || e.propertyName !== 'width') return; 286 - ghost.removeEventListener('transitionend', onDone); 287 - ghost.style.transition = ''; 288 - if (!keepVisible) { 289 - ghost.classList.remove('visible'); 290 - } 291 - resolve(); 292 - }; 293 - ghost.addEventListener('transitionend', onDone); 294 - }); 295 - }); 296 - }); 297 - }; 298 - 299 - const hideGhost = () => { 300 - const ghost = document.getElementById('anim-ghost'); 301 - ghost.classList.remove('visible'); 302 - }; 303 - 304 - /** 305 - * Convert card's viewport-relative rect to a position within the pagestream 306 - * window, then compute what that would look like as a target for the ghost 307 - * that ends at the exact screen position of the page host window. 308 - * 309 - * Ghost coordinates are relative to the pagestream window viewport. 310 - * Page host opens at absolute screen coordinates. 311 - * We need: ghost target = pageHostScreen - pagestreamScreen 312 - */ 313 - const openInPageHost = async (url) => { 314 - if (state.animating) return; 315 - state.animating = true; 316 - 317 - const cards = getCards(); 318 - const card = cards[state.selectedIndex]; 319 - if (!card) { state.animating = false; return; } 320 - 321 - state.openCardIndex = state.selectedIndex; 322 - 323 - // Card position (viewport-relative, which is pagestream-window-relative) 324 - const cardRect = card.getBoundingClientRect(); 325 - 326 - // Get pagestream window's screen position 327 - let psBounds; 328 - try { 329 - psBounds = await api.window.getBounds(); 330 - } catch { 331 - psBounds = { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; 332 - } 333 - 334 - // Center page host on the pagestream window, capped at default page size 335 - const pageHostW = PAGE_HOST_WIDTH; 336 - const pageHostH = PAGE_HOST_HEIGHT; 337 - const pageHostX = Math.round(psBounds.x + (psBounds.width - pageHostW) / 2); 338 - const pageHostY = Math.round(psBounds.y + (psBounds.height - pageHostH) / 2); 339 - 340 - // Ghost target in viewport coords (centered within pagestream window) 341 - const ghostLeft = (psBounds.width - pageHostW) / 2; 342 - const ghostTop = (psBounds.height - pageHostH) / 2; 343 - 344 - // Ghost animates from card to page host size (centered in pagestream) 345 - await animateGhost( 346 - { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' }, 347 - { top: ghostTop, left: ghostLeft, width: pageHostW, height: pageHostH, borderRadius: '10px' }, 348 - true // keepVisible — ghost stays as backdrop until window renders 349 - ); 350 - 351 - // Open the page host window at exact position ghost landed 352 - try { 353 - const result = await api.window.open(url, { 354 - role: 'content', 355 - key: url, 356 - width: pageHostW, 357 - height: pageHostH, 358 - x: pageHostX, 359 - y: pageHostY, 360 - trackingSource: 'pagestream', 361 - }); 362 - if (result && result.id) { 363 - state.openWindowId = result.id; 364 - } 365 - } catch (err) { 366 - console.error('[pagestream] Failed to open page:', err); 367 - state.openCardIndex = -1; 368 - } 369 - 370 - // Give the window time to render on top of the ghost, then hide ghost 371 - setTimeout(() => { 372 - hideGhost(); 373 - state.animating = false; 374 - }, 300); 375 - }; 376 - 377 - // ===== Global navigation while page host is open ===== 378 - 379 - /** Move card selection and optionally navigate the open page host */ 380 - const moveSelection = (action) => { 381 - const cards = getCards(); 382 - if (cards.length === 0) return; 383 - 384 - const prevIndex = state.selectedIndex; 385 - 386 - switch (action) { 387 - case 'down': 388 - if (state.selectedIndex < cards.length - 1) state.selectedIndex++; 389 - break; 390 - case 'up': 391 - if (state.selectedIndex > 0) state.selectedIndex--; 392 - break; 393 - case 'first': 394 - state.selectedIndex = 0; 395 - break; 396 - case 'last': 397 - state.selectedIndex = cards.length - 1; 398 - break; 399 - } 400 - 401 - if (state.selectedIndex === prevIndex) return; 402 - 403 - updateSelection(); 404 - 405 - // If a page host is open, navigate it to the new card's URL 406 - if (state.openWindowId) { 407 - const filtered = getFilteredVisits(); 408 - const entry = filtered[state.selectedIndex]; 409 - if (entry) { 410 - state.openCardIndex = state.selectedIndex; 411 - api.publish('page:navigate', { 412 - windowId: state.openWindowId, 413 - url: entry.item.content 414 - }, api.scopes.GLOBAL); 415 - } 416 - } 417 - }; 418 - 419 - // Listen for navigation keys forwarded from page host via pubsub 420 - // (subscription lives for the lifetime of pagestream — moveSelection 421 - // is safe to call any time, it just no-ops if nothing is open) 422 - api.subscribe('pagestream:nav', (msg) => { 423 - if (msg.action) moveSelection(msg.action); 424 - }, api.scopes.GLOBAL); 425 - 426 - const onPageHostClosed = async (closedWindowId) => { 427 - if (closedWindowId !== state.openWindowId) return; 428 - state.openWindowId = null; 429 - 430 - const cards = getCards(); 431 - const card = cards[state.openCardIndex]; 432 - if (!card || state.animating) return; 433 - 434 - state.animating = true; 435 - 436 - // Get fresh card position and pagestream window size 437 - const cardRect = card.getBoundingClientRect(); 438 - const ghostLeft = (window.innerWidth - PAGE_HOST_WIDTH) / 2; 439 - const ghostTop = (window.innerHeight - PAGE_HOST_HEIGHT) / 2; 440 - 441 - // Animate from page host size (centered) → card 442 - await animateGhost( 443 - { top: ghostTop, left: ghostLeft, width: PAGE_HOST_WIDTH, height: PAGE_HOST_HEIGHT, borderRadius: '10px' }, 444 - { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' } 445 - ); 446 - 447 - state.animating = false; 448 - state.openCardIndex = -1; 449 - state.pageHostBounds = null; 450 - }; 451 - 452 - const createVisitCard = (visit, item, index) => { 453 - const card = document.createElement('peek-card'); 454 - card.className = 'visit-card'; 455 - card.interactive = true; 456 - card.bordered = false; 457 - card.dataset.visitId = visit.id; 458 - card.dataset.itemId = item.id; 459 - card.dataset.index = index; 460 - 461 - // Inject <style> into shadow DOM — permanent override for opaque bg and no borders 462 - card.updateComplete.then(() => { 463 - if (card.shadowRoot && !card.shadowRoot.querySelector('.pagestream-override')) { 464 - const style = document.createElement('style'); 465 - style.className = 'pagestream-override'; 466 - style.textContent = ` 467 - .card, .card:hover, .card:active, .card:focus-visible { 468 - background: #2c2c2e !important; 469 - border: none !important; 470 - border-radius: 8px !important; 471 - outline: none !important; 472 - box-shadow: none !important; 473 - overflow: hidden !important; 474 - } 475 - `; 476 - card.shadowRoot.appendChild(style); 477 - } 478 - }); 479 - 480 - const url = item.content; 481 - 482 - let displayTitle = item.title; 483 - if (!displayTitle && item.metadata) { 484 - try { 485 - const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 486 - displayTitle = meta.title; 487 - } catch { 488 - // Ignore 489 - } 490 - } 491 - displayTitle = displayTitle || extractDomain(url); 492 - 493 - const domain = item.domain || extractDomain(url); 494 - 495 - const header = document.createElement('div'); 496 - header.slot = 'header'; 497 - header.className = 'card-header'; 498 - 499 - const favicon = document.createElement('img'); 500 - favicon.className = 'card-favicon'; 501 - favicon.src = item.favicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; 502 - favicon.onerror = () => { 503 - favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">.</text></svg>'; 504 - }; 505 - 506 - const title = document.createElement('h2'); 507 - title.className = 'card-title'; 508 - title.textContent = displayTitle; 509 - 510 - header.appendChild(favicon); 511 - header.appendChild(title); 512 - card.appendChild(header); 513 - 514 - const body = document.createElement('div'); 515 - body.className = 'card-url'; 516 - body.textContent = url; 517 - card.appendChild(body); 518 - 519 - const footer = document.createElement('div'); 520 - footer.slot = 'footer'; 521 - footer.className = 'card-footer'; 522 - 523 - const time = document.createElement('span'); 524 - time.className = 'card-time'; 525 - time.textContent = formatRelativeTime(visit.timestamp); 526 - 527 - const domainBadge = document.createElement('span'); 528 - domainBadge.className = 'card-domain'; 529 - domainBadge.textContent = domain; 530 - 531 - footer.appendChild(time); 532 - footer.appendChild(domainBadge); 533 - card.appendChild(footer); 534 - 535 - loadItemTags(item.id).then(tags => { 536 - if (tags.length > 0) { 537 - const tagsContainer = document.createElement('div'); 538 - tagsContainer.className = 'card-tags'; 539 - tagsContainer.slot = 'footer'; 540 - tags.forEach(tag => { 541 - const chip = document.createElement('span'); 542 - chip.className = 'tag-chip'; 543 - chip.textContent = tag.name; 544 - chip.addEventListener('click', (e) => { 545 - e.stopPropagation(); 546 - filterByTag(tag.id, tag.name); 547 - }); 548 - tagsContainer.appendChild(chip); 549 - }); 550 - card.appendChild(tagsContainer); 551 - } 552 - }); 553 - 554 - card.addEventListener('card-click', () => { 555 - state.selectedIndex = index; 556 - updateSelection(); 557 - openInPageHost(url); 558 - }); 559 - 560 - return card; 561 - }; 562 - 563 - // ===== Filtering ===== 564 - 565 - const filterByTag = async (tagId, tagName) => { 566 - state.filterTagId = tagId; 567 - state.filterTagName = tagName; 568 - 569 - const result = await api.datastore.getItemsByTag(tagId); 570 - if (result.success && result.data) { 571 - const taggedItemIds = new Set(result.data.map(item => item.id)); 572 - state.visits = state.visits.filter(({ item }) => taggedItemIds.has(item.id)); 573 - } 574 - 575 - render(); 576 - }; 577 - 578 - const clearTagFilter = async () => { 579 - state.filterTagId = null; 580 - state.filterTagName = null; 581 - await loadVisits(); 582 - render(); 583 - }; 584 - 585 - // ===== Keyboard Navigation ===== 586 - 587 - const handleEscape = () => { 588 - if (state.filterTagId) { 589 - clearTagFilter(); 590 - return { handled: true }; 591 - } 592 - return { handled: false }; 593 - }; 594 - 595 - const handleKeydown = (e) => { 596 - const cards = getCards(); 597 - if (cards.length === 0) return; 598 - 599 - switch (e.key) { 600 - case 'j': 601 - case 'ArrowDown': 602 - e.preventDefault(); 603 - moveSelection('down'); 604 - break; 605 - case 'k': 606 - case 'ArrowUp': 607 - e.preventDefault(); 608 - moveSelection('up'); 609 - break; 610 - case 'Enter': 611 - e.preventDefault(); 612 - { 613 - const filtered = getFilteredVisits(); 614 - const entry = filtered[state.selectedIndex]; 615 - if (entry) { 616 - openInPageHost(entry.item.content); 617 - } 618 - } 619 - break; 620 - case 'g': 621 - case 'Home': 622 - e.preventDefault(); 623 - moveSelection('first'); 624 - break; 625 - case 'G': 626 - case 'End': 627 - e.preventDefault(); 628 - moveSelection('last'); 629 - break; 630 - } 631 - }; 632 - 633 - // ===== Initialization ===== 634 - 635 - const init = async () => { 636 - debug && console.log('[pagestream] init'); 637 - 638 - api.escape.onEscape(handleEscape); 639 - 640 - document.addEventListener('keydown', handleKeydown); 641 - 642 - await loadVisits(); 643 - render(true); 644 - 645 - const debouncedRefresh = debounce(async () => { 646 - debug && console.log('[pagestream] debounced refresh triggered'); 647 - await loadVisits(); 648 - render(); 649 - }, 150); 650 - 651 - // Listen for page host window closing — trigger collapse animation 652 - api.subscribe('window:closed', (msg) => { 653 - const closedId = msg?.id; 654 - if (closedId) onPageHostClosed(closedId); 655 - }, api.scopes.GLOBAL); 656 - 657 - api.subscribe('item:created', () => debouncedRefresh(), api.scopes.GLOBAL); 658 - api.subscribe('item:deleted', () => debouncedRefresh(), api.scopes.GLOBAL); 659 - api.subscribe('tag:item-added', () => debouncedRefresh(), api.scopes.GLOBAL); 660 - api.subscribe('tag:item-removed', () => debouncedRefresh(), api.scopes.GLOBAL); 661 - api.subscribe('sync:pull-completed', () => debouncedRefresh(), api.scopes.GLOBAL); 662 - 663 - setInterval(() => { 664 - const timeElements = document.querySelectorAll('.card-time'); 665 - const filtered = getFilteredVisits(); 666 - timeElements.forEach((el, i) => { 667 - if (filtered[i]) { 668 - el.textContent = formatRelativeTime(filtered[i].visit.timestamp); 669 - } 670 - }); 671 - }, 30000); 672 - }; 673 - 674 - document.addEventListener('DOMContentLoaded', init);