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

feat: add optional text messages to guestbook signatures

users can now leave an optional message when signing the guestbook. messages are displayed in handwritten font alongside the signature metadata.

- backend: added text field to visit records and signature struct
- frontend: added message input modal with 280 char limit
- docs: updated lexicon documentation with new field and credits to @thisismissem.social and @essentialrandom.bsky.social

inspired by https://github.com/FujoWebDev/lexicon-guestbook

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

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

Changed files
+121 -21
docs
src
templates
static
+11 -1
docs/lexicon.md
··· 22 22 { 23 23 "$type": "app.at-me.visit", 24 24 "timestamp": "2025-10-25T22:30:00Z", 25 - "createdAt": "2025-10-25T22:30:00Z" 25 + "createdAt": "2025-10-25T22:30:00Z", 26 + "text": "optional message from the visitor" 26 27 } 27 28 ``` 29 + 30 + **fields:** 31 + - `timestamp` (required): ISO 8601 timestamp of when the signature was created 32 + - `createdAt` (required): ISO 8601 timestamp of when the record was created (typically same as timestamp) 33 + - `text` (optional): a message left by the visitor, max 280 characters 28 34 29 35 ### privacy 30 36 ··· 39 45 - user data sovereignty (records live in user's PDS) 40 46 - transparency (users see exactly what's being written) 41 47 - opt-in participation (no tracking without explicit consent) 48 + 49 + ### acknowledgments 50 + 51 + thanks to [@thisismissem.social](https://bsky.app/profile/thisismissem.social) for putting [lexicon-guestbook](https://github.com/FujoWebDev/lexicon-guestbook) on our radar! [@essentialrandom.bsky.social](https://bsky.app/profile/essentialrandom.bsky.social)'s work on that project - a more fully-featured implementation with per-user guestbooks, moderation, and an appview - helped inform the addition of optional text messages to our simpler global guestbook.
+22 -3
src/routes.rs
··· 44 44 pub handle: Option<String>, 45 45 pub avatar: Option<String>, 46 46 pub timestamp: String, 47 + pub text: Option<String>, 47 48 } 48 49 49 50 // UFOs API response structure ··· 757 758 } 758 759 } 759 760 761 + #[derive(Deserialize)] 762 + pub struct SignGuestbookRequest { 763 + text: Option<String>, 764 + } 765 + 760 766 #[post("/api/sign-guestbook")] 761 - pub async fn sign_guestbook(session: Session) -> HttpResponse { 767 + pub async fn sign_guestbook( 768 + session: Session, 769 + body: web::Json<SignGuestbookRequest>, 770 + ) -> HttpResponse { 762 771 // Check if user is logged in 763 772 let did: Option<String> = match session.get(constants::SESSION_KEY_DID) { 764 773 Ok(d) => d, ··· 788 797 } 789 798 }; 790 799 791 - // Create the visit record 792 - let record_json = serde_json::json!({ 800 + // Create the visit record with optional text 801 + let mut record_json = serde_json::json!({ 793 802 "$type": constants::GUESTBOOK_COLLECTION, 794 803 "timestamp": chrono::Utc::now().to_rfc3339(), 795 804 "createdAt": chrono::Utc::now().to_rfc3339(), 796 805 }); 806 + 807 + // Add text field if provided 808 + if let Some(text) = &body.text { 809 + if !text.trim().is_empty() { 810 + record_json["text"] = serde_json::Value::String(text.clone()); 811 + } 812 + } 797 813 798 814 // Convert to Unknown type 799 815 let record: atrium_api::types::Unknown = serde_json::from_value(record_json) ··· 831 847 handle, 832 848 avatar, 833 849 timestamp: chrono::Utc::now().to_rfc3339(), 850 + text: body.text.clone(), 834 851 }; 835 852 836 853 // Add at the beginning (most recent) ··· 1155 1172 .as_str() 1156 1173 .unwrap_or("") 1157 1174 .to_string(); 1175 + let text = record.record["text"].as_str().map(String::from); 1158 1176 async move { 1159 1177 let (handle, avatar) = fetch_profile_info(&did).await; 1160 1178 GuestbookSignature { ··· 1162 1180 handle, 1163 1181 avatar, 1164 1182 timestamp, 1183 + text, 1165 1184 } 1166 1185 } 1167 1186 })
+15
src/templates/app.html
··· 1385 1385 margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1386 1386 } 1387 1387 1388 + .guestbook-message { 1389 + font-family: 'Brush Script MT', cursive, 'Georgia', serif; 1390 + font-size: clamp(1rem, 2.3vmin, 1.25rem); 1391 + color: #3a2f25; 1392 + line-height: 1.6; 1393 + margin-bottom: clamp(0.5rem, 1.2vmin, 0.75rem); 1394 + font-style: italic; 1395 + } 1396 + 1397 + @media (prefers-color-scheme: dark) { 1398 + .guestbook-message { 1399 + color: #d4c5a8; 1400 + } 1401 + } 1402 + 1388 1403 .guestbook-paper-signature { 1389 1404 padding: clamp(1rem, 2.5vmin, 1.5rem) 0; 1390 1405 border-bottom: 1px solid rgba(212, 197, 168, 0.3);
+73 -17
static/app.js
··· 1906 1906 }); 1907 1907 } 1908 1908 1909 + function showMessageInputModal(onConfirm) { 1910 + const overlay = document.createElement('div'); 1911 + overlay.className = 'overlay'; 1912 + overlay.style.display = 'block'; 1913 + 1914 + const modal = document.createElement('div'); 1915 + modal.className = 'info-modal'; 1916 + modal.style.display = 'block'; 1917 + modal.style.maxWidth = '450px'; 1918 + 1919 + modal.innerHTML = ` 1920 + <h2>sign the guestbook</h2> 1921 + <p style="margin-bottom: 1rem; color: var(--text-light);">leave an optional message (or leave blank for just a signature)</p> 1922 + <textarea id="guestbookMessageInput" placeholder="share your thoughts..." style="width: 100%; min-height: 80px; padding: 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: inherit; font-size: 0.8rem; resize: vertical; margin-bottom: 1rem;" maxlength="280"></textarea> 1923 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 1924 + <button id="cancelMessageBtn" style="background: var(--bg);">cancel</button> 1925 + <button id="confirmMessageBtn" style="background: var(--surface-hover);">sign</button> 1926 + </div> 1927 + `; 1928 + 1929 + document.body.appendChild(overlay); 1930 + document.body.appendChild(modal); 1931 + 1932 + const textarea = document.getElementById('guestbookMessageInput'); 1933 + const cancelBtn = document.getElementById('cancelMessageBtn'); 1934 + const confirmBtn = document.getElementById('confirmMessageBtn'); 1935 + 1936 + const closeModal = () => { 1937 + modal.remove(); 1938 + overlay.remove(); 1939 + }; 1940 + 1941 + cancelBtn.addEventListener('click', closeModal); 1942 + overlay.addEventListener('click', closeModal); 1943 + 1944 + confirmBtn.addEventListener('click', () => { 1945 + const text = textarea.value.trim(); 1946 + closeModal(); 1947 + onConfirm(text); 1948 + }); 1949 + 1950 + // Focus the textarea 1951 + setTimeout(() => textarea.focus(), 100); 1952 + } 1953 + 1909 1954 function showUnsignModal() { 1910 1955 const overlay = document.createElement('div'); 1911 1956 overlay.className = 'overlay'; ··· 2013 2058 return; 2014 2059 } 2015 2060 2016 - // Authenticated and viewing own page - show watch prompt, then sign 2061 + // Authenticated and viewing own page - show message input, then watch prompt, then sign 2017 2062 if (viewingOwnPage) { 2018 - showWatchPrompt(async () => { 2019 - signGuestbookBtn.disabled = true; 2020 - const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2021 - const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2063 + showMessageInputModal(async (messageText) => { 2064 + showWatchPrompt(async () => { 2065 + signGuestbookBtn.disabled = true; 2066 + const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2067 + const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2022 2068 2023 - if (iconSpan && textSpan) { 2024 - const originalIcon = iconSpan.innerHTML; 2025 - const originalText = textSpan.textContent; 2026 - iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; 2027 - textSpan.textContent = 'signing...'; 2069 + if (iconSpan && textSpan) { 2070 + const originalIcon = iconSpan.innerHTML; 2071 + const originalText = textSpan.textContent; 2072 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; 2073 + textSpan.textContent = 'signing...'; 2028 2074 2029 - try { 2030 - const response = await fetch('/api/sign-guestbook', { 2031 - method: 'POST', 2032 - headers: { 2033 - 'Content-Type': 'application/json', 2075 + try { 2076 + const body = {}; 2077 + if (messageText && messageText.trim()) { 2078 + body.text = messageText.trim(); 2034 2079 } 2035 - }); 2080 + 2081 + const response = await fetch('/api/sign-guestbook', { 2082 + method: 'POST', 2083 + headers: { 2084 + 'Content-Type': 'application/json', 2085 + }, 2086 + body: JSON.stringify(body) 2087 + }); 2036 2088 2037 2089 const data = await response.json(); 2038 2090 ··· 2059 2111 }, 2000); 2060 2112 } 2061 2113 } 2062 - }); 2114 + }); 2115 + }); 2063 2116 } 2064 2117 }); 2065 2118 ··· 2161 2214 <span class="guestbook-did-tooltip">copied!</span> 2162 2215 </div> 2163 2216 <div class="guestbook-metadata"> 2217 + ${sig.text ? ` 2218 + <div class="guestbook-message">${sig.text}</div> 2219 + ` : ''} 2164 2220 <div class="guestbook-metadata-item"> 2165 2221 <span class="guestbook-metadata-label">handle:</span> 2166 2222 <span class="guestbook-metadata-value">@${handle}</span>