a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 291 lines 9.6 kB view raw
1import { apds } from 'apds' 2import { h } from 'h' 3import { markdown } from './markdown.js' 4import { send } from './send.js' 5 6const isHash = (value) => typeof value === 'string' && value.length === 44 7 8const isReplyYaml = (yaml) => { 9 if (!yaml) { return false } 10 return Boolean(yaml.reply || yaml.replyHash || yaml.replyto || yaml.replyTo) 11} 12 13const resolveImageSrc = async (value) => { 14 if (!value || typeof value !== 'string') { return null } 15 if (isHash(value)) { 16 const blob = await apds.get(value) 17 if (blob) { return blob } 18 await send(value) 19 return null 20 } 21 return value 22} 23 24const normalizeMessages = async (messages) => { 25 if (!Array.isArray(messages)) { return [] } 26 const parsed = await Promise.all(messages.map(async (msg) => { 27 if (!msg || typeof msg !== 'object') { return null } 28 const text = typeof msg.text === 'string' ? msg.text : '' 29 if (!text) { return null } 30 const yaml = await apds.parseYaml(text) 31 const ts = Number.parseInt(msg.ts || '0', 10) 32 return { 33 msg, 34 yaml: yaml || {}, 35 ts: Number.isNaN(ts) ? 0 : ts, 36 } 37 })) 38 return parsed.filter(Boolean) 39} 40 41const buildProfileData = async (messages, fallbackName) => { 42 const normalized = await normalizeMessages(messages) 43 const nonReplies = normalized.filter((entry) => !isReplyYaml(entry.yaml)) 44 const sorted = nonReplies.sort((a, b) => b.ts - a.ts) 45 46 let name = '' 47 let bio = '' 48 let background = null 49 let latestImage = null 50 51 if (sorted[0] && sorted[0].yaml && typeof sorted[0].yaml.image === 'string') { 52 latestImage = sorted[0].yaml.image.trim() 53 } 54 55 for (const entry of sorted) { 56 if (!name && typeof entry.yaml.name === 'string') { 57 name = entry.yaml.name.trim() 58 } 59 if (!bio && typeof entry.yaml.bio === 'string') { 60 bio = entry.yaml.bio.trim() 61 } 62 if (!background && typeof entry.yaml.background === 'string') { 63 background = entry.yaml.background.trim() 64 } 65 if (name && bio && background) { break } 66 } 67 68 return { 69 name: name || fallbackName, 70 bio, 71 background, 72 image: latestImage, 73 } 74} 75 76const publishProfileUpdate = async ({ bio, name, image }) => { 77 const meta = {} 78 if (bio !== null && bio !== undefined && bio !== '') { meta.bio = bio } 79 if (name !== null && name !== undefined && name !== '') { meta.name = name } 80 if (image) { meta.image = image } 81 const published = await apds.compose('', meta) 82 const signed = await apds.get(published) 83 const opened = await apds.open(signed) 84 const blob = await apds.get(opened.substring(13)) 85 await send(signed) 86 await send(blob) 87 if (image) { await send(image) } 88 return { signed, blob } 89} 90 91export const buildProfileHeader = async ({ label, messages, canEdit = false, pubkey = null }) => { 92 const profile = await buildProfileData(messages, label) 93 const backgroundImage = await resolveImageSrc(profile.background) 94 const fallbackVisual = pubkey ? await apds.visual(pubkey) : null 95 const fallbackVisualSrc = fallbackVisual && fallbackVisual.src ? fallbackVisual.src : null 96 let currentName = profile.name || label 97 let currentBio = profile.bio || '' 98 let currentImageHash = profile.image || null 99 100 if (canEdit && pubkey) { 101 const localLog = await apds.query(pubkey) 102 if (localLog && localLog.length) { 103 const localProfile = await buildProfileData(localLog, label) 104 if (localProfile.name) { currentName = localProfile.name } 105 if (localProfile.bio) { currentBio = localProfile.bio } 106 if (localProfile.image) { currentImageHash = localProfile.image } 107 } 108 const localName = await apds.get('name') 109 if (localName) { currentName = localName } 110 const localImage = await apds.get('image') 111 if (localImage) { currentImageHash = localImage } 112 } 113 114 const profileImage = await resolveImageSrc(currentImageHash) 115 let currentImageSrc = profileImage || fallbackVisualSrc || '' 116 let draftImageHash = currentImageHash 117 let draftImageSrc = currentImageSrc 118 let editAvatarImgRef = null 119 let viewAvatarImgRef = null 120 const header = h('div', { classList: 'message profile-header' }) 121 122 if (backgroundImage) { 123 header.style.backgroundImage = `url(${backgroundImage})` 124 } 125 126 let avatar 127 if (canEdit) { 128 const editAvatarImg = h('img', { 129 classList: 'profile-avatar profile-edit-only', 130 src: draftImageSrc, 131 alt: `${currentName || label} profile photo` 132 }) 133 editAvatarImgRef = editAvatarImg 134 const uploader = h('input', { type: 'file', accept: 'image/*', style: 'display: none;' }) 135 editAvatarImg.onclick = () => { uploader.click() } 136 137 draftImageSrc = editAvatarImg.src || '' 138 139 uploader.addEventListener('change', (e) => { 140 const file = e.target.files && e.target.files[0] 141 if (!file) { return } 142 const reader = new FileReader() 143 reader.onload = () => { 144 const canvas = document.createElement('canvas') 145 const ctx = canvas.getContext('2d') 146 const img = new Image() 147 img.onload = async () => { 148 const size = 256 149 const minSide = Math.min(img.width, img.height) 150 const sx = Math.floor((img.width - minSide) / 2) 151 const sy = Math.floor((img.height - minSide) / 2) 152 canvas.width = size 153 canvas.height = size 154 ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size) 155 const croppedImage = canvas.toDataURL() 156 editAvatarImg.src = croppedImage 157 draftImageSrc = croppedImage 158 draftImageHash = await apds.make(croppedImage) 159 } 160 img.src = reader.result 161 } 162 reader.readAsDataURL(file) 163 }) 164 165 const editAvatar = h('span', { classList: 'profile-edit-only' }, [ 166 editAvatarImg, 167 uploader 168 ]) 169 170 const viewAvatar = h('img', { 171 classList: 'post-image profile-avatar profile-view-only', 172 src: draftImageSrc, 173 alt: `${currentName || label} profile photo` 174 }) 175 viewAvatarImgRef = viewAvatar 176 177 avatar = h('div', { classList: 'profile-avatar-wrap' }, [ 178 viewAvatar, 179 editAvatar 180 ]) 181 } else { 182 avatar = h('img', { 183 classList: 'post-image profile-avatar', 184 src: draftImageSrc, 185 alt: `${currentName || label} profile photo` 186 }) 187 } 188 189 const nameText = h('h2', { classList: 'profile-view-only' }, [currentName || label]) 190 const nameEditor = h('input', { 191 classList: 'profile-edit-only', 192 placeholder: currentName || label 193 }) 194 nameEditor.value = currentName || '' 195 196 const bioPreview = h('div', { classList: 'profile-bio profile-view-only' }) 197 bioPreview.innerHTML = await markdown(currentBio || '') 198 199 const input = h('textarea', { 200 classList: 'profile-edit-only', 201 placeholder: currentBio ? 'Update your bio' : 'Write a short bio' 202 }) 203 input.value = currentBio 204 const status = h('div', { classList: 'profile-bio-status profile-edit-only' }) 205 const editButton = h('button', { type: 'button', classList: canEdit ? 'profile-view-only' : 'hidden' }, ['Edit profile']) 206 if (!canEdit) { editButton.style.display = 'none' } 207 const saveButton = h('button', { type: 'button', classList: 'profile-edit-only' }, ['Save profile']) 208 const cancelButton = h('button', { type: 'button', classList: 'profile-edit-only' }, ['Cancel']) 209 210 const setEditing = (isEditing) => { 211 header.classList.toggle('profile-editing', isEditing) 212 status.textContent = '' 213 } 214 215 saveButton.onclick = async () => { 216 const value = input.value.trim() 217 const nameValue = nameEditor.value.trim() 218 const nameChanged = nameValue && nameValue !== currentName 219 const bioChanged = value && value !== currentBio 220 const imageChanged = draftImageHash && draftImageHash !== currentImageHash 221 if (!nameChanged && !bioChanged && !imageChanged) { 222 setEditing(false) 223 return 224 } 225 saveButton.disabled = true 226 status.textContent = 'Saving...' 227 try { 228 await publishProfileUpdate({ 229 bio: bioChanged ? value : null, 230 name: nameChanged ? nameValue : null, 231 image: imageChanged ? draftImageHash : null 232 }) 233 if (nameChanged) { await apds.put('name', nameValue) } 234 if (imageChanged) { await apds.put('image', draftImageHash) } 235 if (nameChanged) { currentName = nameValue } 236 if (bioChanged) { currentBio = value } 237 if (imageChanged) { 238 currentImageHash = draftImageHash 239 currentImageSrc = draftImageSrc 240 } 241 const rendered = await markdown(currentBio || '') 242 bioPreview.innerHTML = rendered 243 nameText.textContent = currentName || label 244 if (draftImageSrc && viewAvatarImgRef && viewAvatarImgRef.tagName === 'IMG') { 245 viewAvatarImgRef.src = draftImageSrc 246 } 247 setEditing(false) 248 } catch { 249 status.textContent = 'Failed to save profile.' 250 } finally { 251 saveButton.disabled = false 252 } 253 } 254 255 editButton.onclick = () => { 256 input.value = currentBio || '' 257 nameEditor.value = currentName || '' 258 if (draftImageSrc && editAvatarImgRef) { editAvatarImgRef.src = draftImageSrc } 259 setEditing(true) 260 } 261 262 cancelButton.onclick = () => { 263 input.value = currentBio || '' 264 nameEditor.value = currentName || '' 265 draftImageHash = currentImageHash 266 draftImageSrc = currentImageSrc 267 if (editAvatarImgRef) { editAvatarImgRef.src = draftImageSrc } 268 setEditing(false) 269 } 270 271 setEditing(false) 272 273 const content = h('div', { classList: 'profile-header-content' }, [ 274 nameText, 275 nameEditor, 276 bioPreview, 277 editButton, 278 input, 279 saveButton, 280 cancelButton, 281 status 282 ]) 283 284 const layout = h('div', { classList: 'profile-header-layout' }, [ 285 avatar, 286 content 287 ]) 288 289 header.appendChild(layout) 290 return header 291}