interactive intro to open social at-me.zzstoatzz.io

feat: dynamic app circle removal and improved toast notifications

app circle lifecycle:
- automatically remove app circle when delete particle animation completes
- added removeAppCircle() function to clean up DOM and globalApps state
- repositions remaining circles smoothly after removal

fixed dynamically added circles:
- app circles created via firehose now properly fetch and display records
- clicking circle loads record count from PDS
- expanding records shows full data with copy functionality
- fixes "loading..." state that never resolved

improved toast notifications:
- hide "view record" link for delete events (no record to view)
- collection names now use inline code formatting for better readability
- code style: monospace background, subtle padding, reduced font size
- changed to innerHTML to support formatted content

this completes the guestbook circle lifecycle: create → visualize → interact → delete → remove

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

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

+148 -10
+148 -10
static/app.js
··· 1047 1047 } else { 1048 1048 // Particle reached destination - pulse the identity/PDS 1049 1049 pulseIdentity(); 1050 + 1051 + // If this was a delete event, remove the app circle 1052 + if (particle.metadata && particle.metadata.action === 'delete') { 1053 + removeAppCircle(particle.metadata.namespace); 1054 + } 1050 1055 } 1051 1056 return alive; 1052 1057 }); ··· 1091 1096 'delete': 'deleted' 1092 1097 }[action] || action; 1093 1098 1094 - // If we don't have record details, fall back to basic message 1099 + // If we don't have record details, fall back to basic message with code-formatted collection 1095 1100 if (!record) { 1096 1101 return { 1097 1102 action: `${actionText} record`, 1098 - details: collection 1103 + details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1099 1104 }; 1100 1105 } 1101 1106 ··· 1130 1135 }; 1131 1136 } 1132 1137 1133 - // Default for unknown collections 1138 + // Default for unknown collections with code formatting 1134 1139 return { 1135 1140 action: `${actionText} record`, 1136 - details: collection 1141 + details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1137 1142 }; 1138 1143 } 1139 1144 ··· 1143 1148 const collectionEl = toast.querySelector('.firehose-toast-collection'); 1144 1149 const linkEl = document.getElementById('firehoseToastLink'); 1145 1150 1146 - // Build PDS link for the record 1147 - if (globalPds && event.did && event.collection && event.rkey) { 1148 - const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 1149 - linkEl.href = recordUrl; 1151 + // Hide link for delete events, show for others 1152 + if (event.action === 'delete') { 1153 + linkEl.style.display = 'none'; 1154 + } else { 1155 + linkEl.style.display = 'inline-block'; 1156 + // Build PDS link for the record 1157 + if (globalPds && event.did && event.collection && event.rkey) { 1158 + const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 1159 + linkEl.href = recordUrl; 1160 + } 1150 1161 } 1151 1162 1152 1163 // Fetch record details if available (skip for deletes) ··· 1158 1169 const formatted = formatToastMessage(event.action, event.collection, record); 1159 1170 1160 1171 actionEl.textContent = formatted.action; 1161 - collectionEl.textContent = formatted.details; 1172 + collectionEl.innerHTML = formatted.details; 1162 1173 1163 1174 toast.classList.add('visible'); 1164 1175 setTimeout(() => { ··· 1503 1514 // Add click handler 1504 1515 div.addEventListener('click', () => { 1505 1516 const detail = document.getElementById('detail'); 1517 + const collections = globalApps[namespace] || []; 1518 + 1506 1519 detail.innerHTML = ` 1507 1520 <button class="detail-close" id="detailClose">×</button> 1508 1521 <h3><a href="${url}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} ↗</a></h3> 1509 1522 <div class="subtitle">guestbook for your PDS</div> 1510 - <div class="tree-item"> 1523 + <div class="tree-item" data-lexicon="app.at-me.visit"> 1511 1524 <div class="tree-item-header"> 1512 1525 <span>visit</span> 1513 1526 <span class="tree-item-count">loading...</span> ··· 1520 1533 e.stopPropagation(); 1521 1534 detail.classList.remove('visible'); 1522 1535 }); 1536 + 1537 + // Fetch record count 1538 + const collection = 'app.at-me.visit'; 1539 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=1`) 1540 + .then(r => r.json()) 1541 + .then(data => { 1542 + const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1543 + if (item) { 1544 + const countSpan = item.querySelector('.tree-item-count'); 1545 + countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 1546 + } 1547 + }) 1548 + .catch(e => { 1549 + console.error('Error fetching count for', collection, e); 1550 + const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1551 + if (item) { 1552 + const countSpan = item.querySelector('.tree-item-count'); 1553 + countSpan.textContent = 'error'; 1554 + } 1555 + }); 1556 + 1557 + // Add click handler to expand and show records 1558 + detail.querySelector('.tree-item[data-lexicon]').addEventListener('click', (e) => { 1559 + e.stopPropagation(); 1560 + const item = e.currentTarget; 1561 + const lexicon = item.dataset.lexicon; 1562 + const existingContent = item.querySelector('.collection-content'); 1563 + 1564 + if (existingContent) { 1565 + existingContent.remove(); 1566 + return; 1567 + } 1568 + 1569 + // Create container for content 1570 + const contentDiv = document.createElement('div'); 1571 + contentDiv.className = 'collection-content'; 1572 + contentDiv.innerHTML = ` 1573 + <div class="collection-view-content"> 1574 + <div class="collection-view records-view active"> 1575 + <div class="loading">loading records...</div> 1576 + </div> 1577 + </div> 1578 + `; 1579 + item.appendChild(contentDiv); 1580 + 1581 + const recordsView = contentDiv.querySelector('.records-view'); 1582 + 1583 + // Load records 1584 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 1585 + .then(r => r.json()) 1586 + .then(data => { 1587 + if (data.records && data.records.length > 0) { 1588 + let recordsHtml = ''; 1589 + data.records.forEach((record, idx) => { 1590 + const json = JSON.stringify(record.value, null, 2); 1591 + const recordId = `record-${Date.now()}-${idx}`; 1592 + recordsHtml += ` 1593 + <div class="record"> 1594 + <div class="record-header"> 1595 + <span class="record-label">record</span> 1596 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 1597 + </div> 1598 + <div class="record-content"> 1599 + <pre>${json}</pre> 1600 + </div> 1601 + </div> 1602 + `; 1603 + }); 1604 + recordsView.innerHTML = recordsHtml; 1605 + 1606 + // Add copy button handlers 1607 + recordsView.addEventListener('click', (e) => { 1608 + if (e.target.classList.contains('copy-btn')) { 1609 + e.stopPropagation(); 1610 + const copyBtn = e.target; 1611 + const content = decodeURIComponent(copyBtn.dataset.content); 1612 + 1613 + navigator.clipboard.writeText(content).then(() => { 1614 + const originalText = copyBtn.textContent; 1615 + copyBtn.textContent = 'copied!'; 1616 + copyBtn.classList.add('copied'); 1617 + setTimeout(() => { 1618 + copyBtn.textContent = originalText; 1619 + copyBtn.classList.remove('copied'); 1620 + }, 1500); 1621 + }).catch(err => { 1622 + console.error('Failed to copy:', err); 1623 + copyBtn.textContent = 'error'; 1624 + setTimeout(() => { 1625 + copyBtn.textContent = 'copy'; 1626 + }, 1500); 1627 + }); 1628 + } 1629 + }); 1630 + } else { 1631 + recordsView.innerHTML = '<div class="record">no records found</div>'; 1632 + } 1633 + }) 1634 + .catch(e => { 1635 + console.error('Error fetching records:', e); 1636 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 1637 + }); 1638 + }); 1523 1639 }); 1524 1640 1525 1641 // Reposition all existing apps to make room 1526 1642 repositionAppCircles(); 1643 + } 1644 + 1645 + // Function to remove an app circle from the UI 1646 + function removeAppCircle(namespace) { 1647 + console.log('[removeAppCircle] removing circle for namespace:', namespace); 1648 + 1649 + // Find and remove the DOM element 1650 + const appElement = document.querySelector(`.app-view [data-namespace="${namespace}"]`)?.closest('.app-view'); 1651 + if (appElement) { 1652 + appElement.remove(); 1653 + console.log('[removeAppCircle] removed DOM element'); 1654 + } 1655 + 1656 + // Remove from globalApps 1657 + if (globalApps && globalApps[namespace]) { 1658 + delete globalApps[namespace]; 1659 + console.log('[removeAppCircle] removed from globalApps'); 1660 + } 1661 + 1662 + // Reposition remaining circles 1663 + repositionAppCircles(); 1664 + console.log('[removeAppCircle] repositioned remaining circles'); 1527 1665 } 1528 1666 1529 1667 // Check auth status on page load