From c482c255f3dc10a15bfd8055b1afd5e44801695e Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Tue, 21 Jul 2026 19:30:22 -0500 Subject: [PATCH] Add list editing and deletion --- package.json | 2 +- src/css/style.css | 24 +- src/js/api.js | 28 + src/js/components/account-switcher-dialog.js | 2 + src/js/components/edit-list-details-dialog.js | 447 ++++++++++++++ src/js/components/edit-profile-dialog.js | 9 +- src/js/components/image-alt-text-dialog.js | 5 + src/js/components/image-cropper.js | 68 ++- src/js/components/plugin-blob-image.js | 27 +- .../components/post-notifications-dialog.js | 5 + src/js/components/report-dialog.js | 5 + src/js/dataHelpers.js | 21 +- src/js/dataLayer/mutations.js | 170 +++++- src/js/listInteractionHandler.js | 22 + src/js/views/listDetail.view.js | 46 ++ tests/e2e/mockServer.js | 49 ++ tests/e2e/specs/flows/deleteList.test.js | 84 +++ tests/e2e/specs/views/listDetail.view.test.js | 344 +++++++++++ .../specs/components/image-cropper.test.js | 24 +- tests/unit/specs/dataLayer/mutations.test.js | 573 ++++++++++++++++-- 20 files changed, 1831 insertions(+), 124 deletions(-) create mode 100644 src/js/components/edit-list-details-dialog.js create mode 100644 tests/e2e/specs/flows/deleteList.test.js diff --git a/package.json b/package.json index d46de693..174598ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.45", + "version": "0.18.46", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 31cdee77..4ecbe1d7 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -5809,6 +5809,10 @@ emoji-picker-dialog emoji-picker, font-size: 24px; } +#list-detail-view .pin-feed-button { + margin-right: 8px; +} + #feed-detail-view .pin-feed-button.pinned, #list-detail-view .pin-feed-button.pinned { color: var(--highlight-color); @@ -9474,7 +9478,7 @@ tab-bar[full-width] .tab-bar-button { } .edit-profile-camera-button-avatar { - bottom: 6px; + bottom: 0; right: 0; } @@ -9482,8 +9486,6 @@ tab-bar[full-width] .tab-bar-button { position: absolute; bottom: 0; left: 12px; - width: 84px; - height: 84px; cursor: pointer; } @@ -9558,6 +9560,22 @@ tab-bar[full-width] .tab-bar-button { background-color: light-dark(#fef2f2, #3b1111); } +.edit-list-details-dialog .edit-list-details-images-section { + padding: 8px 0; + display: flex; + justify-content: flex-start; +} + +.edit-list-details-dialog .edit-profile-avatar-wrapper { + position: relative; + bottom: auto; + left: auto; +} + +.edit-list-details-dialog .edit-profile-avatar-preview { + border-radius: var(--list-avatar-border-radius); +} + /* Image Cropper */ image-cropper { diff --git a/src/js/api.js b/src/js/api.js index 65cd5fb3..c50ac94b 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -1390,6 +1390,34 @@ export class Api { return res.data; } + async getListRecord(rkey) { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.graph.list", + rkey, + }, + }); + return res.data; + } + + async putListRecord(rkey, record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.graph.list", + rkey, + record: { + $type: "app.bsky.graph.list", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + async createModerationReport({ reasonType, reason, subject, labelerDid }) { const body = { reasonType, diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index d7642e6e..af66641b 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -47,6 +47,8 @@ class AccountSwitcherDialog extends Component { this._disposeEffect?.(); this._disposeEffect = null; window.removeEventListener("pageshow", this._onPageShow); + this.scrollLock?.release(); + this.scrollLock = null; } async _load() { diff --git a/src/js/components/edit-list-details-dialog.js b/src/js/components/edit-list-details-dialog.js new file mode 100644 index 00000000..8fb544d9 --- /dev/null +++ b/src/js/components/edit-list-details-dialog.js @@ -0,0 +1,447 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { scrollLocks } from "/js/scrollLocks.js"; +import { + closeWithAnimation, + enableDragToDismiss, + resetScrollOnBlur, +} from "/js/dialogHelpers.js"; +import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; +import { ImageCompressor } from "/js/imageCompressor.js"; +import "/js/components/image-cropper.js"; +import "/js/components/context-menu.js"; +import "/js/components/context-menu-item.js"; +import "/js/components/context-menu-item-group.js"; +import { cameraIconTemplate } from "/js/templates/icons/cameraIcon.template.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 300; + +class EditListDetailsDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.setAttribute("data-dialog-wrapper", ""); + this.scrollLock = null; + this._name = ""; + this._description = ""; + this._currentAvatar = null; + this._newAvatarDataUrl = null; + this._removeAvatar = false; + this._saving = false; + this._error = null; + this._croppingImageSrc = null; + this._isOpen = false; + this._list = null; + this.innerHTML = ""; + this.render(); + this.initialized = true; + } + + setList(list) { + this._list = list; + this._name = list.name || ""; + this._description = list.description || ""; + this._currentAvatar = list.avatar || null; + this._newAvatarDataUrl = null; + this._removeAvatar = false; + this._saving = false; + this._error = null; + this._croppingImageSrc = null; + this.render(); + } + + get _isDirty() { + if (!this._list) return false; + return ( + this._name !== (this._list.name || "") || + this._description !== (this._list.description || "") || + this._newAvatarDataUrl !== null || + this._removeAvatar + ); + } + + get _isNameTooLong() { + return graphemeCount(this._name) > MAX_NAME_LENGTH; + } + + get _isNameEmpty() { + return this._name.trim().length === 0; + } + + get _isDescriptionTooLong() { + return graphemeCount(this._description) > MAX_DESCRIPTION_LENGTH; + } + + get _canSave() { + return ( + this._isDirty && + !this._saving && + !this._isNameEmpty && + !this._isNameTooLong && + !this._isDescriptionTooLong + ); + } + + render() { + const isCropping = !!this._croppingImageSrc; + + const nameCount = graphemeCount(this._name); + const descriptionCount = graphemeCount(this._description); + const avatarSrc = this._removeAvatar + ? null + : this._newAvatarDataUrl || this._currentAvatar; + + render( + html` { + if (!isCropping && event.target.tagName === "DIALOG") { + if (await this.confirmClose()) { + this.close(); + } + } + }} + @cancel=${async (event) => { + event.preventDefault(); + if (isCropping) { + this._croppingImageSrc = null; + this.render(); + } else if (await this.confirmClose()) { + this.close(); + } + }} + @close=${() => { + this.scrollLock?.release(); + this.scrollLock = null; + this.dispatchEvent(new CustomEvent("edit-list-details-closed")); + }} + > + ${isCropping + ? html`
+
+ +

Edit image

+ +
+
+ +
+
` + : html`
+
+ +

Edit list details

+ +
+ +
+
+
this._openAvatarMenu()} + > +
+ ${avatarSrc + ? html`Avatar preview` + : html``} +
+
+
+ ${cameraIconTemplate()} +
+
+
+ + + + this._pickImage()} + > + Upload from Files + + + ${avatarSrc + ? html` + { + this._newAvatarDataUrl = null; + this._removeAvatar = true; + this.render(); + }} + > + Remove Avatar + + ` + : ""} + + +
+ + { + this._name = event.target.value; + this.render(); + }} + data-testid="edit-list-details-name" + /> +
+ ${nameCount}/${MAX_NAME_LENGTH} +
+
+ +
+ + +
+ ${descriptionCount}/${MAX_DESCRIPTION_LENGTH} +
+
+ + ${this._error + ? html`
${this._error}
` + : ""} +
+
`} + + this._handleFileSelect(event)} + @cancel=${(event) => { + event.stopPropagation(); + }} + /> +
`, + this, + ); + + if (this._isOpen) { + const dialog = this.querySelector(".edit-list-details-dialog"); + if (dialog && !dialog.open) { + dialog.showModal(); + } + } + } + + _openAvatarMenu() { + const menu = this.querySelector(".edit-list-details-avatar-menu"); + const cameraButton = this.querySelector( + ".edit-profile-camera-button-avatar", + ); + if (menu && cameraButton) { + const rect = cameraButton.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.bottom; + menu.open(x, y); + } + } + + _pickImage() { + const input = this.querySelector(".edit-list-details-file-input"); + if (input) { + input.click(); + } + } + + async _handleFileSelect(event) { + const file = event.target.files?.[0]; + if (!file || !file.type.startsWith("image/")) { + event.target.value = ""; + return; + } + + const dataUrl = await readFileAsDataUrl(file); + event.target.value = ""; + + this._croppingImageSrc = dataUrl; + this.render(); + } + + async _applyCrop() { + const cropper = this.querySelector("image-cropper"); + if (!cropper) return; + + const croppedDataUrl = cropper.cropImage(); + if (!croppedDataUrl) return; + + this._newAvatarDataUrl = croppedDataUrl; + this._removeAvatar = false; + this._croppingImageSrc = null; + this.render(); + } + + async _save() { + this._saving = true; + this._error = null; + this.render(); + + try { + let avatarBlob = null; + if (this._newAvatarDataUrl) { + const compressed = await new ImageCompressor().compressImage( + this._newAvatarDataUrl, + ); + avatarBlob = compressed.blob; + } + + const successCallback = () => { + this.close(); + }; + const errorCallback = (error) => { + console.error("Failed to update list:", error); + this._error = "Failed to save list. Please try again."; + this._saving = false; + this.render(); + }; + + this.dispatchEvent( + new CustomEvent("list-save", { + detail: { + listUpdates: { + name: this._name, + description: this._description, + avatarBlob, + removeAvatar: this._removeAvatar, + }, + successCallback, + errorCallback, + }, + }), + ); + } catch (error) { + console.error("Error saving list:", error); + this._error = "Failed to save list. Please try again."; + this._saving = false; + this.render(); + } + } + + open() { + this._isOpen = true; + this.scrollLock ??= scrollLocks.acquire({ target: this }); + const dialog = this.querySelector(".edit-list-details-dialog"); + if (dialog?.open) return; + if (dialog) { + dialog.showModal(); + enableDragToDismiss(dialog, { + confirmDismiss: () => this.confirmClose(), + onClose: () => this.close(), + scrollContainer: this.querySelector(".edit-profile-dialog-content"), + ignoreTouchTarget: (el) => + !!el.closest("button") || + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + !!el.closest("image-cropper"), + disableWhenKeyboardOpen: true, + }); + + resetScrollOnBlur( + dialog, + this.querySelector(".edit-profile-dialog-content"), + ); + } + } + + async confirmClose() { + if (!this._isDirty || !!this._croppingImageSrc || this._saving) return true; + return confirmModal("Are you sure you want to discard your changes?", { + title: "Discard changes?", + confirmButtonStyle: "danger", + confirmButtonText: "Discard", + }); + } + + close() { + this._isOpen = false; + return closeWithAnimation(this.querySelector(".edit-list-details-dialog")); + } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } +} + +EditListDetailsDialog.register(); diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js index 49a51ab9..77afecbc 100644 --- a/src/js/components/edit-profile-dialog.js +++ b/src/js/components/edit-profile-dialog.js @@ -159,7 +159,9 @@ class EditProfileDialog extends Component { ` @@ -516,6 +518,11 @@ class EditProfileDialog extends Component { this._isOpen = false; return closeWithAnimation(this.querySelector(".edit-profile-dialog")); } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } } EditProfileDialog.register(); diff --git a/src/js/components/image-alt-text-dialog.js b/src/js/components/image-alt-text-dialog.js index dd0870a0..ebd55522 100644 --- a/src/js/components/image-alt-text-dialog.js +++ b/src/js/components/image-alt-text-dialog.js @@ -159,6 +159,11 @@ class ImageAltTextDialog extends Component { ); this.close(); } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } } ImageAltTextDialog.register(); diff --git a/src/js/components/image-cropper.js b/src/js/components/image-cropper.js index e31a3772..f104bd21 100644 --- a/src/js/components/image-cropper.js +++ b/src/js/components/image-cropper.js @@ -3,6 +3,9 @@ import { Component } from "/js/components/component.js"; const MIN_SCALE = 1; const MAX_SCALE = 5; +const SHAPES = new Set(["circle", "square", "rounded-square"]); +const ROUNDED_SQUARE_RADIUS = 8; + // Claude wrote this class ImageCropper extends Component { connectedCallback() { @@ -48,14 +51,14 @@ class ImageCropper extends Component { } static get observedAttributes() { - return ["src", "aspect-ratio", "circular"]; + return ["src", "aspect-ratio", "shape"]; } attributeChangedCallback(name, oldValue, newValue) { if (!this.initialized) return; if (name === "src" && newValue !== oldValue) { this.loadImage(newValue); - } else if (name === "aspect-ratio" || name === "circular") { + } else if (name === "aspect-ratio" || name === "shape") { this._draw(); } } @@ -64,8 +67,9 @@ class ImageCropper extends Component { return parseFloat(this.getAttribute("aspect-ratio")) || 1; } - get circular() { - return this.hasAttribute("circular"); + get shape() { + const attr = this.getAttribute("shape"); + return SHAPES.has(attr) ? attr : "square"; } async loadImage(src) { @@ -178,33 +182,37 @@ class ImageCropper extends Component { // Darken area outside the crop zone ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; - if (this.circular) { - // Draw darkened overlay with circular cutout - ctx.beginPath(); - ctx.rect(0, 0, displayWidth, displayHeight); - const radius = cropWidth / 2; - const cx = cropX + cropWidth / 2; - const cy = cropY + cropHeight / 2; - ctx.arc(cx, cy, radius, 0, Math.PI * 2, true); - ctx.fill("evenodd"); - - // Draw circle border - ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(cx, cy, radius, 0, Math.PI * 2); - ctx.stroke(); + const shape = this.shape; + ctx.beginPath(); + ctx.rect(0, 0, displayWidth, displayHeight); + this._traceCropPath(ctx, cropX, cropY, cropWidth, cropHeight, shape); + ctx.fill("evenodd"); + + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 2; + ctx.beginPath(); + this._traceCropPath(ctx, cropX, cropY, cropWidth, cropHeight, shape); + ctx.stroke(); + } + + _traceCropPath(ctx, x, y, width, height, shape) { + if (shape === "circle") { + const radius = width / 2; + ctx.arc(x + width / 2, y + height / 2, radius, 0, Math.PI * 2); + } else if (shape === "rounded-square") { + const r = Math.min(ROUNDED_SQUARE_RADIUS, width / 2, height / 2); + ctx.moveTo(x + r, y); + ctx.lineTo(x + width - r, y); + ctx.arcTo(x + width, y, x + width, y + r, r); + ctx.lineTo(x + width, y + height - r); + ctx.arcTo(x + width, y + height, x + width - r, y + height, r); + ctx.lineTo(x + r, y + height); + ctx.arcTo(x, y + height, x, y + height - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); } else { - // Draw darkened overlay with rectangular cutout - ctx.beginPath(); - ctx.rect(0, 0, displayWidth, displayHeight); - ctx.rect(cropX, cropY, cropWidth, cropHeight); - ctx.fill("evenodd"); - - // Draw rect border - ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; - ctx.lineWidth = 2; - ctx.strokeRect(cropX, cropY, cropWidth, cropHeight); + ctx.rect(x, y, width, height); } } diff --git a/src/js/components/plugin-blob-image.js b/src/js/components/plugin-blob-image.js index a54e290b..0699af7c 100644 --- a/src/js/components/plugin-blob-image.js +++ b/src/js/components/plugin-blob-image.js @@ -1,18 +1,18 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; -import { BSKY_CDN_URL } from "/js/config.js"; +import { buildCdnUrl } from "/js/dataHelpers.js"; const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; const CID_PATTERN = /^b[a-z2-7]{20,}$/; -const CDN_PREFIXES = new Set([ - "avatar", - "avatar_thumbnail", - "banner", - "feed_thumbnail", - "feed_fullsize", -]); +function safeBuildCdnUrl(prefix, did, cid) { + try { + return buildCdnUrl(prefix, did, cid); + } catch { + return null; + } +} function isValidDid(did) { return typeof did === "string" && DID_PATTERN.test(did); @@ -22,10 +22,6 @@ function isValidCid(cid) { return typeof cid === "string" && CID_PATTERN.test(cid); } -function buildCdnUrl(prefix, did, cid) { - return `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`; -} - class PluginBlobImage extends Component { static get observedAttributes() { return ["did", "cid", "alt", "cdn-prefix"]; @@ -48,11 +44,8 @@ class PluginBlobImage extends Component { const alt = this.state.$alt.get() ?? ""; const failed = this.state.$failed.get(); const src = - !failed && - isValidDid(did) && - isValidCid(cid) && - CDN_PREFIXES.has(cdnPrefix) - ? buildCdnUrl(cdnPrefix, did, cid) + !failed && isValidDid(did) && isValidCid(cid) + ? safeBuildCdnUrl(cdnPrefix, did, cid) : null; if (src) { render( diff --git a/src/js/components/post-notifications-dialog.js b/src/js/components/post-notifications-dialog.js index 2258a3c9..1290e4b5 100644 --- a/src/js/components/post-notifications-dialog.js +++ b/src/js/components/post-notifications-dialog.js @@ -165,6 +165,11 @@ class PostNotificationsDialog extends Component { close() { return closeWithAnimation(this.querySelector(".post-notifications-dialog")); } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } } PostNotificationsDialog.register(); diff --git a/src/js/components/report-dialog.js b/src/js/components/report-dialog.js index 8a39746f..b8229868 100644 --- a/src/js/components/report-dialog.js +++ b/src/js/components/report-dialog.js @@ -831,6 +831,11 @@ class ReportDialog extends Component { close() { return closeWithAnimation(this.querySelector(".report-dialog")); } + + disconnectedCallback() { + this.scrollLock?.release(); + this.scrollLock = null; + } } ReportDialog.register(); diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 99a235e8..76cf6ae8 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -1,5 +1,9 @@ import { unique } from "/js/utils.js"; -import { FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS } from "/js/config.js"; +import { + BSKY_CDN_URL, + FOLLOWING_FEED_URI, + IN_APP_LINK_DOMAINS, +} from "/js/config.js"; export const INVALID_HANDLE = "handle.invalid"; export const MISSING_HANDLE = "missing.invalid"; @@ -16,6 +20,21 @@ export function hasValidHandle(profile) { ); } +const CDN_PREFIXES = new Set([ + "avatar", + "avatar_thumbnail", + "banner", + "feed_thumbnail", + "feed_fullsize", +]); + +export function buildCdnUrl(prefix, did, cid) { + if (!CDN_PREFIXES.has(prefix)) { + throw new Error(`Invalid CDN prefix: ${prefix}`); + } + return `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`; +} + export function avatarThumbnailUrl(avatarUrl) { if (!avatarUrl) { console.warn("avatarUrl is null"); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 9bdcd9e2..cffe108d 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -5,8 +5,9 @@ import { pinPostInFeed, unpinPostInFeed, valueForPinnedItem, + buildCdnUrl, } from "/js/dataHelpers.js"; -import { getCurrentTimestamp } from "/js/utils.js"; +import { batch, getCurrentTimestamp } from "/js/utils.js"; import { PostCreator } from "/js/postCreator.js"; import { untrack } from "/js/signals.js"; @@ -872,6 +873,7 @@ export class Mutations { } if (description !== undefined) { updatedRecord.description = description; + delete updatedRecord.descriptionFacets; } if (avatarRef) { updatedRecord.avatar = avatarRef; @@ -886,19 +888,163 @@ export class Mutations { await this.api.putProfileRecord(updatedRecord, swapCid); - const preferences = this.preferencesProvider.requirePreferences(); - const labelers = preferences.getLabelerDids(); - // Fetch full profile to get updated image urls - const updatedProfile = await this.api.getProfile(profile.did, { labelers }); - this.dataStore.$profiles.set(updatedProfile.did, updatedProfile); - this.dataStore.$detailedProfiles.set(updatedProfile.did, updatedProfile); - const currentUser = this.dataStore.$currentUser.get(); - if (currentUser && currentUser.did === updatedProfile.did) { - this.dataStore.$currentUser.set({ - ...currentUser, - ...updatedProfile, + // Update in memory + const patch = { displayName, description }; + if (avatarRef) { + patch.avatar = buildCdnUrl("avatar", profile.did, avatarRef.ref.$link); + } else if (removeAvatar) { + patch.avatar = ""; + } + if (bannerRef) { + patch.banner = buildCdnUrl("banner", profile.did, bannerRef.ref.$link); + } else if (removeBanner) { + patch.banner = ""; + } + + const existingProfile = this.dataStore.$profiles.get(profile.did); + if (existingProfile) { + this.dataStore.$profiles.set(profile.did, { + ...existingProfile, + ...patch, + }); + } + const existingDetailed = this.dataStore.$detailedProfiles.get(profile.did); + if (existingDetailed) { + this.dataStore.$detailedProfiles.set(profile.did, { + ...existingDetailed, + ...patch, }); } + const currentUser = this.dataStore.$currentUser.get(); + if (currentUser && currentUser.did === profile.did) { + this.dataStore.$currentUser.set({ ...currentUser, ...patch }); + } + } + + async updateList(list, { name, description, avatarBlob, removeAvatar }) { + const rkey = list.uri.split("/").pop(); + const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; + + const recordData = await this.api.getListRecord(rkey); + const existingRecord = recordData.value || {}; + const swapCid = recordData.cid; + + const updatedRecord = { ...existingRecord }; + if (name !== undefined) { + updatedRecord.name = name; + } + if (description !== undefined) { + updatedRecord.description = description; + delete updatedRecord.descriptionFacets; + } + if (avatarRef) { + updatedRecord.avatar = avatarRef; + } else if (removeAvatar) { + delete updatedRecord.avatar; + } + + await this.api.putListRecord(rkey, updatedRecord, swapCid); + + // Update in memory + const current = this.dataStore.$lists.get(list.uri) ?? list; + const patched = { ...current }; + if (name !== undefined) patched.name = name; + if (description !== undefined) { + patched.description = description; + patched.descriptionFacets = []; + } + if (avatarRef?.ref?.$link && list.creator?.did) { + patched.avatar = buildCdnUrl( + "avatar", + list.creator.did, + avatarRef.ref.$link, + ); + } else if (removeAvatar) { + patched.avatar = ""; + } + this.dataStore.$lists.set(list.uri, patched); + } + + async deleteList(list) { + const { rkey } = parseUri(list.uri); + const listItemUris = []; + let cursor = ""; + const MAX_PAGES = 100; + let hitCap = true; + for (let i = 0; i < MAX_PAGES; i++) { + const res = await this.api.getListItems({ cursor, limit: 100 }); + for (const record of res.records) { + if (record.value?.list === list.uri) { + listItemUris.push(record.uri); + } + } + cursor = res.cursor; + if (!cursor) { + hitCap = false; + break; + } + } + if (hitCap) { + console.warn( + `deleteList: stopped scanning listitems after ${MAX_PAGES} pages`, + ); + } + const writes = [ + ...listItemUris.map((uri) => ({ + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.listitem", + rkey: parseUri(uri).rkey, + })), + { + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.list", + rkey, + }, + ]; + for (const chunk of batch(writes, 10)) { + await this.api.applyWrites(chunk); + } + this.dataStore.$lists.set(list.uri, null); + this.dataStore.$listMembers.set(list.uri, null); + if (list.creator?.did) { + const actorLists = this.dataStore.$actorLists.get(list.creator.did); + if (actorLists) { + this.dataStore.$actorLists.set(list.creator.did, { + ...actorLists, + lists: actorLists.lists.filter((entry) => entry.uri !== list.uri), + }); + } + } + for (const [ + actorDid, + entry, + ] of this.dataStore.$listsWithMembershipByActor.entries()) { + if (!entry?.listsWithMembership) continue; + const filtered = entry.listsWithMembership.filter( + (item) => item.list.uri !== list.uri, + ); + if (filtered.length !== entry.listsWithMembership.length) { + this.dataStore.$listsWithMembershipByActor.set(actorDid, { + ...entry, + listsWithMembership: filtered, + }); + } + } + const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); + if (pinnedItems?.some((item) => item.data?.uri === list.uri)) { + this.dataStore.$pinnedItems.set( + pinnedItems.filter((item) => item.data?.uri !== list.uri), + ); + } + const preferences = this.preferencesProvider.requirePreferences(); + if (preferences.isFeedPinned(list.uri)) { + const newPreferences = preferences.unpinFeed(list.uri); + try { + await this.preferencesProvider.updatePreferences(newPreferences); + } catch (error) { + console.error(error); + } + } } async pinPost(post) { diff --git a/src/js/listInteractionHandler.js b/src/js/listInteractionHandler.js index 054d6ccb..a4020529 100644 --- a/src/js/listInteractionHandler.js +++ b/src/js/listInteractionHandler.js @@ -86,4 +86,26 @@ export class ListInteractionHandler { showToast("Failed to unblock list", { style: "error" }); } } + + async handleDeleteList(list) { + const confirmed = await confirmModal( + "This list will be permanently deleted. This action cannot be undone.", + { + title: "Delete this list?", + confirmButtonText: "Delete", + confirmButtonStyle: "danger", + }, + ); + if (!confirmed) return false; + try { + hapticsImpactMedium(); + await this.dataLayer.mutations.deleteList(list); + showToast("List deleted"); + return true; + } catch (error) { + console.error(error); + showToast("Failed to delete list", { style: "error" }); + return false; + } + } } diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index afa0e2c8..ae376291 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -16,6 +16,7 @@ import { showToast } from "/js/toasts.js"; import "/js/components/infinite-scroll-container.js"; import "/js/components/context-menu.js"; import "/js/components/context-menu-item.js"; +import "/js/components/edit-list-details-dialog.js"; class ListDetailView extends View { async render({ @@ -171,6 +172,22 @@ class ListDetailView extends View { > Copy link to list + ${listCreator?.did && + currentUser?.did && + listCreator.did === currentUser.did + ? html` handleEditList(list)} + > + Edit list details + + handleDeleteList(list)} + > + Delete list + ` + : ""} ` : null, @@ -287,6 +304,35 @@ class ListDetailView extends View { ); }); + async function handleDeleteList(list) { + const deleted = await listInteractionHandler.handleDeleteList(list); + if (!deleted) return; + const fallbackRoute = list.creator?.handle + ? `/profile/${list.creator.handle}` + : "/"; + window.router.back({ fallbackRoute }); + } + + async function handleEditList(list) { + const dialog = document.createElement("edit-list-details-dialog"); + dialog.addEventListener("list-save", async (event) => { + const { listUpdates, successCallback, errorCallback } = event.detail; + try { + await dataLayer.mutations.updateList(list, listUpdates); + showToast("List updated"); + successCallback(); + } catch (error) { + errorCallback(error); + } + }); + dialog.addEventListener("edit-list-details-closed", () => { + dialog.remove(); + }); + root.querySelector("main").appendChild(dialog); + dialog.setList(list); + dialog.open(); + } + async function loadFeed({ reload = false } = {}) { await dataLayer.requests.loadNextFeedPage( { type: "list", uri: listUri }, diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 2a94649b..6629b3af 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -1695,6 +1695,17 @@ export class MockServer { }); }); + await page.route("https://cdn.bsky.app/img/**", (route) => { + return route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }); + }); + await page.route("https://ogcard.cdn.bsky.app/**", (route) => { return route.fulfill({ status: 200, @@ -2378,6 +2389,28 @@ export class MockServer { }), }); } + if (collection === "app.bsky.graph.list") { + const repo = url.searchParams.get("repo"); + const listUri = `at://${repo}/${collection}/${rkey}`; + const list = this.lists.find((l) => l.uri === listUri); + if (list) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: listUri, + cid: list.cid || "bafyreilistrecord", + value: { + $type: "app.bsky.graph.list", + purpose: list.purpose, + name: list.name, + description: list.description || "", + createdAt: list.indexedAt || "2024-01-01T00:00:00.000Z", + }, + }), + }); + } + } return route.fulfill({ status: 404, body: "{}" }); }); @@ -2408,6 +2441,22 @@ export class MockServer { } this.profiles.set(userProfile.did, profile); } + if (collection === "app.bsky.graph.list") { + const repo = body?.repo; + const rkey = body?.rkey; + const listUri = `at://${repo}/${collection}/${rkey}`; + // Intentionally do NOT mutate the list here: the client patches its + // local list from the record it just wrote, matching real bsky + // AppView behavior (which briefly returns stale data after putRecord). + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: listUri, + cid: "bafyreiupdatedlist", + }), + }); + } return route.fulfill({ status: 200, contentType: "application/json", diff --git a/tests/e2e/specs/flows/deleteList.test.js b/tests/e2e/specs/flows/deleteList.test.js new file mode 100644 index 00000000..daa876da --- /dev/null +++ b/tests/e2e/specs/flows/deleteList.test.js @@ -0,0 +1,84 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { userProfile } from "../../testData.js"; +import { createList } from "../../../shared/factories.js"; + +test.describe("Profile → List Detail → delete flow", () => { + test("deleting a list from the list detail view removes it from the profile's Lists tab", async ({ + page, + }) => { + const profileWithLists = { + ...userProfile, + associated: { lists: 2 }, + }; + const listToDelete = createList({ + uri: `at://${userProfile.did}/app.bsky.graph.list/todelete`, + name: "Doomed List", + creatorHandle: userProfile.handle, + }); + const listToKeep = createList({ + uri: `at://${userProfile.did}/app.bsky.graph.list/tokeep`, + name: "Kept List", + creatorHandle: userProfile.handle, + }); + + const mockServer = new MockServer(); + mockServer.addProfile(profileWithLists); + mockServer.addActorLists(userProfile.did, [listToDelete, listToKeep]); + mockServer.addLists([listToDelete, listToKeep]); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.handle}`); + + const profileView = page.locator("#profile-view"); + const tabBar = profileView.locator("tab-bar"); + await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ + timeout: 10000, + }); + await tabBar.locator('[data-testid="tab-lists"]').click(); + + const feedsList = profileView.locator( + ".feed-container:not([hidden]) .feeds-list", + ); + await expect(feedsList.locator(".feeds-list-item")).toHaveCount(2, { + timeout: 10000, + }); + + await feedsList + .locator(".feeds-list-item", { hasText: "Doomed List" }) + .click(); + + await expect(page).toHaveURL( + `/profile/${userProfile.handle}/lists/todelete`, + { timeout: 10000 }, + ); + + const listView = page.locator("#list-detail-view"); + await expect( + listView.locator('[data-testid="list-detail-name"]'), + ).toContainText("Doomed List", { timeout: 10000 }); + + await listView.locator(".context-menu-button").click(); + await listView.locator('[data-testid="menu-action-list-delete"]').click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ + timeout: 10000, + }); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + await expect(page).toHaveURL(`/profile/${userProfile.handle}`, { + timeout: 10000, + }); + + const restoredFeedsList = profileView.locator( + ".feed-container:not([hidden]) .feeds-list", + ); + await expect(restoredFeedsList.locator(".feeds-list-item")).toHaveCount(1, { + timeout: 10000, + }); + await expect(restoredFeedsList).toContainText("Kept List"); + await expect(restoredFeedsList).not.toContainText("Doomed List"); + }); +}); diff --git a/tests/e2e/specs/views/listDetail.view.test.js b/tests/e2e/specs/views/listDetail.view.test.js index e428d943..8f753ed6 100644 --- a/tests/e2e/specs/views/listDetail.view.test.js +++ b/tests/e2e/specs/views/listDetail.view.test.js @@ -535,6 +535,350 @@ test.describe("List Detail view", () => { }); }); + test.describe("Edit list details", () => { + const OWN_LIST_URI = "at://did:plc:testuser123/app.bsky.graph.list/ownlist"; + + function setupOwnList(mockServer, { description } = {}) { + const list = createList({ + uri: OWN_LIST_URI, + name: "My Own List", + creatorHandle: "testuser.bsky.social", + }); + if (description !== undefined) { + list.description = description; + } + mockServer.addLists([list]); + return list; + } + + test("should not show the Edit menu item on another user's list", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/creator1.bsky.social/lists/mylist"); + + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await expect( + view.locator('[data-testid="menu-action-list-copy-link"]'), + ).toBeVisible(); + await expect( + view.locator('[data-testid="menu-action-list-edit"]'), + ).toHaveCount(0); + }); + + test("should show the Edit menu item on the current user's list", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await expect( + view.locator('[data-testid="menu-action-list-edit"]'), + ).toBeVisible(); + }); + + test("should edit list name and description and update the view", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer, { description: "Original description" }); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await expect( + view.locator('[data-testid="list-detail-name"]'), + ).toContainText("My Own List", { timeout: 10000 }); + + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-edit"]').click(); + + const dialog = page.locator("edit-list-details-dialog"); + await expect( + dialog.locator('[data-testid="edit-list-details-name"]'), + ).toBeVisible({ timeout: 10000 }); + + await dialog + .locator('[data-testid="edit-list-details-name"]') + .fill("Renamed List"); + await dialog + .locator('[data-testid="edit-list-details-description"]') + .fill("Updated description"); + + await dialog + .locator('[data-testid="edit-list-details-save-button"]') + .click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + await expect( + view.locator('[data-testid="list-detail-name"]'), + ).toContainText("Renamed List", { timeout: 10000 }); + await expect( + view.locator('[data-testid="list-detail-description"]'), + ).toContainText("Updated description"); + }); + + test("updates the description in place when only the description is edited", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer, { description: "Original description" }); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + const descriptionEl = view.locator( + '[data-testid="list-detail-description"]', + ); + await expect(descriptionEl).toHaveText("Original description", { + timeout: 10000, + }); + + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-edit"]').click(); + + const dialog = page.locator("edit-list-details-dialog"); + const descriptionInput = dialog.locator( + '[data-testid="edit-list-details-description"]', + ); + await expect(descriptionInput).toHaveValue("Original description", { + timeout: 10000, + }); + + await descriptionInput.fill("Brand new description"); + await dialog + .locator('[data-testid="edit-list-details-save-button"]') + .click(); + + // The dialog closes and the on-page description reflects the edit. + await expect(dialog).toHaveCount(0, { timeout: 10000 }); + await expect(descriptionEl).toHaveText("Brand new description", { + timeout: 10000, + }); + await expect(descriptionEl).not.toContainText("Original description"); + }); + + test("updates the on-page avatar when a new avatar is uploaded", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + const avatarImg = view.locator(".list-detail-avatar"); + await expect(avatarImg).toHaveAttribute( + "src", + "/img/list-avatar-fallback.svg", + { timeout: 10000 }, + ); + + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-edit"]').click(); + + const dialog = page.locator("edit-list-details-dialog"); + await expect( + dialog.locator('[data-testid="edit-list-details-name"]'), + ).toBeVisible({ timeout: 10000 }); + + // Upload a tiny in-memory PNG via the hidden file input, then apply + // the crop and save. + await dialog.locator("input.edit-list-details-file-input").setInputFiles({ + name: "avatar.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }); + + await expect(dialog.locator("image-cropper")).toBeVisible({ + timeout: 10000, + }); + await dialog + .locator('[data-testid="edit-list-details-crop-apply-button"]') + .click(); + await dialog + .locator('[data-testid="edit-list-details-save-button"]') + .click(); + + // After save the dialog closes and the on-page avatar is a CDN URL + // constructed from the returned blob ref + list-owner DID. + await expect(dialog).toHaveCount(0, { timeout: 10000 }); + await expect(avatarImg).toHaveAttribute( + "src", + /^https:\/\/cdn\.bsky\.app\/img\/avatar\/plain\/did:plc:testuser123\/bafkreimockblob[a-j]+@jpeg$/, + { timeout: 10000 }, + ); + }); + + test("should not show the Delete menu item on another user's list", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/creator1.bsky.social/lists/mylist"); + + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await expect( + view.locator('[data-testid="menu-action-list-copy-link"]'), + ).toBeVisible(); + await expect( + view.locator('[data-testid="menu-action-list-delete"]'), + ).toHaveCount(0); + }); + + test("should show the Delete menu item on the current user's list", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await expect( + view.locator('[data-testid="menu-action-list-delete"]'), + ).toBeVisible(); + }); + + test("cancelling the delete confirmation keeps the list on the page", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-delete"]').click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ + timeout: 10000, + }); + await page.locator('[data-testid="modal-cancel-button"]').click(); + + await expect(page).toHaveURL( + /\/profile\/testuser\.bsky\.social\/lists\/ownlist/, + ); + await expect( + view.locator('[data-testid="list-detail-name"]'), + ).toContainText("My Own List"); + }); + + test("confirming the delete removes the list and navigates away", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await expect( + view.locator('[data-testid="list-detail-name"]'), + ).toContainText("My Own List", { timeout: 10000 }); + + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-delete"]').click(); + + await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({ + timeout: 10000, + }); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + await expect(page).not.toHaveURL( + /\/profile\/testuser\.bsky\.social\/lists\/ownlist/, + { timeout: 10000 }, + ); + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + + const applyWritesCalls = mockServer.applyWritesCalls; + const flat = applyWritesCalls.flat(); + const listDeletes = flat.filter( + (write) => + write.$type === "com.atproto.repo.applyWrites#delete" && + write.collection === "app.bsky.graph.list" && + write.rkey === "ownlist", + ); + expect(listDeletes.length).toBe(1); + }); + + test("save button is disabled until a field changes and re-disabled when name is empty", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupOwnList(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/lists/ownlist"); + + const view = page.locator("#list-detail-view"); + await expect(view.locator(".context-menu-button")).toBeVisible({ + timeout: 10000, + }); + await view.locator(".context-menu-button").click(); + await view.locator('[data-testid="menu-action-list-edit"]').click(); + + const dialog = page.locator("edit-list-details-dialog"); + const saveButton = dialog.locator( + '[data-testid="edit-list-details-save-button"]', + ); + await expect(saveButton).toBeDisabled({ timeout: 10000 }); + + await dialog + .locator('[data-testid="edit-list-details-name"]') + .fill("Changed"); + await expect(saveButton).toBeEnabled(); + + await dialog.locator('[data-testid="edit-list-details-name"]').fill(""); + await expect(saveButton).toBeDisabled(); + }); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, diff --git a/tests/unit/specs/components/image-cropper.test.js b/tests/unit/specs/components/image-cropper.test.js index 4fe80f6b..72c14d36 100644 --- a/tests/unit/specs/components/image-cropper.test.js +++ b/tests/unit/specs/components/image-cropper.test.js @@ -77,17 +77,31 @@ describe("image-cropper", () => { assert.deepEqual(element.aspectRatio, 1); }); - it("should accept circular attribute", () => { + it("should accept a circle shape", () => { const element = document.createElement("image-cropper"); - element.setAttribute("circular", ""); + element.setAttribute("shape", "circle"); connectElement(element); - assert.deepEqual(element.circular, true); + assert.deepEqual(element.shape, "circle"); }); - it("should not be circular by default", () => { + it("should accept a rounded-square shape", () => { const element = document.createElement("image-cropper"); + element.setAttribute("shape", "rounded-square"); connectElement(element); - assert.deepEqual(element.circular, false); + assert.deepEqual(element.shape, "rounded-square"); + }); + + it("should default to square when no shape is set", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + assert.deepEqual(element.shape, "square"); + }); + + it("should fall back to square when shape is unknown", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("shape", "bogus"); + connectElement(element); + assert.deepEqual(element.shape, "square"); }); it("should initialize with default state", () => { diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 983fb7e1..39dc7c32 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -938,54 +938,66 @@ describe("updateProfile", () => { assert.deepEqual(uploadBlobCallCount, 1); }); - it("should update dataStore with the fetched profile on success", async () => { - const mockApi = makeMockApi({ - getProfile: async (did) => ({ - did, - displayName: "Updated Name", - description: "Updated bio", - avatar: "https://example.com/new-avatar.jpg", - viewer: {}, - }), - }); - - const { mutations, dataStore } = createMutationsWithMockApi(mockApi); + it("patches $profiles in place with the new displayName and description", async () => { + // The appview lags briefly after putRecord, so we patch locally rather + // than round-tripping through getProfile (which can return stale data). + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); await mutations.updateProfile(testProfile, { - displayName: "Updated Name", - description: "Updated bio", + displayName: "Patched Name", + description: "Patched bio", }); const updatedProfile = dataStore.$profiles.get(testProfile.did); - assert.deepEqual(updatedProfile.displayName, "Updated Name"); - assert.deepEqual(updatedProfile.description, "Updated bio"); - assert.deepEqual( - updatedProfile.avatar, - "https://example.com/new-avatar.jpg", - ); + assert.deepEqual(updatedProfile.displayName, "Patched Name"); + assert.deepEqual(updatedProfile.description, "Patched bio"); + // Non-patched fields survive. + assert.deepEqual(updatedProfile.did, testProfile.did); }); - it("should fetch profile with labelers after updating", async () => { - let getProfileArgs = null; + it("sets the local avatar and banner to CDN URLs built from the uploaded refs", async () => { + let uploadCallIndex = 0; const mockApi = makeMockApi({ - getProfile: async (did, options) => { - getProfileArgs = { did, options }; + uploadBlob: async () => { + uploadCallIndex++; return { - did, - displayName: "Fetched", - description: "Fetched", - viewer: {}, + ref: { $link: `bafkreiblob${uploadCallIndex}` }, + mimeType: "image/jpeg", + size: 100, }; }, }); - const { mutations } = createMutationsWithMockApi(mockApi); + const { mutations, dataStore } = createMutationsWithMockApi(mockApi); await mutations.updateProfile(testProfile, { - displayName: "New Name", - description: "New bio", + displayName: "Name", + description: "Bio", + avatarBlob: new Blob(["a"], { type: "image/jpeg" }), + bannerBlob: new Blob(["b"], { type: "image/jpeg" }), + }); + + const updatedProfile = dataStore.$profiles.get(testProfile.did); + assert.match( + updatedProfile.avatar, + /^https:\/\/cdn\.bsky\.app\/img\/avatar\/plain\/did:plc:test123\/bafkreiblob\d+@jpeg$/, + ); + assert.match( + updatedProfile.banner, + /^https:\/\/cdn\.bsky\.app\/img\/banner\/plain\/did:plc:test123\/bafkreiblob\d+@jpeg$/, + ); + }); + + it("clears the local avatar and banner when removeAvatar/removeBanner are true", async () => { + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); + await mutations.updateProfile(testProfile, { + displayName: "Name", + description: "Bio", + removeAvatar: true, + removeBanner: true, }); - assert.deepEqual(getProfileArgs.did, testProfile.did); - assert.deepEqual(Array.isArray(getProfileArgs.options.labelers), true); + const updatedProfile = dataStore.$profiles.get(testProfile.did); + assert.equal(updatedProfile.avatar, ""); + assert.equal(updatedProfile.banner, ""); }); it("should rethrow non-400 errors from getProfileRecord", async () => { @@ -1008,16 +1020,7 @@ describe("updateProfile", () => { }); it("should update currentUser when editing own profile", async () => { - const mockApi = makeMockApi({ - getProfile: async (did) => ({ - did, - displayName: "Updated User", - description: "Updated bio", - viewer: {}, - }), - }); - - const { mutations, dataStore } = createMutationsWithMockApi(mockApi); + const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); await mutations.updateProfile(testProfile, { displayName: "Updated User", description: "Updated bio", @@ -1025,6 +1028,7 @@ describe("updateProfile", () => { const currentUser = dataStore.$currentUser.get(); assert.deepEqual(currentUser.displayName, "Updated User"); + assert.deepEqual(currentUser.description, "Updated bio"); }); }); @@ -3705,26 +3709,25 @@ describe("$detailedProfiles mirroring", () => { return { mutations, dataStore }; } - it("updateProfile writes the fetched detailed profile to both stores", async () => { - const fetched = { - did: targetDid, - displayName: "Updated Name", - description: "Updated bio", - pinnedPost: { uri: "at://newpinned" }, - viewer: {}, - }; + it("updateProfile patches both $profiles and $detailedProfiles in place", async () => { const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid" }), putProfileRecord: async () => ({}), - getProfile: async () => fetched, }; const { mutations, dataStore } = setup(mockApi); await mutations.updateProfile(baseProfile, { displayName: "Updated Name", description: "Updated bio", }); - assert.deepEqual(dataStore.$profiles.get(targetDid), fetched); - assert.deepEqual(dataStore.$detailedProfiles.get(targetDid), fetched); + const patchedBasic = dataStore.$profiles.get(targetDid); + assert.deepEqual(patchedBasic.displayName, "Updated Name"); + assert.deepEqual(patchedBasic.description, "Updated bio"); + assert.deepEqual(patchedBasic.followersCount, 10); + // Detailed-only fields survive on the detailed entry. + const patchedDetailed = dataStore.$detailedProfiles.get(targetDid); + assert.deepEqual(patchedDetailed.displayName, "Updated Name"); + assert.deepEqual(patchedDetailed.description, "Updated bio"); + assert.deepEqual(patchedDetailed.pinnedPost.uri, "at://pinned"); }); it("followProfile mirrors viewer.following and count into $detailedProfiles", async () => { @@ -3874,3 +3877,465 @@ describe("$detailedProfiles mirroring", () => { ); }); }); + +describe("updateList", () => { + const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; + const testList = { + uri: listUri, + cid: "listcid", + name: "Old Name", + description: "Old description", + purpose: "app.bsky.graph.defs#curatelist", + creator: { did: "did:plc:test123", handle: "test.bsky.social" }, + viewer: {}, + }; + + function setup(overrides = {}) { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const preferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$lists.set(listUri, testList); + const api = { + getListRecord: async () => ({ + value: { + purpose: "app.bsky.graph.defs#curatelist", + name: "Old Name", + description: "Old description", + createdAt: "2024-01-01T00:00:00.000Z", + }, + cid: "listcid", + }), + putListRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "list-avatar-blob" }, + mimeType: "image/jpeg", + size: 100, + }), + ...overrides, + }; + return { + api, + dataStore, + mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), + }; + } + + it("puts the record with merged fields and the swapCid from getListRecord", async () => { + let putArgs = null; + const { mutations } = setup({ + putListRecord: async (rkey, record, swapCid) => { + putArgs = { rkey, record, swapCid }; + return {}; + }, + }); + + await mutations.updateList(testList, { + name: "New Name", + description: "New description", + }); + + assert.equal(putArgs.rkey, "mylist"); + assert.equal(putArgs.record.name, "New Name"); + assert.equal(putArgs.record.description, "New description"); + assert.equal(putArgs.record.purpose, "app.bsky.graph.defs#curatelist"); + assert.equal(putArgs.record.createdAt, "2024-01-01T00:00:00.000Z"); + assert.equal(putArgs.swapCid, "listcid"); + }); + + it("uploads and sets the avatar blob when provided", async () => { + let uploadCalled = false; + let putRecord = null; + const { mutations } = setup({ + uploadBlob: async () => { + uploadCalled = true; + return { + ref: { $link: "avatar-blob" }, + mimeType: "image/jpeg", + size: 100, + }; + }, + putListRecord: async (rkey, record) => { + putRecord = record; + return {}; + }, + }); + + await mutations.updateList(testList, { + name: "Name", + description: "Desc", + avatarBlob: new Blob(["x"], { type: "image/jpeg" }), + }); + + assert.equal(uploadCalled, true); + assert.deepEqual(putRecord.avatar, { + ref: { $link: "avatar-blob" }, + mimeType: "image/jpeg", + size: 100, + }); + }); + + it("strips stale descriptionFacets from the put record when description changes", async () => { + let putRecord = null; + const { mutations } = setup({ + getListRecord: async () => ({ + value: { + purpose: "app.bsky.graph.defs#curatelist", + name: "Old Name", + description: "Old description", + descriptionFacets: [ + { index: { byteStart: 0, byteEnd: 3 }, features: [] }, + ], + createdAt: "2024-01-01T00:00:00.000Z", + }, + cid: "listcid", + }), + putListRecord: async (rkey, record) => { + putRecord = record; + return {}; + }, + }); + + await mutations.updateList(testList, { + name: "Old Name", + description: "Totally different text", + }); + + assert.equal("descriptionFacets" in putRecord, false); + }); + + it("clears local descriptionFacets when description changes", async () => { + const { mutations, dataStore } = setup(); + dataStore.$lists.set(listUri, { + ...testList, + descriptionFacets: [ + { index: { byteStart: 0, byteEnd: 3 }, features: [] }, + ], + }); + + await mutations.updateList(testList, { + name: "Old Name", + description: "Totally different text", + }); + + assert.deepEqual(dataStore.$lists.get(listUri).descriptionFacets, []); + }); + + it("deletes the avatar from the record when removeAvatar is true", async () => { + let putRecord = null; + const { mutations } = setup({ + getListRecord: async () => ({ + value: { + purpose: "app.bsky.graph.defs#curatelist", + name: "Old Name", + description: "Old description", + avatar: { ref: { $link: "existing-avatar" } }, + createdAt: "2024-01-01T00:00:00.000Z", + }, + cid: "listcid", + }), + putListRecord: async (rkey, record) => { + putRecord = record; + return {}; + }, + }); + + await mutations.updateList(testList, { + name: "Name", + description: "Desc", + removeAvatar: true, + }); + + assert.equal("avatar" in putRecord, false); + }); + + it("patches dataStore.$lists in place with the new name and description", async () => { + // The appview lags briefly after putRecord, so we patch locally rather + // than round-tripping through getList (which can return stale data). + const { mutations, dataStore } = setup(); + + await mutations.updateList(testList, { + name: "Patched Name", + description: "Patched description", + }); + + const updated = dataStore.$lists.get(listUri); + assert.equal(updated.name, "Patched Name"); + assert.equal(updated.description, "Patched description"); + // Non-patched fields survive. + assert.equal(updated.uri, listUri); + assert.equal(updated.purpose, "app.bsky.graph.defs#curatelist"); + }); + + it("sets the local avatar to a CDN URL built from the uploaded blob ref", async () => { + const { mutations, dataStore } = setup({ + uploadBlob: async () => ({ + ref: { $link: "bafkreiavatarcid" }, + mimeType: "image/jpeg", + size: 100, + }), + }); + + await mutations.updateList(testList, { + name: "Name", + description: "Desc", + avatarBlob: new Blob(["x"], { type: "image/jpeg" }), + }); + + assert.equal( + dataStore.$lists.get(listUri).avatar, + "https://cdn.bsky.app/img/avatar/plain/did:plc:test123/bafkreiavatarcid@jpeg", + ); + }); + + it("clears the local avatar when removeAvatar is true", async () => { + const { mutations, dataStore } = setup(); + dataStore.$lists.set(listUri, { + ...testList, + avatar: "https://example.com/list-avatar.jpg", + }); + + await mutations.updateList(testList, { + name: "Name", + description: "Desc", + removeAvatar: true, + }); + + assert.equal(dataStore.$lists.get(listUri).avatar, ""); + }); +}); + +describe("deleteList", () => { + const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; + const otherListUri = "at://did:plc:test123/app.bsky.graph.list/other"; + const testList = { + uri: listUri, + cid: "listcid", + name: "My List", + purpose: "app.bsky.graph.defs#curatelist", + creator: { did: "did:plc:test123", handle: "test.bsky.social" }, + viewer: {}, + }; + + function setup({ listItems = [], overrides = {} } = {}) { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const preferences = Preferences.createLoggedOutPreferences(); + const preferencesProvider = { + requirePreferences: () => preferences, + updatePreferences: async () => {}, + }; + dataStore.$lists.set(listUri, testList); + const api = { + getListItems: async () => ({ records: listItems, cursor: "" }), + applyWrites: async () => ({}), + ...overrides, + }; + return { + api, + dataStore, + preferencesProvider, + mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), + }; + } + + it("deletes the list record via applyWrites and clears the local list", async () => { + const writesCalls = []; + const { mutations, dataStore } = setup({ + overrides: { + applyWrites: async (writes) => { + writesCalls.push(writes); + return {}; + }, + }, + }); + + await mutations.deleteList(testList); + + assert.equal(writesCalls.length, 1); + assert.deepEqual(writesCalls[0], [ + { + $type: "com.atproto.repo.applyWrites#delete", + collection: "app.bsky.graph.list", + rkey: "mylist", + }, + ]); + assert.equal(dataStore.$lists.get(listUri), null); + }); + + it("also deletes listitems belonging to this list, ignoring items in other lists", async () => { + const writesCalls = []; + const listItems = [ + { + uri: "at://did:plc:test123/app.bsky.graph.listitem/keep", + value: { list: otherListUri }, + }, + { + uri: "at://did:plc:test123/app.bsky.graph.listitem/item1", + value: { list: listUri }, + }, + { + uri: "at://did:plc:test123/app.bsky.graph.listitem/item2", + value: { list: listUri }, + }, + ]; + const { mutations } = setup({ + listItems, + overrides: { + applyWrites: async (writes) => { + writesCalls.push(writes); + return {}; + }, + }, + }); + + await mutations.deleteList(testList); + + const flat = writesCalls.flat(); + const deletedRkeys = flat + .filter((w) => w.collection === "app.bsky.graph.listitem") + .map((w) => w.rkey); + assert.deepEqual(deletedRkeys.sort(), ["item1", "item2"]); + assert.equal( + flat.filter((w) => w.collection === "app.bsky.graph.list").length, + 1, + ); + }); + + it("chunks applyWrites into batches of 10", async () => { + const listItems = Array.from({ length: 25 }, (_, i) => ({ + uri: `at://did:plc:test123/app.bsky.graph.listitem/item${i}`, + value: { list: listUri }, + })); + const writesCalls = []; + const { mutations } = setup({ + listItems, + overrides: { + applyWrites: async (writes) => { + writesCalls.push(writes); + return {}; + }, + }, + }); + + await mutations.deleteList(testList); + + // 25 items + 1 list record = 26 writes → 10, 10, 6 + assert.deepEqual( + writesCalls.map((c) => c.length), + [10, 10, 6], + ); + }); + + it("clears cached list members for the deleted list", async () => { + const { mutations, dataStore } = setup(); + dataStore.$listMembers.set(listUri, { + items: [{ uri: "at://x", subject: { did: "did:plc:m1" } }], + cursor: "", + }); + + await mutations.deleteList(testList); + + assert.equal(dataStore.$listMembers.get(listUri), null); + }); + + it("removes the list from $actorLists for the creator", async () => { + const { mutations, dataStore } = setup(); + const otherList = { uri: otherListUri, name: "Other" }; + dataStore.$actorLists.set(testList.creator.did, { + lists: [testList, otherList], + cursor: "", + }); + + await mutations.deleteList(testList); + + const remaining = dataStore.$actorLists.get(testList.creator.did); + assert.deepEqual( + remaining.lists.map((entry) => entry.uri), + [otherListUri], + ); + }); + + it("removes the list from cached $listsWithMembershipByActor entries", async () => { + const { mutations, dataStore } = setup(); + dataStore.$listsWithMembershipByActor.set("did:plc:member1", { + listsWithMembership: [ + { list: { uri: listUri }, listItem: { uri: "at://li1" } }, + { list: { uri: otherListUri }, listItem: { uri: "at://li2" } }, + ], + cursor: "", + }); + dataStore.$listsWithMembershipByActor.set("did:plc:untouched", { + listsWithMembership: [ + { list: { uri: otherListUri }, listItem: { uri: "at://li3" } }, + ], + cursor: "", + }); + + await mutations.deleteList(testList); + + assert.deepEqual( + dataStore.$listsWithMembershipByActor + .get("did:plc:member1") + .listsWithMembership.map((entry) => entry.list.uri), + [otherListUri], + ); + // Unrelated entries are untouched. + assert.equal( + dataStore.$listsWithMembershipByActor.get("did:plc:untouched") + .listsWithMembership.length, + 1, + ); + }); + + it("removes the list from $pinnedItems if present", async () => { + const { mutations, dataStore } = setup(); + dataStore.$pinnedItems.set([ + { type: "timeline", data: { uri: "following" } }, + { type: "list", data: { uri: listUri, displayName: "My List" } }, + { type: "list", data: { uri: otherListUri, displayName: "Other" } }, + ]); + + await mutations.deleteList(testList); + + assert.deepEqual( + dataStore.$pinnedItems.get().map((item) => item.data.uri), + ["following", otherListUri], + ); + }); + + it("unpins the list if it was pinned", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + let preferences = Preferences.createLoggedOutPreferences().pinFeed( + listUri, + "list", + ); + const updateCalls = []; + const preferencesProvider = { + requirePreferences: () => preferences, + updatePreferences: async (next) => { + updateCalls.push(next); + preferences = next; + }, + }; + dataStore.$lists.set(listUri, testList); + const api = { + getListItems: async () => ({ records: [], cursor: "" }), + applyWrites: async () => ({}), + }; + const mutations = makeMutations( + api, + dataStore, + patchStore, + preferencesProvider, + ); + + assert.equal(preferences.isFeedPinned(listUri), true); + await mutations.deleteList(testList); + + assert.equal(updateCalls.length, 1); + assert.equal(preferences.isFeedPinned(listUri), false); + }); +}); -- 2.51.2