[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

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

Add list editing and deletion

Grace Kind (Jul 21, 2026, 7:30 PM -0500) c482c255 4a8a7c8b

+1830 -123
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.45", 3 + "version": "0.18.46", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+21 -3
src/css/style.css
··· 5809 5809 font-size: 24px; 5810 5810 } 5811 5811 5812 + #list-detail-view .pin-feed-button { 5813 + margin-right: 8px; 5814 + } 5815 + 5812 5816 #feed-detail-view .pin-feed-button.pinned, 5813 5817 #list-detail-view .pin-feed-button.pinned { 5814 5818 color: var(--highlight-color); ··· 9474 9478 } 9475 9479 9476 9480 .edit-profile-camera-button-avatar { 9477 - bottom: 6px; 9481 + bottom: 0; 9478 9482 right: 0; 9479 9483 } 9480 9484 ··· 9482 9486 position: absolute; 9483 9487 bottom: 0; 9484 9488 left: 12px; 9485 - width: 84px; 9486 - height: 84px; 9487 9489 cursor: pointer; 9488 9490 } 9489 9491 ··· 9556 9558 padding: 8px 12px; 9557 9559 border-radius: var(--card-border-radius); 9558 9560 background-color: light-dark(#fef2f2, #3b1111); 9561 + } 9562 + 9563 + .edit-list-details-dialog .edit-list-details-images-section { 9564 + padding: 8px 0; 9565 + display: flex; 9566 + justify-content: flex-start; 9567 + } 9568 + 9569 + .edit-list-details-dialog .edit-profile-avatar-wrapper { 9570 + position: relative; 9571 + bottom: auto; 9572 + left: auto; 9573 + } 9574 + 9575 + .edit-list-details-dialog .edit-profile-avatar-preview { 9576 + border-radius: var(--list-avatar-border-radius); 9559 9577 } 9560 9578 9561 9579 /* Image Cropper */
+28
src/js/api.js
··· 1390 1390 return res.data; 1391 1391 } 1392 1392 1393 + async getListRecord(rkey) { 1394 + const res = await this.request("com.atproto.repo.getRecord", { 1395 + query: { 1396 + repo: this.session.did, 1397 + collection: "app.bsky.graph.list", 1398 + rkey, 1399 + }, 1400 + }); 1401 + return res.data; 1402 + } 1403 + 1404 + async putListRecord(rkey, record, swapRecord) { 1405 + const res = await this.request("com.atproto.repo.putRecord", { 1406 + method: "POST", 1407 + body: { 1408 + repo: this.session.did, 1409 + collection: "app.bsky.graph.list", 1410 + rkey, 1411 + record: { 1412 + $type: "app.bsky.graph.list", 1413 + ...record, 1414 + }, 1415 + swapRecord: swapRecord ?? null, 1416 + }, 1417 + }); 1418 + return res.data; 1419 + } 1420 + 1393 1421 async createModerationReport({ reasonType, reason, subject, labelerDid }) { 1394 1422 const body = { 1395 1423 reasonType,
+2
src/js/components/account-switcher-dialog.js
··· 47 47 this._disposeEffect?.(); 48 48 this._disposeEffect = null; 49 49 window.removeEventListener("pageshow", this._onPageShow); 50 + this.scrollLock?.release(); 51 + this.scrollLock = null; 50 52 } 51 53 52 54 async _load() {
+447
src/js/components/edit-list-details-dialog.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 + import { 5 + closeWithAnimation, 6 + enableDragToDismiss, 7 + resetScrollOnBlur, 8 + } from "/js/dialogHelpers.js"; 9 + import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; 10 + import { ImageCompressor } from "/js/imageCompressor.js"; 11 + import "/js/components/image-cropper.js"; 12 + import "/js/components/context-menu.js"; 13 + import "/js/components/context-menu-item.js"; 14 + import "/js/components/context-menu-item-group.js"; 15 + import { cameraIconTemplate } from "/js/templates/icons/cameraIcon.template.js"; 16 + import { confirmModal } from "/js/modals/confirm.modal.js"; 17 + 18 + const MAX_NAME_LENGTH = 64; 19 + const MAX_DESCRIPTION_LENGTH = 300; 20 + 21 + class EditListDetailsDialog extends Component { 22 + connectedCallback() { 23 + if (this.initialized) { 24 + return; 25 + } 26 + this.setAttribute("data-dialog-wrapper", ""); 27 + this.scrollLock = null; 28 + this._name = ""; 29 + this._description = ""; 30 + this._currentAvatar = null; 31 + this._newAvatarDataUrl = null; 32 + this._removeAvatar = false; 33 + this._saving = false; 34 + this._error = null; 35 + this._croppingImageSrc = null; 36 + this._isOpen = false; 37 + this._list = null; 38 + this.innerHTML = ""; 39 + this.render(); 40 + this.initialized = true; 41 + } 42 + 43 + setList(list) { 44 + this._list = list; 45 + this._name = list.name || ""; 46 + this._description = list.description || ""; 47 + this._currentAvatar = list.avatar || null; 48 + this._newAvatarDataUrl = null; 49 + this._removeAvatar = false; 50 + this._saving = false; 51 + this._error = null; 52 + this._croppingImageSrc = null; 53 + this.render(); 54 + } 55 + 56 + get _isDirty() { 57 + if (!this._list) return false; 58 + return ( 59 + this._name !== (this._list.name || "") || 60 + this._description !== (this._list.description || "") || 61 + this._newAvatarDataUrl !== null || 62 + this._removeAvatar 63 + ); 64 + } 65 + 66 + get _isNameTooLong() { 67 + return graphemeCount(this._name) > MAX_NAME_LENGTH; 68 + } 69 + 70 + get _isNameEmpty() { 71 + return this._name.trim().length === 0; 72 + } 73 + 74 + get _isDescriptionTooLong() { 75 + return graphemeCount(this._description) > MAX_DESCRIPTION_LENGTH; 76 + } 77 + 78 + get _canSave() { 79 + return ( 80 + this._isDirty && 81 + !this._saving && 82 + !this._isNameEmpty && 83 + !this._isNameTooLong && 84 + !this._isDescriptionTooLong 85 + ); 86 + } 87 + 88 + render() { 89 + const isCropping = !!this._croppingImageSrc; 90 + 91 + const nameCount = graphemeCount(this._name); 92 + const descriptionCount = graphemeCount(this._description); 93 + const avatarSrc = this._removeAvatar 94 + ? null 95 + : this._newAvatarDataUrl || this._currentAvatar; 96 + 97 + render( 98 + html`<dialog 99 + class="bottom-sheet bottom-sheet-fullscreen no-handle edit-profile-dialog edit-list-details-dialog" 100 + @click=${async (event) => { 101 + if (!isCropping && event.target.tagName === "DIALOG") { 102 + if (await this.confirmClose()) { 103 + this.close(); 104 + } 105 + } 106 + }} 107 + @cancel=${async (event) => { 108 + event.preventDefault(); 109 + if (isCropping) { 110 + this._croppingImageSrc = null; 111 + this.render(); 112 + } else if (await this.confirmClose()) { 113 + this.close(); 114 + } 115 + }} 116 + @close=${() => { 117 + this.scrollLock?.release(); 118 + this.scrollLock = null; 119 + this.dispatchEvent(new CustomEvent("edit-list-details-closed")); 120 + }} 121 + > 122 + ${isCropping 123 + ? html`<div 124 + class="edit-profile-dialog-content edit-profile-cropper-content sheet-scroll-region" 125 + > 126 + <div class="edit-profile-dialog-header"> 127 + <button 128 + class="edit-profile-dialog-header-button" 129 + data-testid="edit-list-details-crop-cancel-button" 130 + @click=${() => { 131 + this._croppingImageSrc = null; 132 + this.render(); 133 + }} 134 + > 135 + Cancel 136 + </button> 137 + <h2>Edit image</h2> 138 + <button 139 + class="edit-profile-dialog-header-button edit-profile-dialog-save-button" 140 + data-testid="edit-list-details-crop-apply-button" 141 + @click=${() => this._applyCrop()} 142 + > 143 + Apply 144 + </button> 145 + </div> 146 + <div class="edit-profile-cropper-container"> 147 + <image-cropper 148 + src="${this._croppingImageSrc}" 149 + aspect-ratio="1" 150 + shape="rounded-square" 151 + ></image-cropper> 152 + </div> 153 + </div>` 154 + : html`<div class="edit-profile-dialog-content sheet-scroll-region"> 155 + <div class="edit-profile-dialog-header"> 156 + <button 157 + class="edit-profile-dialog-header-button" 158 + data-testid="edit-list-details-cancel-button" 159 + @click=${async () => { 160 + if (await this.confirmClose()) { 161 + this.close(); 162 + } 163 + }} 164 + .disabled=${this._saving} 165 + > 166 + Cancel 167 + </button> 168 + <h2>Edit list details</h2> 169 + <button 170 + class=${classnames( 171 + "edit-profile-dialog-header-button edit-profile-dialog-save-button", 172 + { saving: this._saving }, 173 + )} 174 + @click=${() => this._save()} 175 + .disabled=${!this._canSave} 176 + data-testid="edit-list-details-save-button" 177 + > 178 + <span>Save</span> 179 + ${this._saving 180 + ? html`<div class="loading-spinner"></div>` 181 + : ""} 182 + </button> 183 + </div> 184 + 185 + <div class="edit-profile-dialog-body"> 186 + <div 187 + class="edit-profile-images-section edit-list-details-images-section" 188 + > 189 + <div 190 + class="edit-profile-avatar-wrapper" 191 + @click=${() => this._openAvatarMenu()} 192 + > 193 + <div class="edit-profile-avatar-preview"> 194 + ${avatarSrc 195 + ? html`<img src="${avatarSrc}" alt="Avatar preview" />` 196 + : html`<img 197 + class="edit-profile-avatar-placeholder" 198 + src="/img/list-avatar-fallback.svg" 199 + alt="" 200 + />`} 201 + <div class="edit-profile-image-overlay"></div> 202 + </div> 203 + <div 204 + class="edit-profile-camera-button edit-profile-camera-button-avatar" 205 + > 206 + ${cameraIconTemplate()} 207 + </div> 208 + </div> 209 + </div> 210 + 211 + <context-menu class="edit-list-details-avatar-menu"> 212 + <context-menu-item-group> 213 + <context-menu-item 214 + data-testid="menu-action-list-avatar-upload" 215 + @click=${() => this._pickImage()} 216 + > 217 + Upload from Files 218 + </context-menu-item> 219 + </context-menu-item-group> 220 + ${avatarSrc 221 + ? html`<context-menu-item-group> 222 + <context-menu-item 223 + data-testid="menu-action-list-avatar-remove" 224 + @click=${() => { 225 + this._newAvatarDataUrl = null; 226 + this._removeAvatar = true; 227 + this.render(); 228 + }} 229 + > 230 + Remove Avatar 231 + </context-menu-item> 232 + </context-menu-item-group>` 233 + : ""} 234 + </context-menu> 235 + 236 + <div class="edit-profile-field"> 237 + <label for="edit-list-details-name">List Name</label> 238 + <input 239 + id="edit-list-details-name" 240 + type="text" 241 + class="edit-profile-input" 242 + .value=${this._name} 243 + @input=${(event) => { 244 + this._name = event.target.value; 245 + this.render(); 246 + }} 247 + data-testid="edit-list-details-name" 248 + /> 249 + <div 250 + class=${classnames("edit-profile-char-count", { 251 + overflow: this._isNameTooLong, 252 + })} 253 + > 254 + ${nameCount}/${MAX_NAME_LENGTH} 255 + </div> 256 + </div> 257 + 258 + <div class="edit-profile-field"> 259 + <label for="edit-list-details-description">Description</label> 260 + <textarea 261 + id="edit-list-details-description" 262 + class="edit-profile-textarea" 263 + .value=${this._description} 264 + @input=${(event) => { 265 + this._description = event.target.value; 266 + this.render(); 267 + }} 268 + rows="4" 269 + data-testid="edit-list-details-description" 270 + ></textarea> 271 + <div 272 + class=${classnames("edit-profile-char-count", { 273 + overflow: this._isDescriptionTooLong, 274 + })} 275 + > 276 + ${descriptionCount}/${MAX_DESCRIPTION_LENGTH} 277 + </div> 278 + </div> 279 + 280 + ${this._error 281 + ? html`<div class="edit-profile-error">${this._error}</div>` 282 + : ""} 283 + </div> 284 + </div>`} 285 + 286 + <input 287 + type="file" 288 + accept="image/*" 289 + style="display: none;" 290 + class="edit-list-details-file-input" 291 + @change=${(event) => this._handleFileSelect(event)} 292 + @cancel=${(event) => { 293 + event.stopPropagation(); 294 + }} 295 + /> 296 + </dialog>`, 297 + this, 298 + ); 299 + 300 + if (this._isOpen) { 301 + const dialog = this.querySelector(".edit-list-details-dialog"); 302 + if (dialog && !dialog.open) { 303 + dialog.showModal(); 304 + } 305 + } 306 + } 307 + 308 + _openAvatarMenu() { 309 + const menu = this.querySelector(".edit-list-details-avatar-menu"); 310 + const cameraButton = this.querySelector( 311 + ".edit-profile-camera-button-avatar", 312 + ); 313 + if (menu && cameraButton) { 314 + const rect = cameraButton.getBoundingClientRect(); 315 + const x = rect.left + rect.width / 2; 316 + const y = rect.bottom; 317 + menu.open(x, y); 318 + } 319 + } 320 + 321 + _pickImage() { 322 + const input = this.querySelector(".edit-list-details-file-input"); 323 + if (input) { 324 + input.click(); 325 + } 326 + } 327 + 328 + async _handleFileSelect(event) { 329 + const file = event.target.files?.[0]; 330 + if (!file || !file.type.startsWith("image/")) { 331 + event.target.value = ""; 332 + return; 333 + } 334 + 335 + const dataUrl = await readFileAsDataUrl(file); 336 + event.target.value = ""; 337 + 338 + this._croppingImageSrc = dataUrl; 339 + this.render(); 340 + } 341 + 342 + async _applyCrop() { 343 + const cropper = this.querySelector("image-cropper"); 344 + if (!cropper) return; 345 + 346 + const croppedDataUrl = cropper.cropImage(); 347 + if (!croppedDataUrl) return; 348 + 349 + this._newAvatarDataUrl = croppedDataUrl; 350 + this._removeAvatar = false; 351 + this._croppingImageSrc = null; 352 + this.render(); 353 + } 354 + 355 + async _save() { 356 + this._saving = true; 357 + this._error = null; 358 + this.render(); 359 + 360 + try { 361 + let avatarBlob = null; 362 + if (this._newAvatarDataUrl) { 363 + const compressed = await new ImageCompressor().compressImage( 364 + this._newAvatarDataUrl, 365 + ); 366 + avatarBlob = compressed.blob; 367 + } 368 + 369 + const successCallback = () => { 370 + this.close(); 371 + }; 372 + const errorCallback = (error) => { 373 + console.error("Failed to update list:", error); 374 + this._error = "Failed to save list. Please try again."; 375 + this._saving = false; 376 + this.render(); 377 + }; 378 + 379 + this.dispatchEvent( 380 + new CustomEvent("list-save", { 381 + detail: { 382 + listUpdates: { 383 + name: this._name, 384 + description: this._description, 385 + avatarBlob, 386 + removeAvatar: this._removeAvatar, 387 + }, 388 + successCallback, 389 + errorCallback, 390 + }, 391 + }), 392 + ); 393 + } catch (error) { 394 + console.error("Error saving list:", error); 395 + this._error = "Failed to save list. Please try again."; 396 + this._saving = false; 397 + this.render(); 398 + } 399 + } 400 + 401 + open() { 402 + this._isOpen = true; 403 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 404 + const dialog = this.querySelector(".edit-list-details-dialog"); 405 + if (dialog?.open) return; 406 + if (dialog) { 407 + dialog.showModal(); 408 + enableDragToDismiss(dialog, { 409 + confirmDismiss: () => this.confirmClose(), 410 + onClose: () => this.close(), 411 + scrollContainer: this.querySelector(".edit-profile-dialog-content"), 412 + ignoreTouchTarget: (el) => 413 + !!el.closest("button") || 414 + el.tagName === "INPUT" || 415 + el.tagName === "TEXTAREA" || 416 + !!el.closest("image-cropper"), 417 + disableWhenKeyboardOpen: true, 418 + }); 419 + 420 + resetScrollOnBlur( 421 + dialog, 422 + this.querySelector(".edit-profile-dialog-content"), 423 + ); 424 + } 425 + } 426 + 427 + async confirmClose() { 428 + if (!this._isDirty || !!this._croppingImageSrc || this._saving) return true; 429 + return confirmModal("Are you sure you want to discard your changes?", { 430 + title: "Discard changes?", 431 + confirmButtonStyle: "danger", 432 + confirmButtonText: "Discard", 433 + }); 434 + } 435 + 436 + close() { 437 + this._isOpen = false; 438 + return closeWithAnimation(this.querySelector(".edit-list-details-dialog")); 439 + } 440 + 441 + disconnectedCallback() { 442 + this.scrollLock?.release(); 443 + this.scrollLock = null; 444 + } 445 + } 446 + 447 + EditListDetailsDialog.register();
+8 -1
src/js/components/edit-profile-dialog.js
··· 159 159 <image-cropper 160 160 src="${this._croppingImageSrc}" 161 161 aspect-ratio="${this._croppingTarget === "avatar" ? 1 : 3}" 162 - ?circular=${this._croppingTarget === "avatar"} 162 + shape="${this._croppingTarget === "avatar" 163 + ? "circle" 164 + : "square"}" 163 165 ></image-cropper> 164 166 </div> 165 167 </div>` ··· 515 517 close() { 516 518 this._isOpen = false; 517 519 return closeWithAnimation(this.querySelector(".edit-profile-dialog")); 520 + } 521 + 522 + disconnectedCallback() { 523 + this.scrollLock?.release(); 524 + this.scrollLock = null; 518 525 } 519 526 } 520 527
+5
src/js/components/image-alt-text-dialog.js
··· 159 159 ); 160 160 this.close(); 161 161 } 162 + 163 + disconnectedCallback() { 164 + this.scrollLock?.release(); 165 + this.scrollLock = null; 166 + } 162 167 } 163 168 164 169 ImageAltTextDialog.register();
+37 -29
src/js/components/image-cropper.js
··· 3 3 const MIN_SCALE = 1; 4 4 const MAX_SCALE = 5; 5 5 6 + const SHAPES = new Set(["circle", "square", "rounded-square"]); 7 + const ROUNDED_SQUARE_RADIUS = 8; 8 + 6 9 // Claude wrote this 7 10 class ImageCropper extends Component { 8 11 connectedCallback() { ··· 48 51 } 49 52 50 53 static get observedAttributes() { 51 - return ["src", "aspect-ratio", "circular"]; 54 + return ["src", "aspect-ratio", "shape"]; 52 55 } 53 56 54 57 attributeChangedCallback(name, oldValue, newValue) { 55 58 if (!this.initialized) return; 56 59 if (name === "src" && newValue !== oldValue) { 57 60 this.loadImage(newValue); 58 - } else if (name === "aspect-ratio" || name === "circular") { 61 + } else if (name === "aspect-ratio" || name === "shape") { 59 62 this._draw(); 60 63 } 61 64 } ··· 64 67 return parseFloat(this.getAttribute("aspect-ratio")) || 1; 65 68 } 66 69 67 - get circular() { 68 - return this.hasAttribute("circular"); 70 + get shape() { 71 + const attr = this.getAttribute("shape"); 72 + return SHAPES.has(attr) ? attr : "square"; 69 73 } 70 74 71 75 async loadImage(src) { ··· 178 182 // Darken area outside the crop zone 179 183 ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; 180 184 181 - if (this.circular) { 182 - // Draw darkened overlay with circular cutout 183 - ctx.beginPath(); 184 - ctx.rect(0, 0, displayWidth, displayHeight); 185 - const radius = cropWidth / 2; 186 - const cx = cropX + cropWidth / 2; 187 - const cy = cropY + cropHeight / 2; 188 - ctx.arc(cx, cy, radius, 0, Math.PI * 2, true); 189 - ctx.fill("evenodd"); 185 + const shape = this.shape; 186 + ctx.beginPath(); 187 + ctx.rect(0, 0, displayWidth, displayHeight); 188 + this._traceCropPath(ctx, cropX, cropY, cropWidth, cropHeight, shape); 189 + ctx.fill("evenodd"); 190 190 191 - // Draw circle border 192 - ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; 193 - ctx.lineWidth = 2; 194 - ctx.beginPath(); 195 - ctx.arc(cx, cy, radius, 0, Math.PI * 2); 196 - ctx.stroke(); 197 - } else { 198 - // Draw darkened overlay with rectangular cutout 199 - ctx.beginPath(); 200 - ctx.rect(0, 0, displayWidth, displayHeight); 201 - ctx.rect(cropX, cropY, cropWidth, cropHeight); 202 - ctx.fill("evenodd"); 191 + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; 192 + ctx.lineWidth = 2; 193 + ctx.beginPath(); 194 + this._traceCropPath(ctx, cropX, cropY, cropWidth, cropHeight, shape); 195 + ctx.stroke(); 196 + } 203 197 204 - // Draw rect border 205 - ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; 206 - ctx.lineWidth = 2; 207 - ctx.strokeRect(cropX, cropY, cropWidth, cropHeight); 198 + _traceCropPath(ctx, x, y, width, height, shape) { 199 + if (shape === "circle") { 200 + const radius = width / 2; 201 + ctx.arc(x + width / 2, y + height / 2, radius, 0, Math.PI * 2); 202 + } else if (shape === "rounded-square") { 203 + const r = Math.min(ROUNDED_SQUARE_RADIUS, width / 2, height / 2); 204 + ctx.moveTo(x + r, y); 205 + ctx.lineTo(x + width - r, y); 206 + ctx.arcTo(x + width, y, x + width, y + r, r); 207 + ctx.lineTo(x + width, y + height - r); 208 + ctx.arcTo(x + width, y + height, x + width - r, y + height, r); 209 + ctx.lineTo(x + r, y + height); 210 + ctx.arcTo(x, y + height, x, y + height - r, r); 211 + ctx.lineTo(x, y + r); 212 + ctx.arcTo(x, y, x + r, y, r); 213 + ctx.closePath(); 214 + } else { 215 + ctx.rect(x, y, width, height); 208 216 } 209 217 } 210 218
+10 -17
src/js/components/plugin-blob-image.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { Signal, ReactiveStore, effect } from "/js/signals.js"; 4 - import { BSKY_CDN_URL } from "/js/config.js"; 4 + import { buildCdnUrl } from "/js/dataHelpers.js"; 5 5 6 6 const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; 7 7 const CID_PATTERN = /^b[a-z2-7]{20,}$/; 8 8 9 - const CDN_PREFIXES = new Set([ 10 - "avatar", 11 - "avatar_thumbnail", 12 - "banner", 13 - "feed_thumbnail", 14 - "feed_fullsize", 15 - ]); 9 + function safeBuildCdnUrl(prefix, did, cid) { 10 + try { 11 + return buildCdnUrl(prefix, did, cid); 12 + } catch { 13 + return null; 14 + } 15 + } 16 16 17 17 function isValidDid(did) { 18 18 return typeof did === "string" && DID_PATTERN.test(did); ··· 20 20 21 21 function isValidCid(cid) { 22 22 return typeof cid === "string" && CID_PATTERN.test(cid); 23 - } 24 - 25 - function buildCdnUrl(prefix, did, cid) { 26 - return `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`; 27 23 } 28 24 29 25 class PluginBlobImage extends Component { ··· 48 44 const alt = this.state.$alt.get() ?? ""; 49 45 const failed = this.state.$failed.get(); 50 46 const src = 51 - !failed && 52 - isValidDid(did) && 53 - isValidCid(cid) && 54 - CDN_PREFIXES.has(cdnPrefix) 55 - ? buildCdnUrl(cdnPrefix, did, cid) 47 + !failed && isValidDid(did) && isValidCid(cid) 48 + ? safeBuildCdnUrl(cdnPrefix, did, cid) 56 49 : null; 57 50 if (src) { 58 51 render(
+5
src/js/components/post-notifications-dialog.js
··· 165 165 close() { 166 166 return closeWithAnimation(this.querySelector(".post-notifications-dialog")); 167 167 } 168 + 169 + disconnectedCallback() { 170 + this.scrollLock?.release(); 171 + this.scrollLock = null; 172 + } 168 173 } 169 174 170 175 PostNotificationsDialog.register();
+5
src/js/components/report-dialog.js
··· 831 831 close() { 832 832 return closeWithAnimation(this.querySelector(".report-dialog")); 833 833 } 834 + 835 + disconnectedCallback() { 836 + this.scrollLock?.release(); 837 + this.scrollLock = null; 838 + } 834 839 } 835 840 836 841 ReportDialog.register();
+20 -1
src/js/dataHelpers.js
··· 1 1 import { unique } from "/js/utils.js"; 2 - import { FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS } from "/js/config.js"; 2 + import { 3 + BSKY_CDN_URL, 4 + FOLLOWING_FEED_URI, 5 + IN_APP_LINK_DOMAINS, 6 + } from "/js/config.js"; 3 7 4 8 export const INVALID_HANDLE = "handle.invalid"; 5 9 export const MISSING_HANDLE = "missing.invalid"; ··· 14 18 profile.handle !== INVALID_HANDLE && 15 19 profile.handle !== MISSING_HANDLE 16 20 ); 21 + } 22 + 23 + const CDN_PREFIXES = new Set([ 24 + "avatar", 25 + "avatar_thumbnail", 26 + "banner", 27 + "feed_thumbnail", 28 + "feed_fullsize", 29 + ]); 30 + 31 + export function buildCdnUrl(prefix, did, cid) { 32 + if (!CDN_PREFIXES.has(prefix)) { 33 + throw new Error(`Invalid CDN prefix: ${prefix}`); 34 + } 35 + return `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`; 17 36 } 18 37 19 38 export function avatarThumbnailUrl(avatarUrl) {
+158 -12
src/js/dataLayer/mutations.js
··· 5 5 pinPostInFeed, 6 6 unpinPostInFeed, 7 7 valueForPinnedItem, 8 + buildCdnUrl, 8 9 } from "/js/dataHelpers.js"; 9 - import { getCurrentTimestamp } from "/js/utils.js"; 10 + import { batch, getCurrentTimestamp } from "/js/utils.js"; 10 11 import { PostCreator } from "/js/postCreator.js"; 11 12 import { untrack } from "/js/signals.js"; 12 13 ··· 872 873 } 873 874 if (description !== undefined) { 874 875 updatedRecord.description = description; 876 + delete updatedRecord.descriptionFacets; 875 877 } 876 878 if (avatarRef) { 877 879 updatedRecord.avatar = avatarRef; ··· 886 888 887 889 await this.api.putProfileRecord(updatedRecord, swapCid); 888 890 891 + // Update in memory 892 + const patch = { displayName, description }; 893 + if (avatarRef) { 894 + patch.avatar = buildCdnUrl("avatar", profile.did, avatarRef.ref.$link); 895 + } else if (removeAvatar) { 896 + patch.avatar = ""; 897 + } 898 + if (bannerRef) { 899 + patch.banner = buildCdnUrl("banner", profile.did, bannerRef.ref.$link); 900 + } else if (removeBanner) { 901 + patch.banner = ""; 902 + } 903 + 904 + const existingProfile = this.dataStore.$profiles.get(profile.did); 905 + if (existingProfile) { 906 + this.dataStore.$profiles.set(profile.did, { 907 + ...existingProfile, 908 + ...patch, 909 + }); 910 + } 911 + const existingDetailed = this.dataStore.$detailedProfiles.get(profile.did); 912 + if (existingDetailed) { 913 + this.dataStore.$detailedProfiles.set(profile.did, { 914 + ...existingDetailed, 915 + ...patch, 916 + }); 917 + } 918 + const currentUser = this.dataStore.$currentUser.get(); 919 + if (currentUser && currentUser.did === profile.did) { 920 + this.dataStore.$currentUser.set({ ...currentUser, ...patch }); 921 + } 922 + } 923 + 924 + async updateList(list, { name, description, avatarBlob, removeAvatar }) { 925 + const rkey = list.uri.split("/").pop(); 926 + const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; 927 + 928 + const recordData = await this.api.getListRecord(rkey); 929 + const existingRecord = recordData.value || {}; 930 + const swapCid = recordData.cid; 931 + 932 + const updatedRecord = { ...existingRecord }; 933 + if (name !== undefined) { 934 + updatedRecord.name = name; 935 + } 936 + if (description !== undefined) { 937 + updatedRecord.description = description; 938 + delete updatedRecord.descriptionFacets; 939 + } 940 + if (avatarRef) { 941 + updatedRecord.avatar = avatarRef; 942 + } else if (removeAvatar) { 943 + delete updatedRecord.avatar; 944 + } 945 + 946 + await this.api.putListRecord(rkey, updatedRecord, swapCid); 947 + 948 + // Update in memory 949 + const current = this.dataStore.$lists.get(list.uri) ?? list; 950 + const patched = { ...current }; 951 + if (name !== undefined) patched.name = name; 952 + if (description !== undefined) { 953 + patched.description = description; 954 + patched.descriptionFacets = []; 955 + } 956 + if (avatarRef?.ref?.$link && list.creator?.did) { 957 + patched.avatar = buildCdnUrl( 958 + "avatar", 959 + list.creator.did, 960 + avatarRef.ref.$link, 961 + ); 962 + } else if (removeAvatar) { 963 + patched.avatar = ""; 964 + } 965 + this.dataStore.$lists.set(list.uri, patched); 966 + } 967 + 968 + async deleteList(list) { 969 + const { rkey } = parseUri(list.uri); 970 + const listItemUris = []; 971 + let cursor = ""; 972 + const MAX_PAGES = 100; 973 + let hitCap = true; 974 + for (let i = 0; i < MAX_PAGES; i++) { 975 + const res = await this.api.getListItems({ cursor, limit: 100 }); 976 + for (const record of res.records) { 977 + if (record.value?.list === list.uri) { 978 + listItemUris.push(record.uri); 979 + } 980 + } 981 + cursor = res.cursor; 982 + if (!cursor) { 983 + hitCap = false; 984 + break; 985 + } 986 + } 987 + if (hitCap) { 988 + console.warn( 989 + `deleteList: stopped scanning listitems after ${MAX_PAGES} pages`, 990 + ); 991 + } 992 + const writes = [ 993 + ...listItemUris.map((uri) => ({ 994 + $type: "com.atproto.repo.applyWrites#delete", 995 + collection: "app.bsky.graph.listitem", 996 + rkey: parseUri(uri).rkey, 997 + })), 998 + { 999 + $type: "com.atproto.repo.applyWrites#delete", 1000 + collection: "app.bsky.graph.list", 1001 + rkey, 1002 + }, 1003 + ]; 1004 + for (const chunk of batch(writes, 10)) { 1005 + await this.api.applyWrites(chunk); 1006 + } 1007 + this.dataStore.$lists.set(list.uri, null); 1008 + this.dataStore.$listMembers.set(list.uri, null); 1009 + if (list.creator?.did) { 1010 + const actorLists = this.dataStore.$actorLists.get(list.creator.did); 1011 + if (actorLists) { 1012 + this.dataStore.$actorLists.set(list.creator.did, { 1013 + ...actorLists, 1014 + lists: actorLists.lists.filter((entry) => entry.uri !== list.uri), 1015 + }); 1016 + } 1017 + } 1018 + for (const [ 1019 + actorDid, 1020 + entry, 1021 + ] of this.dataStore.$listsWithMembershipByActor.entries()) { 1022 + if (!entry?.listsWithMembership) continue; 1023 + const filtered = entry.listsWithMembership.filter( 1024 + (item) => item.list.uri !== list.uri, 1025 + ); 1026 + if (filtered.length !== entry.listsWithMembership.length) { 1027 + this.dataStore.$listsWithMembershipByActor.set(actorDid, { 1028 + ...entry, 1029 + listsWithMembership: filtered, 1030 + }); 1031 + } 1032 + } 1033 + const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); 1034 + if (pinnedItems?.some((item) => item.data?.uri === list.uri)) { 1035 + this.dataStore.$pinnedItems.set( 1036 + pinnedItems.filter((item) => item.data?.uri !== list.uri), 1037 + ); 1038 + } 889 1039 const preferences = this.preferencesProvider.requirePreferences(); 890 - const labelers = preferences.getLabelerDids(); 891 - // Fetch full profile to get updated image urls 892 - const updatedProfile = await this.api.getProfile(profile.did, { labelers }); 893 - this.dataStore.$profiles.set(updatedProfile.did, updatedProfile); 894 - this.dataStore.$detailedProfiles.set(updatedProfile.did, updatedProfile); 895 - const currentUser = this.dataStore.$currentUser.get(); 896 - if (currentUser && currentUser.did === updatedProfile.did) { 897 - this.dataStore.$currentUser.set({ 898 - ...currentUser, 899 - ...updatedProfile, 900 - }); 1040 + if (preferences.isFeedPinned(list.uri)) { 1041 + const newPreferences = preferences.unpinFeed(list.uri); 1042 + try { 1043 + await this.preferencesProvider.updatePreferences(newPreferences); 1044 + } catch (error) { 1045 + console.error(error); 1046 + } 901 1047 } 902 1048 } 903 1049
+22
src/js/listInteractionHandler.js
··· 86 86 showToast("Failed to unblock list", { style: "error" }); 87 87 } 88 88 } 89 + 90 + async handleDeleteList(list) { 91 + const confirmed = await confirmModal( 92 + "This list will be permanently deleted. This action cannot be undone.", 93 + { 94 + title: "Delete this list?", 95 + confirmButtonText: "Delete", 96 + confirmButtonStyle: "danger", 97 + }, 98 + ); 99 + if (!confirmed) return false; 100 + try { 101 + hapticsImpactMedium(); 102 + await this.dataLayer.mutations.deleteList(list); 103 + showToast("List deleted"); 104 + return true; 105 + } catch (error) { 106 + console.error(error); 107 + showToast("Failed to delete list", { style: "error" }); 108 + return false; 109 + } 110 + } 89 111 }
+46
src/js/views/listDetail.view.js
··· 16 16 import "/js/components/infinite-scroll-container.js"; 17 17 import "/js/components/context-menu.js"; 18 18 import "/js/components/context-menu-item.js"; 19 + import "/js/components/edit-list-details-dialog.js"; 19 20 20 21 class ListDetailView extends View { 21 22 async render({ ··· 171 172 > 172 173 Copy link to list 173 174 </context-menu-item> 175 + ${listCreator?.did && 176 + currentUser?.did && 177 + listCreator.did === currentUser.did 178 + ? html`<context-menu-item 179 + data-testid="menu-action-list-edit" 180 + @click=${() => handleEditList(list)} 181 + > 182 + Edit list details 183 + </context-menu-item> 184 + <context-menu-item 185 + data-testid="menu-action-list-delete" 186 + @click=${() => handleDeleteList(list)} 187 + > 188 + Delete list 189 + </context-menu-item>` 190 + : ""} 174 191 </context-menu> 175 192 ` 176 193 : null, ··· 286 303 root, 287 304 ); 288 305 }); 306 + 307 + async function handleDeleteList(list) { 308 + const deleted = await listInteractionHandler.handleDeleteList(list); 309 + if (!deleted) return; 310 + const fallbackRoute = list.creator?.handle 311 + ? `/profile/${list.creator.handle}` 312 + : "/"; 313 + window.router.back({ fallbackRoute }); 314 + } 315 + 316 + async function handleEditList(list) { 317 + const dialog = document.createElement("edit-list-details-dialog"); 318 + dialog.addEventListener("list-save", async (event) => { 319 + const { listUpdates, successCallback, errorCallback } = event.detail; 320 + try { 321 + await dataLayer.mutations.updateList(list, listUpdates); 322 + showToast("List updated"); 323 + successCallback(); 324 + } catch (error) { 325 + errorCallback(error); 326 + } 327 + }); 328 + dialog.addEventListener("edit-list-details-closed", () => { 329 + dialog.remove(); 330 + }); 331 + root.querySelector("main").appendChild(dialog); 332 + dialog.setList(list); 333 + dialog.open(); 334 + } 289 335 290 336 async function loadFeed({ reload = false } = {}) { 291 337 await dataLayer.requests.loadNextFeedPage(
+49
tests/e2e/mockServer.js
··· 1695 1695 }); 1696 1696 }); 1697 1697 1698 + await page.route("https://cdn.bsky.app/img/**", (route) => { 1699 + return route.fulfill({ 1700 + status: 200, 1701 + contentType: "image/png", 1702 + body: Buffer.from( 1703 + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", 1704 + "base64", 1705 + ), 1706 + }); 1707 + }); 1708 + 1698 1709 await page.route("https://ogcard.cdn.bsky.app/**", (route) => { 1699 1710 return route.fulfill({ 1700 1711 status: 200, ··· 2378 2389 }), 2379 2390 }); 2380 2391 } 2392 + if (collection === "app.bsky.graph.list") { 2393 + const repo = url.searchParams.get("repo"); 2394 + const listUri = `at://${repo}/${collection}/${rkey}`; 2395 + const list = this.lists.find((l) => l.uri === listUri); 2396 + if (list) { 2397 + return route.fulfill({ 2398 + status: 200, 2399 + contentType: "application/json", 2400 + body: JSON.stringify({ 2401 + uri: listUri, 2402 + cid: list.cid || "bafyreilistrecord", 2403 + value: { 2404 + $type: "app.bsky.graph.list", 2405 + purpose: list.purpose, 2406 + name: list.name, 2407 + description: list.description || "", 2408 + createdAt: list.indexedAt || "2024-01-01T00:00:00.000Z", 2409 + }, 2410 + }), 2411 + }); 2412 + } 2413 + } 2381 2414 return route.fulfill({ status: 404, body: "{}" }); 2382 2415 }); 2383 2416 ··· 2407 2440 delete profile.pinnedPost; 2408 2441 } 2409 2442 this.profiles.set(userProfile.did, profile); 2443 + } 2444 + if (collection === "app.bsky.graph.list") { 2445 + const repo = body?.repo; 2446 + const rkey = body?.rkey; 2447 + const listUri = `at://${repo}/${collection}/${rkey}`; 2448 + // Intentionally do NOT mutate the list here: the client patches its 2449 + // local list from the record it just wrote, matching real bsky 2450 + // AppView behavior (which briefly returns stale data after putRecord). 2451 + return route.fulfill({ 2452 + status: 200, 2453 + contentType: "application/json", 2454 + body: JSON.stringify({ 2455 + uri: listUri, 2456 + cid: "bafyreiupdatedlist", 2457 + }), 2458 + }); 2410 2459 } 2411 2460 return route.fulfill({ 2412 2461 status: 200,
+84
tests/e2e/specs/flows/deleteList.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + import { userProfile } from "../../testData.js"; 5 + import { createList } from "../../../shared/factories.js"; 6 + 7 + test.describe("Profile → List Detail → delete flow", () => { 8 + test("deleting a list from the list detail view removes it from the profile's Lists tab", async ({ 9 + page, 10 + }) => { 11 + const profileWithLists = { 12 + ...userProfile, 13 + associated: { lists: 2 }, 14 + }; 15 + const listToDelete = createList({ 16 + uri: `at://${userProfile.did}/app.bsky.graph.list/todelete`, 17 + name: "Doomed List", 18 + creatorHandle: userProfile.handle, 19 + }); 20 + const listToKeep = createList({ 21 + uri: `at://${userProfile.did}/app.bsky.graph.list/tokeep`, 22 + name: "Kept List", 23 + creatorHandle: userProfile.handle, 24 + }); 25 + 26 + const mockServer = new MockServer(); 27 + mockServer.addProfile(profileWithLists); 28 + mockServer.addActorLists(userProfile.did, [listToDelete, listToKeep]); 29 + mockServer.addLists([listToDelete, listToKeep]); 30 + await mockServer.setup(page); 31 + 32 + await login(page); 33 + await page.goto(`/profile/${userProfile.handle}`); 34 + 35 + const profileView = page.locator("#profile-view"); 36 + const tabBar = profileView.locator("tab-bar"); 37 + await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ 38 + timeout: 10000, 39 + }); 40 + await tabBar.locator('[data-testid="tab-lists"]').click(); 41 + 42 + const feedsList = profileView.locator( 43 + ".feed-container:not([hidden]) .feeds-list", 44 + ); 45 + await expect(feedsList.locator(".feeds-list-item")).toHaveCount(2, { 46 + timeout: 10000, 47 + }); 48 + 49 + await feedsList 50 + .locator(".feeds-list-item", { hasText: "Doomed List" }) 51 + .click(); 52 + 53 + await expect(page).toHaveURL( 54 + `/profile/${userProfile.handle}/lists/todelete`, 55 + { timeout: 10000 }, 56 + ); 57 + 58 + const listView = page.locator("#list-detail-view"); 59 + await expect( 60 + listView.locator('[data-testid="list-detail-name"]'), 61 + ).toContainText("Doomed List", { timeout: 10000 }); 62 + 63 + await listView.locator(".context-menu-button").click(); 64 + await listView.locator('[data-testid="menu-action-list-delete"]').click(); 65 + 66 + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ 67 + timeout: 10000, 68 + }); 69 + await page.locator('[data-testid="modal-confirm-button"]').click(); 70 + 71 + await expect(page).toHaveURL(`/profile/${userProfile.handle}`, { 72 + timeout: 10000, 73 + }); 74 + 75 + const restoredFeedsList = profileView.locator( 76 + ".feed-container:not([hidden]) .feeds-list", 77 + ); 78 + await expect(restoredFeedsList.locator(".feeds-list-item")).toHaveCount(1, { 79 + timeout: 10000, 80 + }); 81 + await expect(restoredFeedsList).toContainText("Kept List"); 82 + await expect(restoredFeedsList).not.toContainText("Doomed List"); 83 + }); 84 + });
+344
tests/e2e/specs/views/listDetail.view.test.js
··· 535 535 }); 536 536 }); 537 537 538 + test.describe("Edit list details", () => { 539 + const OWN_LIST_URI = "at://did:plc:testuser123/app.bsky.graph.list/ownlist"; 540 + 541 + function setupOwnList(mockServer, { description } = {}) { 542 + const list = createList({ 543 + uri: OWN_LIST_URI, 544 + name: "My Own List", 545 + creatorHandle: "testuser.bsky.social", 546 + }); 547 + if (description !== undefined) { 548 + list.description = description; 549 + } 550 + mockServer.addLists([list]); 551 + return list; 552 + } 553 + 554 + test("should not show the Edit menu item on another user's list", async ({ 555 + page, 556 + }) => { 557 + const mockServer = new MockServer(); 558 + setupList(mockServer); 559 + await mockServer.setup(page); 560 + 561 + await login(page); 562 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 563 + 564 + const view = page.locator("#list-detail-view"); 565 + await expect(view.locator(".context-menu-button")).toBeVisible({ 566 + timeout: 10000, 567 + }); 568 + await view.locator(".context-menu-button").click(); 569 + await expect( 570 + view.locator('[data-testid="menu-action-list-copy-link"]'), 571 + ).toBeVisible(); 572 + await expect( 573 + view.locator('[data-testid="menu-action-list-edit"]'), 574 + ).toHaveCount(0); 575 + }); 576 + 577 + test("should show the Edit menu item on the current user's list", async ({ 578 + page, 579 + }) => { 580 + const mockServer = new MockServer(); 581 + setupOwnList(mockServer); 582 + await mockServer.setup(page); 583 + 584 + await login(page); 585 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 586 + 587 + const view = page.locator("#list-detail-view"); 588 + await expect(view.locator(".context-menu-button")).toBeVisible({ 589 + timeout: 10000, 590 + }); 591 + await view.locator(".context-menu-button").click(); 592 + await expect( 593 + view.locator('[data-testid="menu-action-list-edit"]'), 594 + ).toBeVisible(); 595 + }); 596 + 597 + test("should edit list name and description and update the view", async ({ 598 + page, 599 + }) => { 600 + const mockServer = new MockServer(); 601 + setupOwnList(mockServer, { description: "Original description" }); 602 + await mockServer.setup(page); 603 + 604 + await login(page); 605 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 606 + 607 + const view = page.locator("#list-detail-view"); 608 + await expect( 609 + view.locator('[data-testid="list-detail-name"]'), 610 + ).toContainText("My Own List", { timeout: 10000 }); 611 + 612 + await view.locator(".context-menu-button").click(); 613 + await view.locator('[data-testid="menu-action-list-edit"]').click(); 614 + 615 + const dialog = page.locator("edit-list-details-dialog"); 616 + await expect( 617 + dialog.locator('[data-testid="edit-list-details-name"]'), 618 + ).toBeVisible({ timeout: 10000 }); 619 + 620 + await dialog 621 + .locator('[data-testid="edit-list-details-name"]') 622 + .fill("Renamed List"); 623 + await dialog 624 + .locator('[data-testid="edit-list-details-description"]') 625 + .fill("Updated description"); 626 + 627 + await dialog 628 + .locator('[data-testid="edit-list-details-save-button"]') 629 + .click(); 630 + 631 + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); 632 + await expect( 633 + view.locator('[data-testid="list-detail-name"]'), 634 + ).toContainText("Renamed List", { timeout: 10000 }); 635 + await expect( 636 + view.locator('[data-testid="list-detail-description"]'), 637 + ).toContainText("Updated description"); 638 + }); 639 + 640 + test("updates the description in place when only the description is edited", async ({ 641 + page, 642 + }) => { 643 + const mockServer = new MockServer(); 644 + setupOwnList(mockServer, { description: "Original description" }); 645 + await mockServer.setup(page); 646 + 647 + await login(page); 648 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 649 + 650 + const view = page.locator("#list-detail-view"); 651 + const descriptionEl = view.locator( 652 + '[data-testid="list-detail-description"]', 653 + ); 654 + await expect(descriptionEl).toHaveText("Original description", { 655 + timeout: 10000, 656 + }); 657 + 658 + await view.locator(".context-menu-button").click(); 659 + await view.locator('[data-testid="menu-action-list-edit"]').click(); 660 + 661 + const dialog = page.locator("edit-list-details-dialog"); 662 + const descriptionInput = dialog.locator( 663 + '[data-testid="edit-list-details-description"]', 664 + ); 665 + await expect(descriptionInput).toHaveValue("Original description", { 666 + timeout: 10000, 667 + }); 668 + 669 + await descriptionInput.fill("Brand new description"); 670 + await dialog 671 + .locator('[data-testid="edit-list-details-save-button"]') 672 + .click(); 673 + 674 + // The dialog closes and the on-page description reflects the edit. 675 + await expect(dialog).toHaveCount(0, { timeout: 10000 }); 676 + await expect(descriptionEl).toHaveText("Brand new description", { 677 + timeout: 10000, 678 + }); 679 + await expect(descriptionEl).not.toContainText("Original description"); 680 + }); 681 + 682 + test("updates the on-page avatar when a new avatar is uploaded", async ({ 683 + page, 684 + }) => { 685 + const mockServer = new MockServer(); 686 + setupOwnList(mockServer); 687 + await mockServer.setup(page); 688 + 689 + await login(page); 690 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 691 + 692 + const view = page.locator("#list-detail-view"); 693 + const avatarImg = view.locator(".list-detail-avatar"); 694 + await expect(avatarImg).toHaveAttribute( 695 + "src", 696 + "/img/list-avatar-fallback.svg", 697 + { timeout: 10000 }, 698 + ); 699 + 700 + await view.locator(".context-menu-button").click(); 701 + await view.locator('[data-testid="menu-action-list-edit"]').click(); 702 + 703 + const dialog = page.locator("edit-list-details-dialog"); 704 + await expect( 705 + dialog.locator('[data-testid="edit-list-details-name"]'), 706 + ).toBeVisible({ timeout: 10000 }); 707 + 708 + // Upload a tiny in-memory PNG via the hidden file input, then apply 709 + // the crop and save. 710 + await dialog.locator("input.edit-list-details-file-input").setInputFiles({ 711 + name: "avatar.png", 712 + mimeType: "image/png", 713 + buffer: Buffer.from( 714 + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", 715 + "base64", 716 + ), 717 + }); 718 + 719 + await expect(dialog.locator("image-cropper")).toBeVisible({ 720 + timeout: 10000, 721 + }); 722 + await dialog 723 + .locator('[data-testid="edit-list-details-crop-apply-button"]') 724 + .click(); 725 + await dialog 726 + .locator('[data-testid="edit-list-details-save-button"]') 727 + .click(); 728 + 729 + // After save the dialog closes and the on-page avatar is a CDN URL 730 + // constructed from the returned blob ref + list-owner DID. 731 + await expect(dialog).toHaveCount(0, { timeout: 10000 }); 732 + await expect(avatarImg).toHaveAttribute( 733 + "src", 734 + /^https:\/\/cdn\.bsky\.app\/img\/avatar\/plain\/did:plc:testuser123\/bafkreimockblob[a-j]+@jpeg$/, 735 + { timeout: 10000 }, 736 + ); 737 + }); 738 + 739 + test("should not show the Delete menu item on another user's list", async ({ 740 + page, 741 + }) => { 742 + const mockServer = new MockServer(); 743 + setupList(mockServer); 744 + await mockServer.setup(page); 745 + 746 + await login(page); 747 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 748 + 749 + const view = page.locator("#list-detail-view"); 750 + await expect(view.locator(".context-menu-button")).toBeVisible({ 751 + timeout: 10000, 752 + }); 753 + await view.locator(".context-menu-button").click(); 754 + await expect( 755 + view.locator('[data-testid="menu-action-list-copy-link"]'), 756 + ).toBeVisible(); 757 + await expect( 758 + view.locator('[data-testid="menu-action-list-delete"]'), 759 + ).toHaveCount(0); 760 + }); 761 + 762 + test("should show the Delete menu item on the current user's list", async ({ 763 + page, 764 + }) => { 765 + const mockServer = new MockServer(); 766 + setupOwnList(mockServer); 767 + await mockServer.setup(page); 768 + 769 + await login(page); 770 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 771 + 772 + const view = page.locator("#list-detail-view"); 773 + await expect(view.locator(".context-menu-button")).toBeVisible({ 774 + timeout: 10000, 775 + }); 776 + await view.locator(".context-menu-button").click(); 777 + await expect( 778 + view.locator('[data-testid="menu-action-list-delete"]'), 779 + ).toBeVisible(); 780 + }); 781 + 782 + test("cancelling the delete confirmation keeps the list on the page", async ({ 783 + page, 784 + }) => { 785 + const mockServer = new MockServer(); 786 + setupOwnList(mockServer); 787 + await mockServer.setup(page); 788 + 789 + await login(page); 790 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 791 + 792 + const view = page.locator("#list-detail-view"); 793 + await view.locator(".context-menu-button").click(); 794 + await view.locator('[data-testid="menu-action-list-delete"]').click(); 795 + 796 + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ 797 + timeout: 10000, 798 + }); 799 + await page.locator('[data-testid="modal-cancel-button"]').click(); 800 + 801 + await expect(page).toHaveURL( 802 + /\/profile\/testuser\.bsky\.social\/lists\/ownlist/, 803 + ); 804 + await expect( 805 + view.locator('[data-testid="list-detail-name"]'), 806 + ).toContainText("My Own List"); 807 + }); 808 + 809 + test("confirming the delete removes the list and navigates away", async ({ 810 + page, 811 + }) => { 812 + const mockServer = new MockServer(); 813 + setupOwnList(mockServer); 814 + await mockServer.setup(page); 815 + 816 + await login(page); 817 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 818 + 819 + const view = page.locator("#list-detail-view"); 820 + await expect( 821 + view.locator('[data-testid="list-detail-name"]'), 822 + ).toContainText("My Own List", { timeout: 10000 }); 823 + 824 + await view.locator(".context-menu-button").click(); 825 + await view.locator('[data-testid="menu-action-list-delete"]').click(); 826 + 827 + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ 828 + timeout: 10000, 829 + }); 830 + await page.locator('[data-testid="modal-confirm-button"]').click(); 831 + 832 + await expect(page).not.toHaveURL( 833 + /\/profile\/testuser\.bsky\.social\/lists\/ownlist/, 834 + { timeout: 10000 }, 835 + ); 836 + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); 837 + 838 + const applyWritesCalls = mockServer.applyWritesCalls; 839 + const flat = applyWritesCalls.flat(); 840 + const listDeletes = flat.filter( 841 + (write) => 842 + write.$type === "com.atproto.repo.applyWrites#delete" && 843 + write.collection === "app.bsky.graph.list" && 844 + write.rkey === "ownlist", 845 + ); 846 + expect(listDeletes.length).toBe(1); 847 + }); 848 + 849 + test("save button is disabled until a field changes and re-disabled when name is empty", async ({ 850 + page, 851 + }) => { 852 + const mockServer = new MockServer(); 853 + setupOwnList(mockServer); 854 + await mockServer.setup(page); 855 + 856 + await login(page); 857 + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); 858 + 859 + const view = page.locator("#list-detail-view"); 860 + await expect(view.locator(".context-menu-button")).toBeVisible({ 861 + timeout: 10000, 862 + }); 863 + await view.locator(".context-menu-button").click(); 864 + await view.locator('[data-testid="menu-action-list-edit"]').click(); 865 + 866 + const dialog = page.locator("edit-list-details-dialog"); 867 + const saveButton = dialog.locator( 868 + '[data-testid="edit-list-details-save-button"]', 869 + ); 870 + await expect(saveButton).toBeDisabled({ timeout: 10000 }); 871 + 872 + await dialog 873 + .locator('[data-testid="edit-list-details-name"]') 874 + .fill("Changed"); 875 + await expect(saveButton).toBeEnabled(); 876 + 877 + await dialog.locator('[data-testid="edit-list-details-name"]').fill(""); 878 + await expect(saveButton).toBeDisabled(); 879 + }); 880 + }); 881 + 538 882 test.describe("Logged-out behavior", () => { 539 883 test("should redirect to /login when not authenticated", async ({ 540 884 page,
+19 -5
tests/unit/specs/components/image-cropper.test.js
··· 77 77 assert.deepEqual(element.aspectRatio, 1); 78 78 }); 79 79 80 - it("should accept circular attribute", () => { 80 + it("should accept a circle shape", () => { 81 81 const element = document.createElement("image-cropper"); 82 - element.setAttribute("circular", ""); 82 + element.setAttribute("shape", "circle"); 83 83 connectElement(element); 84 - assert.deepEqual(element.circular, true); 84 + assert.deepEqual(element.shape, "circle"); 85 85 }); 86 86 87 - it("should not be circular by default", () => { 87 + it("should accept a rounded-square shape", () => { 88 88 const element = document.createElement("image-cropper"); 89 + element.setAttribute("shape", "rounded-square"); 89 90 connectElement(element); 90 - assert.deepEqual(element.circular, false); 91 + assert.deepEqual(element.shape, "rounded-square"); 92 + }); 93 + 94 + it("should default to square when no shape is set", () => { 95 + const element = document.createElement("image-cropper"); 96 + connectElement(element); 97 + assert.deepEqual(element.shape, "square"); 98 + }); 99 + 100 + it("should fall back to square when shape is unknown", () => { 101 + const element = document.createElement("image-cropper"); 102 + element.setAttribute("shape", "bogus"); 103 + connectElement(element); 104 + assert.deepEqual(element.shape, "square"); 91 105 }); 92 106 93 107 it("should initialize with default state", () => {
+519 -54
tests/unit/specs/dataLayer/mutations.test.js
··· 938 938 assert.deepEqual(uploadBlobCallCount, 1); 939 939 }); 940 940 941 - it("should update dataStore with the fetched profile on success", async () => { 942 - const mockApi = makeMockApi({ 943 - getProfile: async (did) => ({ 944 - did, 945 - displayName: "Updated Name", 946 - description: "Updated bio", 947 - avatar: "https://example.com/new-avatar.jpg", 948 - viewer: {}, 949 - }), 950 - }); 951 - 952 - const { mutations, dataStore } = createMutationsWithMockApi(mockApi); 941 + it("patches $profiles in place with the new displayName and description", async () => { 942 + // The appview lags briefly after putRecord, so we patch locally rather 943 + // than round-tripping through getProfile (which can return stale data). 944 + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); 953 945 await mutations.updateProfile(testProfile, { 954 - displayName: "Updated Name", 955 - description: "Updated bio", 946 + displayName: "Patched Name", 947 + description: "Patched bio", 956 948 }); 957 949 958 950 const updatedProfile = dataStore.$profiles.get(testProfile.did); 959 - assert.deepEqual(updatedProfile.displayName, "Updated Name"); 960 - assert.deepEqual(updatedProfile.description, "Updated bio"); 961 - assert.deepEqual( 962 - updatedProfile.avatar, 963 - "https://example.com/new-avatar.jpg", 964 - ); 951 + assert.deepEqual(updatedProfile.displayName, "Patched Name"); 952 + assert.deepEqual(updatedProfile.description, "Patched bio"); 953 + // Non-patched fields survive. 954 + assert.deepEqual(updatedProfile.did, testProfile.did); 965 955 }); 966 956 967 - it("should fetch profile with labelers after updating", async () => { 968 - let getProfileArgs = null; 957 + it("sets the local avatar and banner to CDN URLs built from the uploaded refs", async () => { 958 + let uploadCallIndex = 0; 969 959 const mockApi = makeMockApi({ 970 - getProfile: async (did, options) => { 971 - getProfileArgs = { did, options }; 960 + uploadBlob: async () => { 961 + uploadCallIndex++; 972 962 return { 973 - did, 974 - displayName: "Fetched", 975 - description: "Fetched", 976 - viewer: {}, 963 + ref: { $link: `bafkreiblob${uploadCallIndex}` }, 964 + mimeType: "image/jpeg", 965 + size: 100, 977 966 }; 978 967 }, 979 968 }); 980 969 981 - const { mutations } = createMutationsWithMockApi(mockApi); 970 + const { mutations, dataStore } = createMutationsWithMockApi(mockApi); 971 + await mutations.updateProfile(testProfile, { 972 + displayName: "Name", 973 + description: "Bio", 974 + avatarBlob: new Blob(["a"], { type: "image/jpeg" }), 975 + bannerBlob: new Blob(["b"], { type: "image/jpeg" }), 976 + }); 977 + 978 + const updatedProfile = dataStore.$profiles.get(testProfile.did); 979 + assert.match( 980 + updatedProfile.avatar, 981 + /^https:\/\/cdn\.bsky\.app\/img\/avatar\/plain\/did:plc:test123\/bafkreiblob\d+@jpeg$/, 982 + ); 983 + assert.match( 984 + updatedProfile.banner, 985 + /^https:\/\/cdn\.bsky\.app\/img\/banner\/plain\/did:plc:test123\/bafkreiblob\d+@jpeg$/, 986 + ); 987 + }); 988 + 989 + it("clears the local avatar and banner when removeAvatar/removeBanner are true", async () => { 990 + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); 982 991 await mutations.updateProfile(testProfile, { 983 - displayName: "New Name", 984 - description: "New bio", 992 + displayName: "Name", 993 + description: "Bio", 994 + removeAvatar: true, 995 + removeBanner: true, 985 996 }); 986 997 987 - assert.deepEqual(getProfileArgs.did, testProfile.did); 988 - assert.deepEqual(Array.isArray(getProfileArgs.options.labelers), true); 998 + const updatedProfile = dataStore.$profiles.get(testProfile.did); 999 + assert.equal(updatedProfile.avatar, ""); 1000 + assert.equal(updatedProfile.banner, ""); 989 1001 }); 990 1002 991 1003 it("should rethrow non-400 errors from getProfileRecord", async () => { ··· 1008 1020 }); 1009 1021 1010 1022 it("should update currentUser when editing own profile", async () => { 1011 - const mockApi = makeMockApi({ 1012 - getProfile: async (did) => ({ 1013 - did, 1014 - displayName: "Updated User", 1015 - description: "Updated bio", 1016 - viewer: {}, 1017 - }), 1018 - }); 1019 - 1020 - const { mutations, dataStore } = createMutationsWithMockApi(mockApi); 1023 + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); 1021 1024 await mutations.updateProfile(testProfile, { 1022 1025 displayName: "Updated User", 1023 1026 description: "Updated bio", ··· 1025 1028 1026 1029 const currentUser = dataStore.$currentUser.get(); 1027 1030 assert.deepEqual(currentUser.displayName, "Updated User"); 1031 + assert.deepEqual(currentUser.description, "Updated bio"); 1028 1032 }); 1029 1033 }); 1030 1034 ··· 3705 3709 return { mutations, dataStore }; 3706 3710 } 3707 3711 3708 - it("updateProfile writes the fetched detailed profile to both stores", async () => { 3709 - const fetched = { 3710 - did: targetDid, 3711 - displayName: "Updated Name", 3712 - description: "Updated bio", 3713 - pinnedPost: { uri: "at://newpinned" }, 3714 - viewer: {}, 3715 - }; 3712 + it("updateProfile patches both $profiles and $detailedProfiles in place", async () => { 3716 3713 const mockApi = { 3717 3714 getProfileRecord: async () => ({ value: {}, cid: "cid" }), 3718 3715 putProfileRecord: async () => ({}), 3719 - getProfile: async () => fetched, 3720 3716 }; 3721 3717 const { mutations, dataStore } = setup(mockApi); 3722 3718 await mutations.updateProfile(baseProfile, { 3723 3719 displayName: "Updated Name", 3724 3720 description: "Updated bio", 3725 3721 }); 3726 - assert.deepEqual(dataStore.$profiles.get(targetDid), fetched); 3727 - assert.deepEqual(dataStore.$detailedProfiles.get(targetDid), fetched); 3722 + const patchedBasic = dataStore.$profiles.get(targetDid); 3723 + assert.deepEqual(patchedBasic.displayName, "Updated Name"); 3724 + assert.deepEqual(patchedBasic.description, "Updated bio"); 3725 + assert.deepEqual(patchedBasic.followersCount, 10); 3726 + // Detailed-only fields survive on the detailed entry. 3727 + const patchedDetailed = dataStore.$detailedProfiles.get(targetDid); 3728 + assert.deepEqual(patchedDetailed.displayName, "Updated Name"); 3729 + assert.deepEqual(patchedDetailed.description, "Updated bio"); 3730 + assert.deepEqual(patchedDetailed.pinnedPost.uri, "at://pinned"); 3728 3731 }); 3729 3732 3730 3733 it("followProfile mirrors viewer.following and count into $detailedProfiles", async () => { ··· 3874 3877 ); 3875 3878 }); 3876 3879 }); 3880 + 3881 + describe("updateList", () => { 3882 + const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; 3883 + const testList = { 3884 + uri: listUri, 3885 + cid: "listcid", 3886 + name: "Old Name", 3887 + description: "Old description", 3888 + purpose: "app.bsky.graph.defs#curatelist", 3889 + creator: { did: "did:plc:test123", handle: "test.bsky.social" }, 3890 + viewer: {}, 3891 + }; 3892 + 3893 + function setup(overrides = {}) { 3894 + const dataStore = new DataStore(); 3895 + const patchStore = new PatchStore(dataStore); 3896 + const preferencesProvider = { 3897 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3898 + }; 3899 + dataStore.$lists.set(listUri, testList); 3900 + const api = { 3901 + getListRecord: async () => ({ 3902 + value: { 3903 + purpose: "app.bsky.graph.defs#curatelist", 3904 + name: "Old Name", 3905 + description: "Old description", 3906 + createdAt: "2024-01-01T00:00:00.000Z", 3907 + }, 3908 + cid: "listcid", 3909 + }), 3910 + putListRecord: async () => ({}), 3911 + uploadBlob: async () => ({ 3912 + ref: { $link: "list-avatar-blob" }, 3913 + mimeType: "image/jpeg", 3914 + size: 100, 3915 + }), 3916 + ...overrides, 3917 + }; 3918 + return { 3919 + api, 3920 + dataStore, 3921 + mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), 3922 + }; 3923 + } 3924 + 3925 + it("puts the record with merged fields and the swapCid from getListRecord", async () => { 3926 + let putArgs = null; 3927 + const { mutations } = setup({ 3928 + putListRecord: async (rkey, record, swapCid) => { 3929 + putArgs = { rkey, record, swapCid }; 3930 + return {}; 3931 + }, 3932 + }); 3933 + 3934 + await mutations.updateList(testList, { 3935 + name: "New Name", 3936 + description: "New description", 3937 + }); 3938 + 3939 + assert.equal(putArgs.rkey, "mylist"); 3940 + assert.equal(putArgs.record.name, "New Name"); 3941 + assert.equal(putArgs.record.description, "New description"); 3942 + assert.equal(putArgs.record.purpose, "app.bsky.graph.defs#curatelist"); 3943 + assert.equal(putArgs.record.createdAt, "2024-01-01T00:00:00.000Z"); 3944 + assert.equal(putArgs.swapCid, "listcid"); 3945 + }); 3946 + 3947 + it("uploads and sets the avatar blob when provided", async () => { 3948 + let uploadCalled = false; 3949 + let putRecord = null; 3950 + const { mutations } = setup({ 3951 + uploadBlob: async () => { 3952 + uploadCalled = true; 3953 + return { 3954 + ref: { $link: "avatar-blob" }, 3955 + mimeType: "image/jpeg", 3956 + size: 100, 3957 + }; 3958 + }, 3959 + putListRecord: async (rkey, record) => { 3960 + putRecord = record; 3961 + return {}; 3962 + }, 3963 + }); 3964 + 3965 + await mutations.updateList(testList, { 3966 + name: "Name", 3967 + description: "Desc", 3968 + avatarBlob: new Blob(["x"], { type: "image/jpeg" }), 3969 + }); 3970 + 3971 + assert.equal(uploadCalled, true); 3972 + assert.deepEqual(putRecord.avatar, { 3973 + ref: { $link: "avatar-blob" }, 3974 + mimeType: "image/jpeg", 3975 + size: 100, 3976 + }); 3977 + }); 3978 + 3979 + it("strips stale descriptionFacets from the put record when description changes", async () => { 3980 + let putRecord = null; 3981 + const { mutations } = setup({ 3982 + getListRecord: async () => ({ 3983 + value: { 3984 + purpose: "app.bsky.graph.defs#curatelist", 3985 + name: "Old Name", 3986 + description: "Old description", 3987 + descriptionFacets: [ 3988 + { index: { byteStart: 0, byteEnd: 3 }, features: [] }, 3989 + ], 3990 + createdAt: "2024-01-01T00:00:00.000Z", 3991 + }, 3992 + cid: "listcid", 3993 + }), 3994 + putListRecord: async (rkey, record) => { 3995 + putRecord = record; 3996 + return {}; 3997 + }, 3998 + }); 3999 + 4000 + await mutations.updateList(testList, { 4001 + name: "Old Name", 4002 + description: "Totally different text", 4003 + }); 4004 + 4005 + assert.equal("descriptionFacets" in putRecord, false); 4006 + }); 4007 + 4008 + it("clears local descriptionFacets when description changes", async () => { 4009 + const { mutations, dataStore } = setup(); 4010 + dataStore.$lists.set(listUri, { 4011 + ...testList, 4012 + descriptionFacets: [ 4013 + { index: { byteStart: 0, byteEnd: 3 }, features: [] }, 4014 + ], 4015 + }); 4016 + 4017 + await mutations.updateList(testList, { 4018 + name: "Old Name", 4019 + description: "Totally different text", 4020 + }); 4021 + 4022 + assert.deepEqual(dataStore.$lists.get(listUri).descriptionFacets, []); 4023 + }); 4024 + 4025 + it("deletes the avatar from the record when removeAvatar is true", async () => { 4026 + let putRecord = null; 4027 + const { mutations } = setup({ 4028 + getListRecord: async () => ({ 4029 + value: { 4030 + purpose: "app.bsky.graph.defs#curatelist", 4031 + name: "Old Name", 4032 + description: "Old description", 4033 + avatar: { ref: { $link: "existing-avatar" } }, 4034 + createdAt: "2024-01-01T00:00:00.000Z", 4035 + }, 4036 + cid: "listcid", 4037 + }), 4038 + putListRecord: async (rkey, record) => { 4039 + putRecord = record; 4040 + return {}; 4041 + }, 4042 + }); 4043 + 4044 + await mutations.updateList(testList, { 4045 + name: "Name", 4046 + description: "Desc", 4047 + removeAvatar: true, 4048 + }); 4049 + 4050 + assert.equal("avatar" in putRecord, false); 4051 + }); 4052 + 4053 + it("patches dataStore.$lists in place with the new name and description", async () => { 4054 + // The appview lags briefly after putRecord, so we patch locally rather 4055 + // than round-tripping through getList (which can return stale data). 4056 + const { mutations, dataStore } = setup(); 4057 + 4058 + await mutations.updateList(testList, { 4059 + name: "Patched Name", 4060 + description: "Patched description", 4061 + }); 4062 + 4063 + const updated = dataStore.$lists.get(listUri); 4064 + assert.equal(updated.name, "Patched Name"); 4065 + assert.equal(updated.description, "Patched description"); 4066 + // Non-patched fields survive. 4067 + assert.equal(updated.uri, listUri); 4068 + assert.equal(updated.purpose, "app.bsky.graph.defs#curatelist"); 4069 + }); 4070 + 4071 + it("sets the local avatar to a CDN URL built from the uploaded blob ref", async () => { 4072 + const { mutations, dataStore } = setup({ 4073 + uploadBlob: async () => ({ 4074 + ref: { $link: "bafkreiavatarcid" }, 4075 + mimeType: "image/jpeg", 4076 + size: 100, 4077 + }), 4078 + }); 4079 + 4080 + await mutations.updateList(testList, { 4081 + name: "Name", 4082 + description: "Desc", 4083 + avatarBlob: new Blob(["x"], { type: "image/jpeg" }), 4084 + }); 4085 + 4086 + assert.equal( 4087 + dataStore.$lists.get(listUri).avatar, 4088 + "https://cdn.bsky.app/img/avatar/plain/did:plc:test123/bafkreiavatarcid@jpeg", 4089 + ); 4090 + }); 4091 + 4092 + it("clears the local avatar when removeAvatar is true", async () => { 4093 + const { mutations, dataStore } = setup(); 4094 + dataStore.$lists.set(listUri, { 4095 + ...testList, 4096 + avatar: "https://example.com/list-avatar.jpg", 4097 + }); 4098 + 4099 + await mutations.updateList(testList, { 4100 + name: "Name", 4101 + description: "Desc", 4102 + removeAvatar: true, 4103 + }); 4104 + 4105 + assert.equal(dataStore.$lists.get(listUri).avatar, ""); 4106 + }); 4107 + }); 4108 + 4109 + describe("deleteList", () => { 4110 + const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; 4111 + const otherListUri = "at://did:plc:test123/app.bsky.graph.list/other"; 4112 + const testList = { 4113 + uri: listUri, 4114 + cid: "listcid", 4115 + name: "My List", 4116 + purpose: "app.bsky.graph.defs#curatelist", 4117 + creator: { did: "did:plc:test123", handle: "test.bsky.social" }, 4118 + viewer: {}, 4119 + }; 4120 + 4121 + function setup({ listItems = [], overrides = {} } = {}) { 4122 + const dataStore = new DataStore(); 4123 + const patchStore = new PatchStore(dataStore); 4124 + const preferences = Preferences.createLoggedOutPreferences(); 4125 + const preferencesProvider = { 4126 + requirePreferences: () => preferences, 4127 + updatePreferences: async () => {}, 4128 + }; 4129 + dataStore.$lists.set(listUri, testList); 4130 + const api = { 4131 + getListItems: async () => ({ records: listItems, cursor: "" }), 4132 + applyWrites: async () => ({}), 4133 + ...overrides, 4134 + }; 4135 + return { 4136 + api, 4137 + dataStore, 4138 + preferencesProvider, 4139 + mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), 4140 + }; 4141 + } 4142 + 4143 + it("deletes the list record via applyWrites and clears the local list", async () => { 4144 + const writesCalls = []; 4145 + const { mutations, dataStore } = setup({ 4146 + overrides: { 4147 + applyWrites: async (writes) => { 4148 + writesCalls.push(writes); 4149 + return {}; 4150 + }, 4151 + }, 4152 + }); 4153 + 4154 + await mutations.deleteList(testList); 4155 + 4156 + assert.equal(writesCalls.length, 1); 4157 + assert.deepEqual(writesCalls[0], [ 4158 + { 4159 + $type: "com.atproto.repo.applyWrites#delete", 4160 + collection: "app.bsky.graph.list", 4161 + rkey: "mylist", 4162 + }, 4163 + ]); 4164 + assert.equal(dataStore.$lists.get(listUri), null); 4165 + }); 4166 + 4167 + it("also deletes listitems belonging to this list, ignoring items in other lists", async () => { 4168 + const writesCalls = []; 4169 + const listItems = [ 4170 + { 4171 + uri: "at://did:plc:test123/app.bsky.graph.listitem/keep", 4172 + value: { list: otherListUri }, 4173 + }, 4174 + { 4175 + uri: "at://did:plc:test123/app.bsky.graph.listitem/item1", 4176 + value: { list: listUri }, 4177 + }, 4178 + { 4179 + uri: "at://did:plc:test123/app.bsky.graph.listitem/item2", 4180 + value: { list: listUri }, 4181 + }, 4182 + ]; 4183 + const { mutations } = setup({ 4184 + listItems, 4185 + overrides: { 4186 + applyWrites: async (writes) => { 4187 + writesCalls.push(writes); 4188 + return {}; 4189 + }, 4190 + }, 4191 + }); 4192 + 4193 + await mutations.deleteList(testList); 4194 + 4195 + const flat = writesCalls.flat(); 4196 + const deletedRkeys = flat 4197 + .filter((w) => w.collection === "app.bsky.graph.listitem") 4198 + .map((w) => w.rkey); 4199 + assert.deepEqual(deletedRkeys.sort(), ["item1", "item2"]); 4200 + assert.equal( 4201 + flat.filter((w) => w.collection === "app.bsky.graph.list").length, 4202 + 1, 4203 + ); 4204 + }); 4205 + 4206 + it("chunks applyWrites into batches of 10", async () => { 4207 + const listItems = Array.from({ length: 25 }, (_, i) => ({ 4208 + uri: `at://did:plc:test123/app.bsky.graph.listitem/item${i}`, 4209 + value: { list: listUri }, 4210 + })); 4211 + const writesCalls = []; 4212 + const { mutations } = setup({ 4213 + listItems, 4214 + overrides: { 4215 + applyWrites: async (writes) => { 4216 + writesCalls.push(writes); 4217 + return {}; 4218 + }, 4219 + }, 4220 + }); 4221 + 4222 + await mutations.deleteList(testList); 4223 + 4224 + // 25 items + 1 list record = 26 writes → 10, 10, 6 4225 + assert.deepEqual( 4226 + writesCalls.map((c) => c.length), 4227 + [10, 10, 6], 4228 + ); 4229 + }); 4230 + 4231 + it("clears cached list members for the deleted list", async () => { 4232 + const { mutations, dataStore } = setup(); 4233 + dataStore.$listMembers.set(listUri, { 4234 + items: [{ uri: "at://x", subject: { did: "did:plc:m1" } }], 4235 + cursor: "", 4236 + }); 4237 + 4238 + await mutations.deleteList(testList); 4239 + 4240 + assert.equal(dataStore.$listMembers.get(listUri), null); 4241 + }); 4242 + 4243 + it("removes the list from $actorLists for the creator", async () => { 4244 + const { mutations, dataStore } = setup(); 4245 + const otherList = { uri: otherListUri, name: "Other" }; 4246 + dataStore.$actorLists.set(testList.creator.did, { 4247 + lists: [testList, otherList], 4248 + cursor: "", 4249 + }); 4250 + 4251 + await mutations.deleteList(testList); 4252 + 4253 + const remaining = dataStore.$actorLists.get(testList.creator.did); 4254 + assert.deepEqual( 4255 + remaining.lists.map((entry) => entry.uri), 4256 + [otherListUri], 4257 + ); 4258 + }); 4259 + 4260 + it("removes the list from cached $listsWithMembershipByActor entries", async () => { 4261 + const { mutations, dataStore } = setup(); 4262 + dataStore.$listsWithMembershipByActor.set("did:plc:member1", { 4263 + listsWithMembership: [ 4264 + { list: { uri: listUri }, listItem: { uri: "at://li1" } }, 4265 + { list: { uri: otherListUri }, listItem: { uri: "at://li2" } }, 4266 + ], 4267 + cursor: "", 4268 + }); 4269 + dataStore.$listsWithMembershipByActor.set("did:plc:untouched", { 4270 + listsWithMembership: [ 4271 + { list: { uri: otherListUri }, listItem: { uri: "at://li3" } }, 4272 + ], 4273 + cursor: "", 4274 + }); 4275 + 4276 + await mutations.deleteList(testList); 4277 + 4278 + assert.deepEqual( 4279 + dataStore.$listsWithMembershipByActor 4280 + .get("did:plc:member1") 4281 + .listsWithMembership.map((entry) => entry.list.uri), 4282 + [otherListUri], 4283 + ); 4284 + // Unrelated entries are untouched. 4285 + assert.equal( 4286 + dataStore.$listsWithMembershipByActor.get("did:plc:untouched") 4287 + .listsWithMembership.length, 4288 + 1, 4289 + ); 4290 + }); 4291 + 4292 + it("removes the list from $pinnedItems if present", async () => { 4293 + const { mutations, dataStore } = setup(); 4294 + dataStore.$pinnedItems.set([ 4295 + { type: "timeline", data: { uri: "following" } }, 4296 + { type: "list", data: { uri: listUri, displayName: "My List" } }, 4297 + { type: "list", data: { uri: otherListUri, displayName: "Other" } }, 4298 + ]); 4299 + 4300 + await mutations.deleteList(testList); 4301 + 4302 + assert.deepEqual( 4303 + dataStore.$pinnedItems.get().map((item) => item.data.uri), 4304 + ["following", otherListUri], 4305 + ); 4306 + }); 4307 + 4308 + it("unpins the list if it was pinned", async () => { 4309 + const dataStore = new DataStore(); 4310 + const patchStore = new PatchStore(dataStore); 4311 + let preferences = Preferences.createLoggedOutPreferences().pinFeed( 4312 + listUri, 4313 + "list", 4314 + ); 4315 + const updateCalls = []; 4316 + const preferencesProvider = { 4317 + requirePreferences: () => preferences, 4318 + updatePreferences: async (next) => { 4319 + updateCalls.push(next); 4320 + preferences = next; 4321 + }, 4322 + }; 4323 + dataStore.$lists.set(listUri, testList); 4324 + const api = { 4325 + getListItems: async () => ({ records: [], cursor: "" }), 4326 + applyWrites: async () => ({}), 4327 + }; 4328 + const mutations = makeMutations( 4329 + api, 4330 + dataStore, 4331 + patchStore, 4332 + preferencesProvider, 4333 + ); 4334 + 4335 + assert.equal(preferences.isFeedPinned(listUri), true); 4336 + await mutations.deleteList(testList); 4337 + 4338 + assert.equal(updateCalls.length, 1); 4339 + assert.equal(preferences.isFeedPinned(listUri), false); 4340 + }); 4341 + });