jack's avatar
jack 7 months ago
source: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Nostr Media Feed</title> <style> :root { --bg-color: #ffffff; --text-color: #333333; } [data-theme="dark"] { --bg-color: #1a1a1a; --text-color: #ffffff; } body { font-family: -apple-system, system-ui, sans-serif; margin: 0; padding: 0; background: var(--bg-color); color: var(--text-color); } #header { position: fixed; top: 0; left: 0; right: 0; padding: 15px 20px; background: var(--bg-color); display: flex; justify-content: space-between; align-items: center; z-index: 1000; font-size: 14px; } #feed { margin-top: 52px; } .note { margin-bottom: 0; } .media-container { background: #000; line-height: 0; width: 100%; } .media-container a { display: block; line-height: 0; } .media-container img, .media-container video { width: 100%; height: auto; object-fit: contain; opacity: 0; transition: opacity 0.5s ease-in; } .media-container img.loaded, .media-container video.loaded { opacity: 1; } #status { display: flex; align-items: center; gap: 6px; opacity: 0.7; } .status-dot { width: 6px; height: 6px; border-radius: 50%; } .status-live .status-dot { background: #4CAF50; } .status-paused .status-dot { background: #ff9800; } @media (min-width: 800px) { .media-container img, .media-container video { max-height: 100vh; } } @media (max-width: 799px) { .media-container img, .media-container video { max-height: none; } } </style> </head> <body> <div id="header"> <div id="status" class="status-live"> <div class="status-dot"></div> <span>Live</span> </div> </div> <div id="feed"></div> <script> // Auto dark mode if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.body.setAttribute('data-theme', 'dark'); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light'); }); // Initialize const feed = document.getElementById('feed'); const status = document.getElementById('status'); const seenNotes = new Set(); const seenMedia = new Set(); let isPaused = false; // Relays const RELAYS = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.nostr.info' ]; const relayPool = new Map(); // Function to update status display function updateStatus(paused) { status.className = paused ? 'status-paused' : 'status-live'; status.querySelector('span').textContent = paused ? 'Paused' : 'Live'; } // Pause/Resume based on scroll position let lastScrollTop = 0; window.addEventListener('scroll', () => { const st = window.pageYOffset || document.documentElement.scrollTop; if (st > lastScrollTop && st > 100) { // Scrolling down if (!isPaused) { isPaused = true; updateStatus(true); } } else if (st === 0) { // At top if (isPaused) { isPaused = false; updateStatus(false); } } lastScrollTop = st; }); // Connect to relays function connect() { let connectedRelays = 0; RELAYS.forEach(relayUrl => { const socket = new WebSocket(relayUrl); relayPool.set(relayUrl, socket); socket.onopen = () => { connectedRelays++; if (connectedRelays === 1) { updateStatus(false); } // Subscribe to notes with media const recentSub = JSON.stringify([ "REQ", "recent_" + relayUrl, { "kinds": [1], "limit": 500 } ]); socket.send(recentSub); }; socket.onclose = () => { relayPool.delete(relayUrl); connectedRelays--; if (connectedRelays === 0) { setTimeout(() => connect(), 2000); } }; socket.onerror = (error) => { console.error('WebSocket error:', error); }; // Handle incoming messages socket.onmessage = async (event) => { if (isPaused) return; const data = JSON.parse(event.data); if (data[0] !== 'EVENT') return; const msg = data[2]; // Handle notes if (msg.kind !== 1) return; if (seenNotes.has(msg.id)) return; seenNotes.add(msg.id); // Look for media URLs const mediaUrls = []; const urlRegex = /(https?:\/\/[^\s<]+\.(jpg|jpeg|png|gif|mp4|webm))/gi; let match; while ((match = urlRegex.exec(msg.content)) !== null) { mediaUrls.push(match[0]); } if (mediaUrls.length === 0) return; // Check for duplicate media const mediaKey = mediaUrls.sort().join(','); if (seenMedia.has(mediaKey)) return; seenMedia.add(mediaKey); try { // Create note element const noteEl = document.createElement('div'); noteEl.className = 'note'; // Add media const mediaContainer = document.createElement('div'); mediaContainer.className = 'media-container'; // Make media container clickable const mediaLink = document.createElement('a'); mediaLink.href = `https://njump.me/${msg.id}`; mediaLink.target = '_blank'; mediaLink.style.cursor = 'pointer'; mediaContainer.appendChild(mediaLink); for (const url of mediaUrls) { if (url.match(/\.(jpg|jpeg|png|gif)$/i)) { try { // Create and preload image const img = document.createElement('img'); img.style.opacity = '0'; img.src = url; img.loading = 'lazy'; // Wait for image to load await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); // Add to container and fade in mediaLink.appendChild(img); requestAnimationFrame(() => { img.style.opacity = '1'; }); } catch (e) { console.error('Failed to load image:', url); } } else { const video = document.createElement('video'); video.src = url; video.controls = true; video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; video.style.opacity = '0'; // Fade in once video starts playing video.addEventListener('playing', () => { requestAnimationFrame(() => { video.style.opacity = '1'; }); }, { once: true }); video.onerror = () => { video.remove(); }; mediaLink.appendChild(video); // Try to start playing video.play().catch(e => console.log('Auto-play prevented:', e)); } } noteEl.appendChild(mediaContainer); feed.insertBefore(noteEl, feed.firstChild); } catch (e) { console.error('Error creating note element:', e); } }; }); } // Initial connection connect(); </script> </body> </html>

Replies (6)

Nice. Kinda funny, amethyst thinks the CSS colors are hashtags and lists them at the bottom of this post ๐Ÿ˜…
This is some of the best code I've seen in a nostr client. Unlike every other client, it uses pure javascript, which is native everywhere. It doesnt require a compiler. It's blazingly fast. And it just works. Well done Jack, this is a gold standard in how to write client apps. Keep going!
You can just note the source too
jack's avatar jack
source: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Nostr Media Feed</title> <style> :root { --bg-color: #ffffff; --text-color: #333333; } [data-theme="dark"] { --bg-color: #1a1a1a; --text-color: #ffffff; } body { font-family: -apple-system, system-ui, sans-serif; margin: 0; padding: 0; background: var(--bg-color); color: var(--text-color); } #header { position: fixed; top: 0; left: 0; right: 0; padding: 15px 20px; background: var(--bg-color); display: flex; justify-content: space-between; align-items: center; z-index: 1000; font-size: 14px; } #feed { margin-top: 52px; } .note { margin-bottom: 0; } .media-container { background: #000; line-height: 0; width: 100%; } .media-container a { display: block; line-height: 0; } .media-container img, .media-container video { width: 100%; height: auto; object-fit: contain; opacity: 0; transition: opacity 0.5s ease-in; } .media-container img.loaded, .media-container video.loaded { opacity: 1; } #status { display: flex; align-items: center; gap: 6px; opacity: 0.7; } .status-dot { width: 6px; height: 6px; border-radius: 50%; } .status-live .status-dot { background: #4CAF50; } .status-paused .status-dot { background: #ff9800; } @media (min-width: 800px) { .media-container img, .media-container video { max-height: 100vh; } } @media (max-width: 799px) { .media-container img, .media-container video { max-height: none; } } </style> </head> <body> <div id="header"> <div id="status" class="status-live"> <div class="status-dot"></div> <span>Live</span> </div> </div> <div id="feed"></div> <script> // Auto dark mode if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.body.setAttribute('data-theme', 'dark'); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light'); }); // Initialize const feed = document.getElementById('feed'); const status = document.getElementById('status'); const seenNotes = new Set(); const seenMedia = new Set(); let isPaused = false; // Relays const RELAYS = [ 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.nostr.info' ]; const relayPool = new Map(); // Function to update status display function updateStatus(paused) { status.className = paused ? 'status-paused' : 'status-live'; status.querySelector('span').textContent = paused ? 'Paused' : 'Live'; } // Pause/Resume based on scroll position let lastScrollTop = 0; window.addEventListener('scroll', () => { const st = window.pageYOffset || document.documentElement.scrollTop; if (st > lastScrollTop && st > 100) { // Scrolling down if (!isPaused) { isPaused = true; updateStatus(true); } } else if (st === 0) { // At top if (isPaused) { isPaused = false; updateStatus(false); } } lastScrollTop = st; }); // Connect to relays function connect() { let connectedRelays = 0; RELAYS.forEach(relayUrl => { const socket = new WebSocket(relayUrl); relayPool.set(relayUrl, socket); socket.onopen = () => { connectedRelays++; if (connectedRelays === 1) { updateStatus(false); } // Subscribe to notes with media const recentSub = JSON.stringify([ "REQ", "recent_" + relayUrl, { "kinds": [1], "limit": 500 } ]); socket.send(recentSub); }; socket.onclose = () => { relayPool.delete(relayUrl); connectedRelays--; if (connectedRelays === 0) { setTimeout(() => connect(), 2000); } }; socket.onerror = (error) => { console.error('WebSocket error:', error); }; // Handle incoming messages socket.onmessage = async (event) => { if (isPaused) return; const data = JSON.parse(event.data); if (data[0] !== 'EVENT') return; const msg = data[2]; // Handle notes if (msg.kind !== 1) return; if (seenNotes.has(msg.id)) return; seenNotes.add(msg.id); // Look for media URLs const mediaUrls = []; const urlRegex = /(https?:\/\/[^\s<]+\.(jpg|jpeg|png|gif|mp4|webm))/gi; let match; while ((match = urlRegex.exec(msg.content)) !== null) { mediaUrls.push(match[0]); } if (mediaUrls.length === 0) return; // Check for duplicate media const mediaKey = mediaUrls.sort().join(','); if (seenMedia.has(mediaKey)) return; seenMedia.add(mediaKey); try { // Create note element const noteEl = document.createElement('div'); noteEl.className = 'note'; // Add media const mediaContainer = document.createElement('div'); mediaContainer.className = 'media-container'; // Make media container clickable const mediaLink = document.createElement('a'); mediaLink.href = `https://njump.me/${msg.id}`; mediaLink.target = '_blank'; mediaLink.style.cursor = 'pointer'; mediaContainer.appendChild(mediaLink); for (const url of mediaUrls) { if (url.match(/\.(jpg|jpeg|png|gif)$/i)) { try { // Create and preload image const img = document.createElement('img'); img.style.opacity = '0'; img.src = url; img.loading = 'lazy'; // Wait for image to load await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); // Add to container and fade in mediaLink.appendChild(img); requestAnimationFrame(() => { img.style.opacity = '1'; }); } catch (e) { console.error('Failed to load image:', url); } } else { const video = document.createElement('video'); video.src = url; video.controls = true; video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; video.style.opacity = '0'; // Fade in once video starts playing video.addEventListener('playing', () => { requestAnimationFrame(() => { video.style.opacity = '1'; }); }, { once: true }); video.onerror = () => { video.remove(); }; mediaLink.appendChild(video); // Try to start playing video.play().catch(e => console.log('Auto-play prevented:', e)); } } noteEl.appendChild(mediaContainer); feed.insertBefore(noteEl, feed.firstChild); } catch (e) { console.error('Error creating note element:', e); } }; }); } // Initial connection connect(); </script> </body> </html>
View quoted note →
โ†‘