experiments in a post-browser web
10
fork

Configure Feed

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

feat(tags): enable web page viewing for note items containing URLs

Notes (text items) that contain URLs now get the same web page viewing
treatment as URL-type items. Changes:

- Add extractUrl() utility to detect URLs in text content (full URLs,
bare domains, and URLs embedded in text)
- Add openItemUrl() that routes through api.window.open() -> peek://page
webview container, same path as URL items
- Cards for notes with URLs show a link icon and the URL as subtitle
- Cards with URLs (both URL items and notes) get an 'Open Page' button
- Edit modal shows an 'Open Page' button for any item with a URL
- Groups extension also opens notes-with-URLs alongside URL items

+176 -7
+15 -3
extensions/groups/background.js
··· 217 217 return { success: false, error: 'Failed to get group items' }; 218 218 } 219 219 220 - // Filter to URL items only 221 - const urlItems = itemsResult.data.filter(item => item.type === 'url'); 220 + // Filter to items with URLs (explicit URL items + text items containing URLs) 221 + const urlItems = itemsResult.data 222 + .map(item => { 223 + if (item.type === 'url') return { ...item, _openUrl: item.content }; 224 + if (item.type === 'text' && item.content) { 225 + // Check if text note contains a URL 226 + const urlMatch = item.content.trim().match(/^https?:\/\/\S+/) || item.content.match(/https?:\/\/[^\s<>"')\]]+/i); 227 + if (urlMatch) { 228 + try { new URL(urlMatch[0]); return { ...item, _openUrl: urlMatch[0] }; } catch (e) {} 229 + } 230 + } 231 + return null; 232 + }) 233 + .filter(Boolean); 222 234 if (urlItems.length === 0) { 223 235 console.log('[ext:groups] No URLs in group:', groupName); 224 236 return { success: false, error: 'Group is empty' }; ··· 231 243 // Open windows and set group mode 232 244 const openedWindows = []; 233 245 for (const item of urlItems) { 234 - const result = await api.window.open(item.content, { 246 + const result = await api.window.open(item._openUrl, { 235 247 trackingSource: 'cmd', 236 248 trackingSourceId: `group:${groupName}`, 237 249 // Pass group context for mode inheritance
+50
extensions/tags/home.css
··· 608 608 opacity: 0.5; 609 609 cursor: default; 610 610 } 611 + 612 + 613 + /* Card open button - opens URL in webview */ 614 + .card-open-btn { 615 + display: flex; 616 + align-items: center; 617 + justify-content: center; 618 + width: 28px; 619 + height: 28px; 620 + background: var(--base02); 621 + border: 1px solid var(--base03); 622 + border-radius: 6px; 623 + cursor: pointer; 624 + color: var(--base04); 625 + flex-shrink: 0; 626 + transition: all 0.15s; 627 + padding: 0; 628 + } 629 + 630 + .card-open-btn:hover { 631 + background: var(--base0D); 632 + border-color: var(--base0D); 633 + color: var(--base00); 634 + } 635 + 636 + /* Modal open page button */ 637 + .modal-open-page-btn { 638 + display: flex; 639 + align-items: center; 640 + gap: 6px; 641 + padding: 6px 12px; 642 + background: var(--base0D); 643 + border: none; 644 + border-radius: 6px; 645 + font-size: 12px; 646 + font-weight: 500; 647 + color: var(--base00); 648 + cursor: pointer; 649 + white-space: nowrap; 650 + flex-shrink: 0; 651 + transition: all 0.15s; 652 + } 653 + 654 + .modal-open-page-btn:hover { 655 + filter: brightness(1.1); 656 + } 657 + 658 + .modal-open-page-btn svg { 659 + flex-shrink: 0; 660 + }
+8
extensions/tags/home.html
··· 84 84 <div class="modal-item-title"></div> 85 85 <div class="modal-item-url"></div> 86 86 </div> 87 + <button class="modal-open-page-btn" style="display: none;" title="Open page"> 88 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 89 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> 90 + <polyline points="15 3 21 3 21 9"></polyline> 91 + <line x1="10" y1="14" x2="21" y2="3"></line> 92 + </svg> 93 + <span>Open Page</span> 94 + </button> 87 95 </div> 88 96 89 97 <div class="edit-section">
+103 -4
extensions/tags/home.js
··· 12 12 const debug = api?.debug; 13 13 14 14 /** 15 + * Extract the first URL from a text string. 16 + * @param {string} text - The text to search for URLs 17 + * @returns {string|null} The first URL found, or null 18 + */ 19 + const extractUrl = (text) => { 20 + if (!text) return null; 21 + const trimmed = text.trim(); 22 + // Check if the entire content is a URL (with protocol) 23 + if (/^https?:\/\//i.test(trimmed)) { 24 + try { new URL(trimmed); return trimmed; } catch (e) { /* fall through */ } 25 + } 26 + // Check if it looks like a bare domain 27 + if (/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/\S*)?$/.test(trimmed)) { 28 + try { new URL('https://' + trimmed); return 'https://' + trimmed; } catch (e) { /* not valid */ } 29 + } 30 + // Search for a URL embedded in the text 31 + const match = trimmed.match(/https?:\/\/[^\s<>"')\]]+/i); 32 + if (match) { try { new URL(match[0]); return match[0]; } catch (e) { /* not valid */ } } 33 + return null; 34 + }; 35 + 36 + /** 37 + * Open a URL in a new page window (same webview mechanism as URL items). 38 + * Routes through peek://page which provides the webview container. 39 + * @param {string} url - The URL to open 40 + */ 41 + const openItemUrl = async (url) => { 42 + try { 43 + await api.window.open(url, { 44 + width: 800, 45 + height: 600, 46 + trackingSource: 'tags', 47 + trackingSourceId: 'note-url' 48 + }); 49 + debug && console.log('[tags] Opened URL from note:', url); 50 + } catch (err) { 51 + console.error('[tags] Failed to open URL:', err); 52 + } 53 + }; 54 + 55 + /** 15 56 * Simple debounce helper - collapses rapid calls into one 16 57 */ 17 58 const debounce = (fn, ms) => { ··· 615 656 const isAddress = !!item.uri; 616 657 const itemType = item.type || 'url'; 617 658 659 + // For text items, check if content contains a URL 660 + const noteUrl = (itemType === 'text') ? extractUrl(item.content) : null; 661 + 618 662 let title, subtitle, faviconUrl; 619 663 620 664 if (isAddress) { ··· 630 674 faviconUrl = '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>'; 631 675 } else if (itemType === 'text') { 632 676 title = item.content.substring(0, 100) + (item.content.length > 100 ? '...' : ''); 633 - subtitle = 'Text'; 634 - faviconUrl = '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>'; 677 + if (noteUrl) { 678 + // Note contains a URL - show the URL as subtitle and use a link icon 679 + subtitle = noteUrl; 680 + faviconUrl = '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>'; 681 + } else { 682 + subtitle = 'Text'; 683 + faviconUrl = '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>'; 684 + } 635 685 } else if (itemType === 'tagset') { 636 686 title = 'Tag Set'; 637 687 subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset'; ··· 643 693 } 644 694 } 645 695 696 + // Build the open button for items with URLs (both url type and notes with URLs) 697 + const itemUrl = isAddress ? item.uri : (itemType === 'url') ? item.content : noteUrl; 698 + const openBtnHtml = itemUrl 699 + ? `<button class="card-open-btn" data-url="${escapeHtml(itemUrl)}" title="Open page">` + 700 + `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` + 701 + `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>` + 702 + `<polyline points="15 3 21 3 21 9"></polyline>` + 703 + `<line x1="10" y1="14" x2="21" y2="3"></line>` + 704 + `</svg></button>` 705 + : ''; 706 + 646 707 card.innerHTML = ` 647 708 <div class="card-header"> 648 709 <img class="card-favicon" src="${escapeHtml(faviconUrl)}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>๐ŸŒ</text></svg>'"> ··· 650 711 <div class="card-title">${escapeHtml(title)}</div> 651 712 <div class="card-url">${escapeHtml(subtitle)}</div> 652 713 </div> 714 + ${openBtnHtml} 653 715 </div> 654 716 <div class="card-tags"> 655 717 ${tags.map(tag => `<span class="card-tag" data-tag-id="${tag.id}">${escapeHtml(tag.name)}</span>`).join('')} 656 718 </div> 657 719 `; 658 720 721 + // Click handler for the open button - opens the URL as a web page 722 + const openBtn = card.querySelector('.card-open-btn'); 723 + if (openBtn) { 724 + openBtn.addEventListener('click', (e) => { 725 + e.stopPropagation(); 726 + const url = openBtn.dataset.url; 727 + if (url) openItemUrl(url); 728 + }); 729 + } 730 + 659 731 // Click on card to open edit modal 660 732 card.addEventListener('click', (e) => { 661 733 // If clicking a tag, add it to filter (toggle behavior) ··· 695 767 const isAddress = !!item.uri; 696 768 const itemType = item.type || 'url'; 697 769 770 + // For text items, check if content contains a URL 771 + const noteUrl = (itemType === 'text') ? extractUrl(item.content) : null; 772 + 698 773 let title, subtitle, faviconUrl; 699 774 700 775 if (isAddress) { ··· 710 785 faviconUrl = '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>'; 711 786 } else if (itemType === 'text') { 712 787 title = item.content.substring(0, 100) + (item.content.length > 100 ? '...' : ''); 713 - subtitle = 'Text'; 714 - faviconUrl = '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>'; 788 + if (noteUrl) { 789 + subtitle = noteUrl; 790 + faviconUrl = '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>'; 791 + } else { 792 + subtitle = 'Text'; 793 + faviconUrl = '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>'; 794 + } 715 795 } else if (itemType === 'tagset') { 716 796 title = 'Tag Set'; 717 797 subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset'; ··· 727 807 modal.querySelector('.modal-favicon').src = faviconUrl; 728 808 modal.querySelector('.modal-item-title').textContent = title; 729 809 modal.querySelector('.modal-item-url').textContent = subtitle; 810 + 811 + // Show or hide the "Open Page" button based on whether item has a URL 812 + const openPageBtn = modal.querySelector('.modal-open-page-btn'); 813 + const itemUrl = isAddress ? item.uri : (itemType === 'url' ? item.content : noteUrl); 814 + if (openPageBtn) { 815 + if (itemUrl) { 816 + openPageBtn.style.display = 'flex'; 817 + openPageBtn.dataset.url = itemUrl; 818 + // Clone to remove old listeners, then add fresh listener 819 + const newBtn = openPageBtn.cloneNode(true); 820 + openPageBtn.parentNode.replaceChild(newBtn, openPageBtn); 821 + newBtn.addEventListener('click', () => { 822 + openItemUrl(newBtn.dataset.url); 823 + closeModal(); 824 + }); 825 + } else { 826 + openPageBtn.style.display = 'none'; 827 + } 828 + } 730 829 731 830 // Render current tags 732 831 renderCurrentTags(tags);