slack status without the slack status.zzstoatzz.io
hatk statusphere
0
fork

Configure Feed

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

add shareable status permalinks with og previews

- share button on status items (hover to reveal, copies permalink)
- new /status/{did}/{rkey} route for individual statuses
- cloudflare pages function injects og tags for social bots
- uses bufo.zone images for custom emoji og:image

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 64a6cd14 97d16bc2

+373 -8
+4
.gitignore
··· 1 1 # wrangler/cloudflare 2 2 .wrangler/ 3 3 4 + # node 5 + node_modules/ 6 + package-lock.json 7 + 4 8 # notes 5 9 oauth-experience.md
+159 -8
site/app.js
··· 5 5 }; 6 6 7 7 // Base path for routing (empty for root domain, '/subpath' for subdirectory) 8 - const BASE_PATH = ''; 8 + // Auto-detect from pathname for wisp.place style hosting (/did:xxx/sitename) 9 + const BASE_PATH = (() => { 10 + const match = window.location.pathname.match(/^(\/did:[^/]+\/[^/]+)/); 11 + return match ? match[1] : ''; 12 + })(); 9 13 10 14 let client = null; 11 15 let userPreferences = null; ··· 661 665 return div.innerHTML; 662 666 } 663 667 668 + // Extract did and rkey from status uri (at://did/collection/rkey) 669 + function parseStatusUri(uri) { 670 + const parts = uri.split('/'); 671 + const did = parts[2]; 672 + const rkey = parts[parts.length - 1]; 673 + return { did, rkey }; 674 + } 675 + 676 + // Build permalink for a status 677 + function getStatusPermalink(uri) { 678 + const { did, rkey } = parseStatusUri(uri); 679 + return `${window.location.origin}/status/${did}/${rkey}`; 680 + } 681 + 682 + // Copy text to clipboard with visual feedback 683 + async function copyToClipboard(text, button) { 684 + try { 685 + await navigator.clipboard.writeText(text); 686 + button.classList.add('copied'); 687 + setTimeout(() => button.classList.remove('copied'), 1500); 688 + } catch (e) { 689 + console.error('Failed to copy:', e); 690 + } 691 + } 692 + 664 693 // Parse markdown links [text](url) and return HTML 665 694 function parseLinks(text) { 666 695 if (!text) return ''; ··· 698 727 699 728 // Router 700 729 function getRoute() { 701 - const path = window.location.pathname; 730 + let path = window.location.pathname; 731 + // Strip base path if present (for wisp.place or other subdirectory hosting) 732 + if (BASE_PATH && path.startsWith(BASE_PATH)) { 733 + path = path.slice(BASE_PATH.length) || '/'; 734 + } 702 735 if (path === '/' || path === '/index.html') return { page: 'home' }; 703 736 if (path === '/feed' || path === '/feed.html') return { page: 'feed' }; 704 737 if (path.startsWith('/@')) { 705 738 const handle = path.slice(2); 706 739 return { page: 'profile', handle }; 740 + } 741 + // Match /status/{did}/{rkey} 742 + const statusMatch = path.match(/^\/status\/(did:[^/]+)\/([^/]+)$/); 743 + if (statusMatch) { 744 + return { page: 'status', did: statusMatch[1], rkey: statusMatch[2] }; 707 745 } 708 746 return { page: '404' }; 709 747 } ··· 852 890 <div>${s.text ? `<span class="text">${parseLinks(s.text)}</span>` : ''}</div> 853 891 <span class="time">${relativeTime(s.createdAt)}</span> 854 892 </div> 855 - <button class="delete-btn" data-rkey="${escapeHtml(rkey)}" title="delete"> 856 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 857 - <line x1="18" y1="6" x2="6" y2="18"></line> 858 - <line x1="6" y1="6" x2="18" y2="18"></line> 859 - </svg> 860 - </button> 893 + <div class="status-actions"> 894 + <button class="share-btn" data-uri="${escapeHtml(s.uri)}" title="copy link"> 895 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 896 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> 897 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> 898 + </svg> 899 + </button> 900 + <button class="delete-btn" data-rkey="${escapeHtml(rkey)}" title="delete"> 901 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 902 + <line x1="18" y1="6" x2="6" y2="18"></line> 903 + <line x1="6" y1="6" x2="18" y2="18"></line> 904 + </svg> 905 + </button> 906 + </div> 861 907 </div> 862 908 `; 863 909 }); ··· 981 1027 } 982 1028 }); 983 1029 }); 1030 + 1031 + // Share buttons 1032 + document.querySelectorAll('.share-btn').forEach(btn => { 1033 + btn.addEventListener('click', () => { 1034 + const uri = btn.dataset.uri; 1035 + const permalink = getStatusPermalink(uri); 1036 + copyToClipboard(permalink, btn); 1037 + }); 1038 + }); 984 1039 } 985 1040 } catch (e) { 986 1041 console.error('Failed to init:', e); ··· 1044 1099 </div> 1045 1100 <span class="time">${relativeTime(status.createdAt)}</span> 1046 1101 </div> 1102 + <button class="share-btn" data-uri="${escapeHtml(status.uri)}" title="copy link"> 1103 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1104 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> 1105 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> 1106 + </svg> 1107 + </button> 1047 1108 `; 1109 + // Attach share button handler 1110 + div.querySelector('.share-btn').addEventListener('click', (e) => { 1111 + const permalink = getStatusPermalink(status.uri); 1112 + copyToClipboard(permalink, e.currentTarget); 1113 + }); 1048 1114 feedList.appendChild(div); 1049 1115 }); 1050 1116 ··· 1139 1205 <div>${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''}</div> 1140 1206 <span class="time">${relativeTime(status.createdAt)}</span> 1141 1207 </div> 1208 + <button class="share-btn" data-uri="${escapeHtml(status.uri)}" title="copy link"> 1209 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1210 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> 1211 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> 1212 + </svg> 1213 + </button> 1142 1214 </div> 1143 1215 `; 1144 1216 }); ··· 1146 1218 } 1147 1219 1148 1220 main.innerHTML = html; 1221 + 1222 + // Share buttons 1223 + document.querySelectorAll('.share-btn').forEach(btn => { 1224 + btn.addEventListener('click', () => { 1225 + const uri = btn.dataset.uri; 1226 + const permalink = getStatusPermalink(uri); 1227 + copyToClipboard(permalink, btn); 1228 + }); 1229 + }); 1149 1230 } catch (e) { 1150 1231 console.error('Failed to load profile:', e); 1151 1232 main.innerHTML = '<div class="center">failed to load profile</div>'; 1152 1233 } 1153 1234 } 1154 1235 1236 + // Render single status permalink page 1237 + async function renderStatus(did, rkey) { 1238 + const main = document.getElementById('main-content'); 1239 + const pageTitle = document.getElementById('page-title'); 1240 + 1241 + // Initialize auth UI for header elements 1242 + await initAuthUI(); 1243 + 1244 + pageTitle.textContent = 'status'; 1245 + main.innerHTML = '<div class="center">loading...</div>'; 1246 + 1247 + try { 1248 + // Fetch the specific status by did and rkey 1249 + const res = await fetch(`${CONFIG.server}/graphql`, { 1250 + method: 'POST', 1251 + headers: { 'Content-Type': 'application/json' }, 1252 + body: JSON.stringify({ 1253 + query: ` 1254 + query GetStatus($did: String!, $rkey: String!) { 1255 + ioZzstoatzzStatusRecord( 1256 + first: 1 1257 + where: { 1258 + did: { eq: $did } 1259 + uri: { endsWith: $rkey } 1260 + } 1261 + ) { 1262 + edges { node { uri did actorHandle emoji text createdAt expires } } 1263 + } 1264 + } 1265 + `, 1266 + variables: { did, rkey: `/${rkey}` } 1267 + }) 1268 + }); 1269 + 1270 + const json = await res.json(); 1271 + const statuses = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node) || []; 1272 + 1273 + if (statuses.length === 0) { 1274 + main.innerHTML = '<div class="center">status not found</div>'; 1275 + return; 1276 + } 1277 + 1278 + const status = statuses[0]; 1279 + const handle = status.actorHandle || await resolveDidToHandle(status.did) || status.did.slice(8, 28); 1280 + const expiresHtml = status.expires ? ` • ${formatExpiration(status.expires)}` : ''; 1281 + 1282 + pageTitle.innerHTML = `<a href="/@${handle}" target="_blank">@${handle}</a>`; 1283 + 1284 + main.innerHTML = ` 1285 + <div class="profile-card"> 1286 + <div class="current-status"> 1287 + <span class="big-emoji">${renderEmoji(status.emoji)}</span> 1288 + <div class="status-info"> 1289 + ${status.text ? `<span id="current-text">${parseLinks(status.text)}</span>` : ''} 1290 + <span class="meta">${relativeTime(status.createdAt)}${expiresHtml}</span> 1291 + </div> 1292 + </div> 1293 + </div> 1294 + <div class="center"> 1295 + <a href="/@${handle}" class="view-profile-link">view all statuses from @${handle}</a> 1296 + </div> 1297 + `; 1298 + } catch (e) { 1299 + console.error('Failed to load status:', e); 1300 + main.innerHTML = '<div class="center">failed to load status</div>'; 1301 + } 1302 + } 1303 + 1155 1304 // Update nav active state - hide current page icon, show the other 1156 1305 function updateNavActive(page) { 1157 1306 const navHome = document.getElementById('nav-home'); ··· 1235 1384 renderFeed(); 1236 1385 } else if (route.page === 'profile') { 1237 1386 renderProfile(route.handle); 1387 + } else if (route.page === 'status') { 1388 + renderStatus(route.did, route.rkey); 1238 1389 } else { 1239 1390 document.getElementById('main-content').innerHTML = '<div class="center">page not found</div>'; 1240 1391 }
+144
site/functions/status/[did]/[rkey].js
··· 1 + // CloudFlare Pages Function to handle /status/:did/:rkey routes 2 + // Injects OG meta tags for social media crawlers 3 + 4 + const GRAPHQL_ENDPOINT = 'https://zzstoatzz-quickslice-status.fly.dev/graphql'; 5 + const SITE_URL = 'https://status.zzstoatzz.io'; 6 + 7 + // Social media bot user agents 8 + const BOT_USER_AGENTS = [ 9 + 'Twitterbot', 10 + 'facebookexternalhit', 11 + 'LinkedInBot', 12 + 'Slackbot', 13 + 'Discordbot', 14 + 'TelegramBot', 15 + 'WhatsApp', 16 + 'Bluesky', 17 + ]; 18 + 19 + function isSocialBot(userAgent) { 20 + if (!userAgent) return false; 21 + return BOT_USER_AGENTS.some(bot => userAgent.includes(bot)); 22 + } 23 + 24 + async function fetchStatus(did, rkey) { 25 + const response = await fetch(GRAPHQL_ENDPOINT, { 26 + method: 'POST', 27 + headers: { 'Content-Type': 'application/json' }, 28 + body: JSON.stringify({ 29 + query: ` 30 + query GetStatus($did: String!, $rkey: String!) { 31 + ioZzstoatzzStatusRecord( 32 + first: 1 33 + where: { 34 + did: { eq: $did } 35 + uri: { endsWith: $rkey } 36 + } 37 + ) { 38 + edges { node { uri did actorHandle emoji text createdAt } } 39 + } 40 + } 41 + `, 42 + variables: { did, rkey: `/${rkey}` } 43 + }) 44 + }); 45 + 46 + const json = await response.json(); 47 + const statuses = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node) || []; 48 + return statuses[0] || null; 49 + } 50 + 51 + function getEmojiDisplay(emoji) { 52 + if (emoji && emoji.startsWith('custom:')) { 53 + return emoji.slice(7).replace(/-/g, ' '); // "bufo-stab" -> "bufo stab" 54 + } 55 + return emoji || ''; 56 + } 57 + 58 + function getOgImageUrl(emoji) { 59 + // For custom emojis (bufos), use the bufo.zone image directly 60 + if (emoji && emoji.startsWith('custom:')) { 61 + const name = emoji.slice(7); 62 + return `https://all-the.bufo.zone/${name}.png`; 63 + } 64 + // For standard emojis, no image (social platforms will use text) 65 + return null; 66 + } 67 + 68 + function generateOgHtml(status, did, rkey, handle) { 69 + const emojiDisplay = getEmojiDisplay(status.emoji); 70 + const title = `@${handle}'s status`; 71 + const description = status.text 72 + ? `${emojiDisplay} ${status.text}` 73 + : emojiDisplay; 74 + const url = `${SITE_URL}/status/${did}/${rkey}`; 75 + const imageUrl = getOgImageUrl(status.emoji); 76 + 77 + const imageMetaTags = imageUrl ? ` 78 + <meta property="og:image" content="${imageUrl}"> 79 + <meta name="twitter:image" content="${imageUrl}">` : ''; 80 + 81 + const twitterCard = imageUrl ? 'summary_large_image' : 'summary'; 82 + 83 + return `<!DOCTYPE html> 84 + <html lang="en"> 85 + <head> 86 + <meta charset="utf-8"> 87 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 88 + <title>${title} | status</title> 89 + 90 + <!-- Open Graph --> 91 + <meta property="og:type" content="website"> 92 + <meta property="og:title" content="${title}"> 93 + <meta property="og:description" content="${description}"> 94 + <meta property="og:url" content="${url}"> 95 + <meta property="og:site_name" content="status">${imageMetaTags} 96 + 97 + <!-- Twitter Card --> 98 + <meta name="twitter:card" content="${twitterCard}"> 99 + <meta name="twitter:title" content="${title}"> 100 + <meta name="twitter:description" content="${description}"> 101 + 102 + <!-- Redirect browsers to the actual page --> 103 + <meta http-equiv="refresh" content="0;url=${url}"> 104 + <link rel="canonical" href="${url}"> 105 + </head> 106 + <body> 107 + <p>Redirecting to <a href="${url}">${url}</a></p> 108 + </body> 109 + </html>`; 110 + } 111 + 112 + export async function onRequest(context) { 113 + const { request, params, next } = context; 114 + const { did, rkey } = params; 115 + 116 + const userAgent = request.headers.get('user-agent') || ''; 117 + 118 + // If not a social bot, pass through to the SPA 119 + if (!isSocialBot(userAgent)) { 120 + return next(); 121 + } 122 + 123 + try { 124 + const status = await fetchStatus(did, rkey); 125 + 126 + if (!status) { 127 + // Status not found, let the SPA handle it 128 + return next(); 129 + } 130 + 131 + const handle = status.actorHandle || did.slice(8, 28); 132 + const html = generateOgHtml(status, did, rkey, handle); 133 + 134 + return new Response(html, { 135 + headers: { 136 + 'Content-Type': 'text/html;charset=UTF-8', 137 + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour 138 + }, 139 + }); 140 + } catch (error) { 141 + console.error('Error fetching status for OG tags:', error); 142 + return next(); 143 + } 144 + }
+13
site/index.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>status</title> 7 + 8 + <!-- Open Graph (fallback) --> 9 + <meta property="og:type" content="website"> 10 + <meta property="og:title" content="status"> 11 + <meta property="og:description" content="share your status"> 12 + <meta property="og:url" content="https://status.zzstoatzz.io"> 13 + <meta property="og:site_name" content="status"> 14 + 15 + <!-- Twitter Card (fallback) --> 16 + <meta name="twitter:card" content="summary"> 17 + <meta name="twitter:title" content="status"> 18 + <meta name="twitter:description" content="share your status"> 19 + 7 20 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 21 <link rel="stylesheet" href="/styles.css"> 9 22 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@v0.17.3/quickslice-client-js/dist/quickslice-client.min.js"></script>
+7
site/package.json
··· 1 + { 2 + "name": "status-site", 3 + "private": true, 4 + "dependencies": { 5 + "@cloudflare/pages-plugin-vercel-og": "^0.1.2" 6 + } 7 + }
+46
site/styles.css
··· 344 344 margin-top: 0.25rem; 345 345 } 346 346 347 + .status-actions { 348 + display: flex; 349 + gap: 0.25rem; 350 + flex-shrink: 0; 351 + } 352 + 353 + .share-btn, 347 354 .delete-btn { 348 355 background: transparent; 349 356 border: none; ··· 356 363 flex-shrink: 0; 357 364 } 358 365 366 + .status-item:hover .share-btn, 359 367 .status-item:hover .delete-btn { 360 368 opacity: 1; 361 369 } 362 370 371 + .share-btn:hover { 372 + color: var(--accent); 373 + } 374 + 375 + .share-btn.copied { 376 + color: var(--accent); 377 + opacity: 1; 378 + } 379 + 380 + .share-btn.copied::after { 381 + content: 'copied!'; 382 + position: absolute; 383 + font-size: 0.75rem; 384 + background: var(--bg-card); 385 + border: 1px solid var(--border); 386 + padding: 0.25rem 0.5rem; 387 + border-radius: 4px; 388 + transform: translateY(-100%) translateX(-50%); 389 + left: 50%; 390 + top: -4px; 391 + white-space: nowrap; 392 + } 393 + 394 + .share-btn { 395 + position: relative; 396 + } 397 + 363 398 .delete-btn:hover { 364 399 color: #e74c3c; 400 + } 401 + 402 + /* View profile link */ 403 + .view-profile-link { 404 + color: var(--accent); 405 + text-decoration: none; 406 + font-size: 0.875rem; 407 + } 408 + 409 + .view-profile-link:hover { 410 + text-decoration: underline; 365 411 } 366 412 367 413 /* Logout */