a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
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}