Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Enable unpin feeds and update feed sidebar icon

Grace Kind (Jul 16, 2026, 5:40 PM -0500) 6594bfaf 19a828b6

+757 -104
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.189", 3 + "version": "0.17.190", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+47 -27
src/css/style.css
··· 552 552 } 553 553 } 554 554 555 - .inbox-button { 556 - position: relative; 555 + .header-icon-button { 557 556 display: flex; 558 557 align-items: center; 559 558 justify-content: center; 559 + background: transparent; 560 + border: none; 561 + padding: 0; 560 562 cursor: pointer; 561 563 color: var(--text-color-muted); 562 - height: 30px; 563 - width: 30px; 564 564 border-radius: var(--icon-button-border-radius); 565 565 transition: background-color 0.2s; 566 566 } 567 567 568 568 @media (hover: hover) { 569 - .inbox-button:hover { 569 + .header-icon-button:not(:disabled):hover { 570 570 background-color: var(--back-button-hover-color); 571 571 } 572 572 } 573 573 574 + .header-icon-button:disabled { 575 + opacity: 0.5; 576 + cursor: default; 577 + } 578 + 579 + .inbox-button { 580 + position: relative; 581 + height: 30px; 582 + width: 30px; 583 + } 584 + 574 585 .inbox-button .icon { 575 586 width: 24px; 576 587 height: 24px; 577 588 } 578 589 579 590 .feeds-button { 580 - display: flex; 581 - align-items: center; 582 - justify-content: center; 583 - cursor: pointer; 584 - color: var(--text-color-muted); 585 591 height: 30px; 586 592 width: 30px; 587 - border-radius: var(--icon-button-border-radius); 588 - transition: background-color 0.2s; 589 - } 590 - 591 - @media (hover: hover) { 592 - .feeds-button:hover { 593 - background-color: var(--back-button-hover-color); 594 - } 595 593 } 596 594 597 595 .feeds-button .icon { ··· 5617 5615 .feeds-list-item { 5618 5616 display: flex; 5619 5617 align-items: flex-start; 5620 - gap: 12px; 5621 5618 padding: 16px; 5622 5619 border-bottom: var(--hair) solid var(--post-border-color); 5623 5620 text-decoration: none; ··· 5648 5645 .feeds-list-item-content { 5649 5646 flex: 1; 5650 5647 min-width: 0; 5648 + padding-left: 12px; 5651 5649 } 5652 5650 5653 5651 .feeds-list-item-title { ··· 5660 5658 .feeds-list-item-creator { 5661 5659 font-size: 14px; 5662 5660 color: var(--text-color-muted); 5663 - margin-bottom: 6px; 5664 5661 white-space: nowrap; 5665 5662 overflow: hidden; 5666 5663 text-overflow: ellipsis; ··· 5689 5686 user-select: none; 5690 5687 } 5691 5688 5689 + .feeds-list-item-unpin-button { 5690 + flex-shrink: 0; 5691 + width: 40px; 5692 + height: 40px; 5693 + display: flex; 5694 + align-items: center; 5695 + justify-content: center; 5696 + background: transparent; 5697 + border: none; 5698 + padding: 0; 5699 + color: var(--highlight-color); 5700 + cursor: pointer; 5701 + } 5702 + 5703 + .feeds-list-item-unpin-button:disabled { 5704 + cursor: default; 5705 + opacity: 0.5; 5706 + } 5707 + 5708 + .feeds-list-item-unpin-button svg { 5709 + width: 20px; 5710 + height: 20px; 5711 + } 5712 + 5692 5713 .feeds-list-item-drag-handle:active { 5693 5714 cursor: grabbing; 5694 5715 } ··· 5728 5749 } 5729 5750 5730 5751 .header-edit-button { 5731 - background: transparent; 5732 - border: none; 5733 5752 padding: 8px; 5734 - display: flex; 5735 - align-items: center; 5736 - justify-content: center; 5737 - cursor: pointer; 5738 - color: var(--text-color-muted); 5739 5753 } 5740 5754 5741 5755 .header-edit-button svg { 5742 5756 width: 24px; 5743 5757 height: 24px; 5758 + } 5759 + 5760 + .new-chat-button:disabled { 5761 + opacity: 0.5; 5762 + cursor: default; 5744 5763 } 5745 5764 5746 5765 .header svg { ··· 5853 5872 5854 5873 .feeds-list-item-skeleton { 5855 5874 pointer-events: none; 5875 + min-height: 75px; 5856 5876 } 5857 5877 5858 5878 .feeds-list-item-skeleton-avatar {
+1
src/img/icons/custom/hashtag-solid.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16"/></svg>
+6 -9
src/js/dataLayer/mutations.js
··· 435 435 } 436 436 } 437 437 438 - async reorderPinnedItems(orderedValues) { 438 + async setPinnedItems(values) { 439 439 const preferences = this.preferencesProvider.requirePreferences(); 440 - const newPreferences = preferences.reorderPinnedItems(orderedValues); 440 + const newPreferences = preferences.setPinnedItems(values); 441 441 try { 442 442 await this.preferencesProvider.updatePreferences(newPreferences); 443 443 } catch (error) { ··· 448 448 // Update pinned items in memory 449 449 const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); 450 450 if (pinnedItems) { 451 - const indexOf = (item) => { 452 - const idx = orderedValues.indexOf(valueForPinnedItem(item)); 453 - return idx === -1 ? orderedValues.length : idx; 454 - }; 455 - const reordered = [...pinnedItems].sort( 456 - (a, b) => indexOf(a) - indexOf(b), 451 + const byValue = new Map( 452 + pinnedItems.map((item) => [valueForPinnedItem(item), item]), 457 453 ); 458 - this.dataStore.$pinnedItems.set(reordered); 454 + const next = values.map((value) => byValue.get(value)).filter(Boolean); 455 + this.dataStore.$pinnedItems.set(next); 459 456 } 460 457 } 461 458
+11 -6
src/js/preferences.js
··· 135 135 return clone; 136 136 } 137 137 138 - reorderPinnedItems(orderedValues) { 138 + setPinnedItems(values) { 139 139 const clone = this.clone(); 140 140 const savedFeedsPreference = Preferences.getSavedFeedsPreference(clone.obj); 141 141 if (!savedFeedsPreference) { 142 142 throw new Error("Saved feeds preference not found"); 143 143 } 144 + const valueSet = new Set(values); 145 + for (const item of savedFeedsPreference.items) { 146 + item.pinned = valueSet.has(item.value); 147 + } 144 148 const byValue = new Map( 145 149 savedFeedsPreference.items.map((item) => [item.value, item]), 146 150 ); 147 - const reordered = orderedValues 148 - .map((value) => byValue.get(value)) 149 - .filter((item) => item && item.pinned); 150 - const unpinned = savedFeedsPreference.items.filter((item) => !item.pinned); 151 - savedFeedsPreference.items = [...reordered, ...unpinned]; 151 + const reordered = values.map((value) => byValue.get(value)).filter(Boolean); 152 + const reorderedSet = new Set(reordered); 153 + const rest = savedFeedsPreference.items.filter( 154 + (item) => !reorderedSet.has(item), 155 + ); 156 + savedFeedsPreference.items = [...reordered, ...rest]; 152 157 return clone; 153 158 } 154 159
+89
src/js/signals.js
··· 310 310 } 311 311 } 312 312 313 + export class SignalArray { 314 + __debugName = "<SignalArray>"; 315 + 316 + #collection = new Signal.State(null, { equals: () => false }); 317 + #array; 318 + 319 + constructor(values = []) { 320 + this.#array = [...values]; 321 + } 322 + 323 + get length() { 324 + this.#collection.get(); 325 + return this.#array.length; 326 + } 327 + 328 + at(index) { 329 + this.#collection.get(); 330 + return this.#array.at(index); 331 + } 332 + 333 + indexOf(value, fromIndex) { 334 + this.#collection.get(); 335 + return this.#array.indexOf(value, fromIndex); 336 + } 337 + 338 + includes(value, fromIndex) { 339 + this.#collection.get(); 340 + return this.#array.includes(value, fromIndex); 341 + } 342 + 343 + slice(start, end) { 344 + this.#collection.get(); 345 + return this.#array.slice(start, end); 346 + } 347 + 348 + map(fn, thisArg) { 349 + this.#collection.get(); 350 + return this.#array.map(fn, thisArg); 351 + } 352 + 353 + filter(fn, thisArg) { 354 + this.#collection.get(); 355 + return this.#array.filter(fn, thisArg); 356 + } 357 + 358 + forEach(callback, thisArg) { 359 + this.#collection.get(); 360 + this.#array.forEach(callback, thisArg); 361 + } 362 + 363 + [Symbol.iterator]() { 364 + this.#collection.get(); 365 + return this.#array[Symbol.iterator](); 366 + } 367 + 368 + set(index, value) { 369 + this.#array[index] = value; 370 + this.#collection.set(null); 371 + } 372 + 373 + push(...values) { 374 + this.#array.push(...values); 375 + this.#collection.set(null); 376 + return this.#array.length; 377 + } 378 + 379 + pop() { 380 + const value = this.#array.pop(); 381 + this.#collection.set(null); 382 + return value; 383 + } 384 + 385 + splice(start, deleteCount, ...items) { 386 + const removed = this.#array.splice(start, deleteCount, ...items); 387 + this.#collection.set(null); 388 + return removed; 389 + } 390 + 391 + replace(values) { 392 + this.#array = [...values]; 393 + this.#collection.set(null); 394 + } 395 + 396 + clear() { 397 + this.#array.length = 0; 398 + this.#collection.set(null); 399 + } 400 + } 401 + 313 402 export class ComputedMap { 314 403 __debugName = "<ComputedMap>"; 315 404
+2 -2
src/js/templates/icons/feedIcon.template.js src/js/templates/icons/listIcon.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 2 import { classnames } from "/js/utils.js"; 3 3 4 - export function feedIconTemplate({ filled = false } = {}) { 5 - return html`<div class=${classnames("icon feed-icon", { filled })}> 4 + export function listIconTemplate({ filled = false } = {}) { 5 + return html`<div class=${classnames("icon list-icon", { filled })}> 6 6 ${filled 7 7 ? html`<svg 8 8 xmlns="http://www.w3.org/2000/svg"
+31 -12
src/js/templates/icons/hashtagIcon.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 + import { classnames } from "/js/utils.js"; 2 3 3 - // Source: src/img/icons/custom/hashtag-line.svg 4 - export function hashtagIconTemplate() { 5 - return html`<div class="icon hashtag-icon"> 6 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> 7 - <path 8 - stroke="currentColor" 9 - stroke-linecap="round" 10 - stroke-linejoin="round" 11 - stroke-width="2" 12 - d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16" 13 - /> 14 - </svg> 4 + // Source: src/img/icons/custom/hashtag-line.svg, src/img/icons/custom/hashtag-solid.svg 5 + export function hashtagIconTemplate({ filled = false } = {}) { 6 + return html`<div class=${classnames("icon hashtag-icon", { filled })}> 7 + ${filled 8 + ? html`<svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + viewBox="0 0 24 24" 11 + fill="none" 12 + > 13 + <path 14 + stroke="currentColor" 15 + stroke-linecap="round" 16 + stroke-linejoin="round" 17 + stroke-width="3" 18 + d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16" 19 + /> 20 + </svg>` 21 + : html`<svg 22 + xmlns="http://www.w3.org/2000/svg" 23 + viewBox="0 0 24 24" 24 + fill="none" 25 + > 26 + <path 27 + stroke="currentColor" 28 + stroke-linecap="round" 29 + stroke-linejoin="round" 30 + stroke-width="2" 31 + d="M4 8h16M4 16h16M9 4 7 20M17 4l-2 16" 32 + /> 33 + </svg>`} 15 34 </div>`; 16 35 }
+2 -2
src/js/templates/sidebar.template.js
··· 12 12 import { chatIconTemplate } from "/js/templates/icons/chatIcon.template.js"; 13 13 import { settingsIconTemplate } from "/js/templates/icons/settingsIcon.template.js"; 14 14 import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; 15 - import { feedIconTemplate } from "/js/templates/icons/feedIcon.template.js"; 15 + import { hashtagIconTemplate } from "/js/templates/icons/hashtagIcon.template.js"; 16 16 import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; 17 17 import { avatarTemplate } from "/js/templates/avatar.template.js"; 18 18 import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; ··· 197 197 }, 198 198 { 199 199 id: "feeds", 200 - icon: feedIconTemplate, 200 + icon: hashtagIconTemplate, 201 201 label: "Feeds", 202 202 url: "/feeds", 203 203 },
+1 -1
src/js/views/chat.view.js
··· 136 136 function inboxButtonTemplate({ hasUnreadRequests }) { 137 137 return html` 138 138 <a 139 - class="inbox-button" 139 + class="header-icon-button inbox-button" 140 140 href="/messages/inbox" 141 141 aria-label=${hasUnreadRequests ? "Requests (unread)" : "Requests"} 142 142 data-testid="inbox-button"
+68 -29
src/js/views/feeds.view.js
··· 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 6 import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGeneratorListItemSkeleton.template.js"; 7 7 import { menuIconTemplate } from "/js/templates/icons/menuIcon.template.js"; 8 + import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 8 9 import { settingsIconTemplate } from "/js/templates/icons/settingsIcon.template.js"; 9 10 import { homeIconTemplate } from "/js/templates/icons/homeIcon.template.js"; 10 11 import { linkToList, linkToFeed } from "/js/navigation.js"; 11 - import { Signal, ReactiveStore } from "/js/signals.js"; 12 + import { Signal, ReactiveStore, SignalSet, SignalArray } from "/js/signals.js"; 12 13 import { enableReorder } from "/js/utils.js"; 13 14 import { showToast } from "/js/toasts.js"; 14 15 import { valueForPinnedItem } from "/js/dataHelpers.js"; ··· 19 20 await auth.requireAuth(); 20 21 21 22 const state = new ReactiveStore("feedsView"); 22 - state.$draftOrder = new Signal.State(null); 23 + state.$draftOrder = new SignalArray(); 24 + state.$draftUnpinned = new SignalSet(); 23 25 state.$isEditing = new Signal.State(false); 24 26 state.$isSaving = new Signal.State(false); 25 27 ··· 33 35 34 36 async function handleSave() { 35 37 if (state.$isSaving.get()) return; 36 - const draft = state.$draftOrder.get(); 37 - if (draft) { 38 + const draftOrder = state.$draftOrder; 39 + const draftUnpinned = state.$draftUnpinned; 40 + if (draftOrder.length > 0 || draftUnpinned.size > 0) { 41 + const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); 42 + if (!pinnedItems) return; 43 + const baseOrder = 44 + draftOrder.length > 0 45 + ? [...draftOrder] 46 + : pinnedItems.map(valueForPinnedItem); 47 + const nextValues = 48 + draftUnpinned.size > 0 49 + ? baseOrder.filter((value) => !draftUnpinned.has(value)) 50 + : baseOrder; 38 51 state.$isSaving.set(true); 39 52 try { 40 - await dataLayer.mutations.reorderPinnedItems(draft); 53 + await dataLayer.mutations.setPinnedItems(nextValues); 41 54 } catch { 42 - showToast("Couldn't save feed order"); 55 + showToast("Couldn't save changes"); 43 56 return; 44 57 } finally { 45 58 state.$isSaving.set(false); 46 59 } 47 60 } 48 - state.$draftOrder.set(null); 61 + state.$draftOrder.clear(); 62 + state.$draftUnpinned.clear(); 49 63 state.$isEditing.set(false); 50 64 } 51 65 52 66 function cancelEditing() { 53 67 if (state.$isSaving.get()) return; 54 - state.$draftOrder.set(null); 68 + state.$draftOrder.clear(); 69 + state.$draftUnpinned.clear(); 55 70 state.$isEditing.set(false); 56 71 } 57 72 73 + function editControlsTemplate({ value, isSaving }) { 74 + return html`<button 75 + class="feeds-list-item-unpin-button" 76 + data-testid="feeds-list-item-unpin-button" 77 + aria-label="Unpin" 78 + ?disabled=${isSaving} 79 + @click=${(e) => { 80 + e.preventDefault(); 81 + e.stopPropagation(); 82 + state.$draftUnpinned.add(value); 83 + }} 84 + > 85 + ${pinIconTemplate({ filled: true })} 86 + </button> 87 + <button 88 + class="feeds-list-item-drag-handle" 89 + data-testid="feeds-list-item-drag-handle" 90 + aria-label="Drag to reorder" 91 + ?disabled=${isSaving} 92 + > 93 + ${menuIconTemplate()} 94 + </button>`; 95 + } 96 + 58 97 function rowTemplate({ item, currentUser, isEditing, isSaving }) { 59 - const dragHandle = isEditing 60 - ? html`<button 61 - class="feeds-list-item-drag-handle" 62 - data-testid="feeds-list-item-drag-handle" 63 - aria-label="Drag to reorder" 64 - ?disabled=${isSaving} 65 - > 66 - ${menuIconTemplate()} 67 - </button>` 68 - : ""; 69 98 const value = valueForPinnedItem(item); 99 + const editControls = isEditing 100 + ? editControlsTemplate({ value, isSaving }) 101 + : ""; 70 102 if (item.type === "timeline") { 71 103 return html`<div 72 104 class="feeds-list-item" ··· 80 112 <div class="feeds-list-item-title">Following</div> 81 113 <div class="feeds-list-item-creator">Feed by @bsky.app</div> 82 114 </div> 83 - ${dragHandle} 115 + ${editControls} 84 116 </div>`; 85 117 } 86 118 if (item.type === "list") { ··· 108 140 </div>` 109 141 : ""} 110 142 </div> 111 - ${dragHandle} 143 + ${editControls} 112 144 </container-link>`; 113 145 } 114 146 const feedGenerator = item.data; ··· 136 168 </div>` 137 169 : ""} 138 170 </div> 139 - ${dragHandle} 171 + ${editControls} 140 172 </container-link>`; 141 173 } 142 174 143 175 pageEffect(root, () => { 144 176 const currentUser = dataLayer.derived.$currentUser.get(); 145 177 const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); 146 - const draftOrder = state.$draftOrder.get(); 178 + const draftOrder = state.$draftOrder; 179 + const draftUnpinned = state.$draftUnpinned; 147 180 const isEditing = state.$isEditing.get(); 148 181 const isSaving = state.$isSaving.get(); 149 182 ··· 151 184 let persistedOrder = null; 152 185 if (pinnedItems) { 153 186 persistedOrder = pinnedItems.map(valueForPinnedItem); 154 - if (draftOrder) { 187 + if (draftOrder.length > 0) { 155 188 const byValue = new Map( 156 189 pinnedItems.map((it) => [valueForPinnedItem(it), it]), 157 190 ); ··· 159 192 .map((value) => byValue.get(value)) 160 193 .filter(Boolean); 161 194 } 195 + if (draftUnpinned.size > 0) { 196 + orderedItems = orderedItems.filter( 197 + (it) => !draftUnpinned.has(valueForPinnedItem(it)), 198 + ); 199 + } 162 200 } 163 201 164 202 const canEdit = (pinnedItems?.length ?? 0) >= 2; ··· 169 207 title: "Feeds", 170 208 subtitle: "", 171 209 rightItemTemplate: () => { 172 - if (!canEdit) return null; 173 210 if (isEditing) { 174 211 return html`<div class="header-actions"> 175 212 <button ··· 193 230 </div>`; 194 231 } 195 232 return html`<button 196 - class="header-edit-button" 233 + class="header-icon-button header-edit-button" 197 234 data-testid="feeds-edit-button" 198 - aria-label="Reorder feeds" 235 + aria-label="Edit feeds" 199 236 @click=${() => state.$isEditing.set(true)} 237 + ?disabled=${!canEdit} 200 238 > 201 239 ${settingsIconTemplate()} 202 240 </button>`; ··· 236 274 const nextOrder = elements.map((el) => el.dataset.pinnedValue); 237 275 const baseline = persistedOrder ?? []; 238 276 if (orderMatches(nextOrder, baseline)) { 239 - state.$draftOrder.set(null); 277 + state.$draftOrder.clear(); 240 278 } else { 241 - state.$draftOrder.set(nextOrder); 279 + state.$draftOrder.replace(nextOrder); 242 280 } 243 281 }, 244 282 }); ··· 248 286 }); 249 287 250 288 function resetEditingState() { 251 - state.$draftOrder.set(null); 289 + state.$draftOrder.clear(); 290 + state.$draftUnpinned.clear(); 252 291 state.$isEditing.set(false); 253 292 } 254 293
+1 -1
src/js/views/home.view.js
··· 228 228 onClickMenuButton: () => handleMenuClick(), 229 229 rightItemTemplate: () => html` 230 230 <a 231 - class="feeds-button" 231 + class="header-icon-button feeds-button" 232 232 href="/feeds" 233 233 aria-label="Feeds" 234 234 data-testid="feeds-button"
+258 -2
tests/e2e/specs/views/feeds.view.test.js
··· 156 156 await page.mouse.up(); 157 157 } 158 158 159 - test("gear button is hidden when there is only one pinned item", async ({ 159 + test("gear button is disabled when there is only one pinned item", async ({ 160 160 page, 161 161 }) => { 162 162 const mockServer = new MockServer(); ··· 170 170 }); 171 171 await expect( 172 172 feedsView.locator('[data-testid="feeds-edit-button"]'), 173 - ).toHaveCount(0); 173 + ).toBeDisabled(); 174 174 }); 175 175 176 176 test("drag handles are hidden until entering edit mode via the gear", async ({ ··· 244 244 const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); 245 245 await dragRow(page, followingRow, feedBRow); 246 246 247 + await expect( 248 + feedsView.locator(".feeds-list-item").first(), 249 + ).toHaveAttribute("data-pinned-value", feedA.uri); 247 250 const titlesAfterDrag = await feedsView 248 251 .locator(".feeds-list-item-title") 249 252 .allTextContents(); ··· 370 373 .allTextContents(); 371 374 expect(titlesAfterCancel).toEqual(["Following", "Feed A", "Feed B"]); 372 375 expect(sawPut).toBe(false); 376 + }); 377 + 378 + test("unpin buttons are hidden until entering edit mode", async ({ 379 + page, 380 + }) => { 381 + const mockServer = new MockServer(); 382 + const feedA = createFeedGenerator({ 383 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 384 + displayName: "Feed A", 385 + creatorHandle: "creator-a.bsky.social", 386 + }); 387 + const feedB = createFeedGenerator({ 388 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 389 + displayName: "Feed B", 390 + creatorHandle: "creator-b.bsky.social", 391 + }); 392 + mockServer.addFeedGenerators([feedA, feedB]); 393 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 394 + await mockServer.setup(page); 395 + await login(page); 396 + await page.goto("/feeds"); 397 + 398 + const feedsView = page.locator("#feeds-view"); 399 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 400 + timeout: 10000, 401 + }); 402 + await expect( 403 + feedsView.locator('[data-testid="feeds-list-item-unpin-button"]'), 404 + ).toHaveCount(0); 405 + 406 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 407 + await expect( 408 + feedsView.locator('[data-testid="feeds-list-item-unpin-button"]'), 409 + ).toHaveCount(3); 410 + }); 411 + 412 + test("clicking a row's unpin button hides it visually without a request", async ({ 413 + page, 414 + }) => { 415 + const mockServer = new MockServer(); 416 + const feedA = createFeedGenerator({ 417 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 418 + displayName: "Feed A", 419 + creatorHandle: "creator-a.bsky.social", 420 + }); 421 + const feedB = createFeedGenerator({ 422 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 423 + displayName: "Feed B", 424 + creatorHandle: "creator-b.bsky.social", 425 + }); 426 + mockServer.addFeedGenerators([feedA, feedB]); 427 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 428 + await mockServer.setup(page); 429 + await login(page); 430 + await page.goto("/feeds"); 431 + 432 + const feedsView = page.locator("#feeds-view"); 433 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 434 + timeout: 10000, 435 + }); 436 + 437 + let sawPut = false; 438 + page.on("request", (req) => { 439 + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { 440 + sawPut = true; 441 + } 442 + }); 443 + 444 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 445 + 446 + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); 447 + await feedARow 448 + .locator('[data-testid="feeds-list-item-unpin-button"]') 449 + .click(); 450 + 451 + const titlesAfterUnpin = await feedsView 452 + .locator(".feeds-list-item-title") 453 + .allTextContents(); 454 + expect(titlesAfterUnpin).toEqual(["Following", "Feed B"]); 455 + expect(sawPut).toBe(false); 456 + }); 457 + 458 + test("unpin + Save persists via putPreferences and exits edit mode", async ({ 459 + page, 460 + }) => { 461 + const mockServer = new MockServer(); 462 + const feedA = createFeedGenerator({ 463 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 464 + displayName: "Feed A", 465 + creatorHandle: "creator-a.bsky.social", 466 + }); 467 + const feedB = createFeedGenerator({ 468 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 469 + displayName: "Feed B", 470 + creatorHandle: "creator-b.bsky.social", 471 + }); 472 + mockServer.addFeedGenerators([feedA, feedB]); 473 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 474 + await mockServer.setup(page); 475 + await login(page); 476 + await page.goto("/feeds"); 477 + 478 + const feedsView = page.locator("#feeds-view"); 479 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 480 + timeout: 10000, 481 + }); 482 + 483 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 484 + 485 + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); 486 + await feedARow 487 + .locator('[data-testid="feeds-list-item-unpin-button"]') 488 + .click(); 489 + 490 + const putRequest = page.waitForRequest( 491 + (req) => 492 + req.url().includes("/xrpc/app.bsky.actor.putPreferences") && 493 + req.method() === "POST", 494 + ); 495 + await feedsView.locator('[data-testid="feeds-save-button"]').click(); 496 + const request = await putRequest; 497 + const body = request.postDataJSON(); 498 + const savedFeedsPref = body.preferences.find( 499 + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 500 + ); 501 + const pinnedValues = savedFeedsPref.items 502 + .filter((it) => it.pinned) 503 + .map((it) => it.value); 504 + expect(pinnedValues).toEqual(["following", feedB.uri]); 505 + 506 + await expect( 507 + feedsView.locator('[data-testid="feeds-edit-button"]'), 508 + ).toBeVisible(); 509 + await expect( 510 + feedsView.locator('[data-testid="feeds-save-button"]'), 511 + ).toHaveCount(0); 512 + }); 513 + 514 + test("Cancel discards the unsaved unpin and restores the row", async ({ 515 + page, 516 + }) => { 517 + const mockServer = new MockServer(); 518 + const feedA = createFeedGenerator({ 519 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 520 + displayName: "Feed A", 521 + creatorHandle: "creator-a.bsky.social", 522 + }); 523 + const feedB = createFeedGenerator({ 524 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 525 + displayName: "Feed B", 526 + creatorHandle: "creator-b.bsky.social", 527 + }); 528 + mockServer.addFeedGenerators([feedA, feedB]); 529 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 530 + await mockServer.setup(page); 531 + await login(page); 532 + await page.goto("/feeds"); 533 + 534 + const feedsView = page.locator("#feeds-view"); 535 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 536 + timeout: 10000, 537 + }); 538 + 539 + let sawPut = false; 540 + page.on("request", (req) => { 541 + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { 542 + sawPut = true; 543 + } 544 + }); 545 + 546 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 547 + 548 + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); 549 + await feedARow 550 + .locator('[data-testid="feeds-list-item-unpin-button"]') 551 + .click(); 552 + 553 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(2); 554 + 555 + await feedsView.locator('[data-testid="feeds-cancel-button"]').click(); 556 + 557 + await expect( 558 + feedsView.locator('[data-testid="feeds-edit-button"]'), 559 + ).toBeVisible(); 560 + const titlesAfterCancel = await feedsView 561 + .locator(".feeds-list-item-title") 562 + .allTextContents(); 563 + expect(titlesAfterCancel).toEqual(["Following", "Feed A", "Feed B"]); 564 + expect(sawPut).toBe(false); 565 + }); 566 + 567 + test("unpin + drag reorder + Save persists the combined change", async ({ 568 + page, 569 + }) => { 570 + const mockServer = new MockServer(); 571 + const feedA = createFeedGenerator({ 572 + uri: "at://did:plc:a/app.bsky.feed.generator/a", 573 + displayName: "Feed A", 574 + creatorHandle: "creator-a.bsky.social", 575 + }); 576 + const feedB = createFeedGenerator({ 577 + uri: "at://did:plc:b/app.bsky.feed.generator/b", 578 + displayName: "Feed B", 579 + creatorHandle: "creator-b.bsky.social", 580 + }); 581 + mockServer.addFeedGenerators([feedA, feedB]); 582 + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); 583 + await mockServer.setup(page); 584 + await login(page); 585 + await page.goto("/feeds"); 586 + 587 + const feedsView = page.locator("#feeds-view"); 588 + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { 589 + timeout: 10000, 590 + }); 591 + 592 + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); 593 + 594 + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); 595 + await feedARow 596 + .locator('[data-testid="feeds-list-item-unpin-button"]') 597 + .click(); 598 + 599 + const followingRow = feedsView.locator( 600 + '[data-testid="feeds-list-item-following"]', 601 + ); 602 + const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); 603 + await dragRow(page, followingRow, feedBRow); 604 + 605 + const putRequests = []; 606 + page.on("request", (req) => { 607 + if ( 608 + req.url().includes("/xrpc/app.bsky.actor.putPreferences") && 609 + req.method() === "POST" 610 + ) { 611 + putRequests.push(req); 612 + } 613 + }); 614 + 615 + await feedsView.locator('[data-testid="feeds-save-button"]').click(); 616 + await expect( 617 + feedsView.locator('[data-testid="feeds-edit-button"]'), 618 + ).toBeVisible(); 619 + 620 + expect(putRequests.length).toBeGreaterThanOrEqual(1); 621 + const lastBody = putRequests[putRequests.length - 1].postDataJSON(); 622 + const savedFeedsPref = lastBody.preferences.find( 623 + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 624 + ); 625 + const pinnedValues = savedFeedsPref.items 626 + .filter((it) => it.pinned) 627 + .map((it) => it.value); 628 + expect(pinnedValues).toEqual([feedB.uri, "following"]); 373 629 }); 374 630 375 631 test("navigating away discards the unsaved draft order", async ({
+4 -4
tests/unit/specs/dataLayer/mutations.test.js
··· 2036 2036 }); 2037 2037 }); 2038 2038 2039 - describe("reorderPinnedItems", () => { 2039 + describe("setPinnedItems", () => { 2040 2040 const feedA = "at://did:plc:x/app.bsky.feed.generator/a"; 2041 2041 const feedB = "at://did:plc:x/app.bsky.feed.generator/b"; 2042 2042 const listA = "at://did:plc:x/app.bsky.graph.list/a"; ··· 2102 2102 const before = dataStore.$pinnedItems 2103 2103 .get() 2104 2104 .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); 2105 - const promise = mutations.reorderPinnedItems([listA, "following", feedA]); 2105 + const promise = mutations.setPinnedItems([listA, "following", feedA]); 2106 2106 const during = dataStore.$pinnedItems 2107 2107 .get() 2108 2108 .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); ··· 2117 2117 2118 2118 it("sends the reordered preferences to updatePreferences", async () => { 2119 2119 const { mutations, resolveUpdate, getUpdatedPreferences } = setup(); 2120 - const promise = mutations.reorderPinnedItems([feedA, listA, "following"]); 2120 + const promise = mutations.setPinnedItems([feedA, listA, "following"]); 2121 2121 resolveUpdate(); 2122 2122 await promise; 2123 2123 const items = getUpdatedPreferences().obj[0].items; ··· 2163 2163 console.error = () => {}; 2164 2164 try { 2165 2165 await assert.rejects( 2166 - () => mutations.reorderPinnedItems([listA, feedA]), 2166 + () => mutations.setPinnedItems([listA, feedA]), 2167 2167 (err) => err === error, 2168 2168 ); 2169 2169 } finally {
+27 -7
tests/unit/specs/preferences.test.js
··· 255 255 }); 256 256 }); 257 257 258 - describe("Preferences.reorderPinnedItems", () => { 258 + describe("Preferences.setPinnedItems", () => { 259 259 const buildObj = () => [ 260 260 { 261 261 $type: "app.bsky.actor.defs#savedFeedsPrefV2", ··· 270 270 271 271 it("reorders the pinned slice and preserves unpinned entries at the end", () => { 272 272 const preferences = new Preferences(buildObj(), []); 273 - const newPreferences = preferences.reorderPinnedItems([ 273 + const newPreferences = preferences.setPinnedItems([ 274 274 "list-a", 275 275 "following", 276 276 "feed-a", ··· 284 284 285 285 it("does not mutate the original preferences", () => { 286 286 const preferences = new Preferences(buildObj(), []); 287 - preferences.reorderPinnedItems(["feed-a", "following", "list-a"]); 287 + preferences.setPinnedItems(["feed-a", "following", "list-a"]); 288 288 const items = Preferences.getSavedFeedsPreference(preferences.obj).items; 289 289 assert.deepEqual( 290 290 items.map((it) => it.value), ··· 292 292 ); 293 293 }); 294 294 295 - it("drops unknown values and unpinned values silently", () => { 295 + it("drops unknown values silently and pins listed unpinned items", () => { 296 296 const preferences = new Preferences(buildObj(), []); 297 - const newPreferences = preferences.reorderPinnedItems([ 297 + const newPreferences = preferences.setPinnedItems([ 298 298 "feed-a", 299 299 "unknown-value", 300 300 "feed-unpinned", ··· 303 303 ]); 304 304 const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; 305 305 assert.deepEqual( 306 - items.map((it) => it.value), 307 - ["feed-a", "following", "list-a", "feed-unpinned"], 306 + items.map((it) => ({ value: it.value, pinned: it.pinned })), 307 + [ 308 + { value: "feed-a", pinned: true }, 309 + { value: "feed-unpinned", pinned: true }, 310 + { value: "following", pinned: true }, 311 + { value: "list-a", pinned: true }, 312 + ], 313 + ); 314 + }); 315 + 316 + it("unpins currently-pinned items that are omitted from the target list", () => { 317 + const preferences = new Preferences(buildObj(), []); 318 + const newPreferences = preferences.setPinnedItems(["following", "list-a"]); 319 + const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; 320 + assert.deepEqual( 321 + items.map((it) => ({ value: it.value, pinned: it.pinned })), 322 + [ 323 + { value: "following", pinned: true }, 324 + { value: "list-a", pinned: true }, 325 + { value: "feed-a", pinned: false }, 326 + { value: "feed-unpinned", pinned: false }, 327 + ], 308 328 ); 309 329 }); 310 330 });
+208 -1
tests/unit/specs/signals.test.js
··· 1 1 import { describe, it } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { Signal, SignalSet, SignalMap } from "/js/signals.js"; 3 + import { Signal, SignalSet, SignalMap, SignalArray } from "/js/signals.js"; 4 4 5 5 describe("SignalSet - Set behavior", () => { 6 6 it("starts empty by default", () => { ··· 306 306 assert.deepEqual(runs, 3); 307 307 }); 308 308 }); 309 + 310 + describe("SignalArray - Array behavior", () => { 311 + it("starts empty by default", () => { 312 + const arr = new SignalArray(); 313 + assert.deepEqual(arr.length, 0); 314 + assert.deepEqual([...arr], []); 315 + }); 316 + 317 + it("seeds from an iterable passed to the constructor", () => { 318 + const arr = new SignalArray(["a", "b", "c"]); 319 + assert.deepEqual(arr.length, 3); 320 + assert.deepEqual([...arr], ["a", "b", "c"]); 321 + }); 322 + 323 + it("does not share the backing storage with the seed array", () => { 324 + const seed = ["a", "b"]; 325 + const arr = new SignalArray(seed); 326 + seed.push("c"); 327 + assert.deepEqual([...arr], ["a", "b"]); 328 + }); 329 + 330 + it("at() returns the element at the given index", () => { 331 + const arr = new SignalArray(["a", "b", "c"]); 332 + assert.deepEqual(arr.at(0), "a"); 333 + assert.deepEqual(arr.at(-1), "c"); 334 + assert.deepEqual(arr.at(99), undefined); 335 + }); 336 + 337 + it("indexOf and includes work like a native Array", () => { 338 + const arr = new SignalArray(["a", "b", "c"]); 339 + assert.deepEqual(arr.indexOf("b"), 1); 340 + assert.deepEqual(arr.indexOf("missing"), -1); 341 + assert(arr.includes("c")); 342 + assert.deepEqual(arr.includes("missing"), false); 343 + }); 344 + 345 + it("push appends and returns the new length", () => { 346 + const arr = new SignalArray(["a"]); 347 + assert.deepEqual(arr.push("b", "c"), 3); 348 + assert.deepEqual([...arr], ["a", "b", "c"]); 349 + }); 350 + 351 + it("pop removes and returns the last element", () => { 352 + const arr = new SignalArray(["a", "b"]); 353 + assert.deepEqual(arr.pop(), "b"); 354 + assert.deepEqual([...arr], ["a"]); 355 + assert.deepEqual(new SignalArray().pop(), undefined); 356 + }); 357 + 358 + it("set replaces the element at an index", () => { 359 + const arr = new SignalArray(["a", "b", "c"]); 360 + arr.set(1, "B"); 361 + assert.deepEqual([...arr], ["a", "B", "c"]); 362 + }); 363 + 364 + it("splice inserts, removes, and returns removed items", () => { 365 + const arr = new SignalArray(["a", "b", "c", "d"]); 366 + const removed = arr.splice(1, 2, "X", "Y", "Z"); 367 + assert.deepEqual(removed, ["b", "c"]); 368 + assert.deepEqual([...arr], ["a", "X", "Y", "Z", "d"]); 369 + }); 370 + 371 + it("replace swaps the whole contents in one shot", () => { 372 + const arr = new SignalArray(["a", "b"]); 373 + arr.replace(["x", "y", "z"]); 374 + assert.deepEqual([...arr], ["x", "y", "z"]); 375 + arr.replace([]); 376 + assert.deepEqual([...arr], []); 377 + }); 378 + 379 + it("clear empties the array", () => { 380 + const arr = new SignalArray(["a", "b"]); 381 + arr.clear(); 382 + assert.deepEqual(arr.length, 0); 383 + assert.deepEqual([...arr], []); 384 + }); 385 + 386 + it("map/filter/slice return plain arrays", () => { 387 + const arr = new SignalArray([1, 2, 3, 4]); 388 + const doubled = arr.map((n) => n * 2); 389 + assert.deepEqual(doubled, [2, 4, 6, 8]); 390 + assert(Array.isArray(doubled)); 391 + assert.deepEqual( 392 + arr.filter((n) => n % 2 === 0), 393 + [2, 4], 394 + ); 395 + assert.deepEqual(arr.slice(1, 3), [2, 3]); 396 + }); 397 + 398 + it("forEach visits each element in order", () => { 399 + const arr = new SignalArray(["a", "b", "c"]); 400 + const seen = []; 401 + arr.forEach((value, index) => seen.push([index, value])); 402 + assert.deepEqual(seen, [ 403 + [0, "a"], 404 + [1, "b"], 405 + [2, "c"], 406 + ]); 407 + }); 408 + }); 409 + 410 + describe("SignalArray - reactivity", () => { 411 + it("a length reader recomputes on push, pop, splice, replace, and clear", () => { 412 + const arr = new SignalArray(); 413 + let runs = 0; 414 + const $length = new Signal.Computed(() => { 415 + runs++; 416 + return arr.length; 417 + }); 418 + 419 + assert.deepEqual($length.get(), 0); 420 + assert.deepEqual(runs, 1); 421 + 422 + arr.push("a"); 423 + assert.deepEqual($length.get(), 1); 424 + assert.deepEqual(runs, 2); 425 + 426 + arr.pop(); 427 + assert.deepEqual($length.get(), 0); 428 + assert.deepEqual(runs, 3); 429 + 430 + arr.splice(0, 0, "a", "b"); 431 + assert.deepEqual($length.get(), 2); 432 + assert.deepEqual(runs, 4); 433 + 434 + arr.replace(["x"]); 435 + assert.deepEqual($length.get(), 1); 436 + assert.deepEqual(runs, 5); 437 + 438 + arr.clear(); 439 + assert.deepEqual($length.get(), 0); 440 + assert.deepEqual(runs, 6); 441 + }); 442 + 443 + it("an iteration reader recomputes on mutation", () => { 444 + const arr = new SignalArray(["a"]); 445 + let runs = 0; 446 + const $joined = new Signal.Computed(() => { 447 + runs++; 448 + return [...arr].join(","); 449 + }); 450 + 451 + assert.deepEqual($joined.get(), "a"); 452 + assert.deepEqual(runs, 1); 453 + 454 + arr.push("b"); 455 + assert.deepEqual($joined.get(), "a,b"); 456 + assert.deepEqual(runs, 2); 457 + 458 + arr.replace(["z"]); 459 + assert.deepEqual($joined.get(), "z"); 460 + assert.deepEqual(runs, 3); 461 + }); 462 + 463 + it("set(index, value) notifies iteration and length readers", () => { 464 + const arr = new SignalArray(["a", "b"]); 465 + let runs = 0; 466 + const $joined = new Signal.Computed(() => { 467 + runs++; 468 + return [...arr].join(","); 469 + }); 470 + 471 + assert.deepEqual($joined.get(), "a,b"); 472 + arr.set(1, "B"); 473 + assert.deepEqual($joined.get(), "a,B"); 474 + assert.deepEqual(runs, 2); 475 + }); 476 + 477 + it("map/filter/indexOf/at/includes readers all subscribe to the collection", () => { 478 + const cases = [ 479 + { name: "map", read: (arr) => arr.map((v) => v).join(",") }, 480 + { name: "filter", read: (arr) => arr.filter(() => true).join(",") }, 481 + { name: "indexOf", read: (arr) => arr.indexOf("b") }, 482 + { name: "at", read: (arr) => arr.at(0) }, 483 + { name: "includes", read: (arr) => arr.includes("a") }, 484 + ]; 485 + for (const { name, read } of cases) { 486 + const arr = new SignalArray(["a", "b"]); 487 + let runs = 0; 488 + const $c = new Signal.Computed(() => { 489 + runs++; 490 + return read(arr); 491 + }); 492 + $c.get(); 493 + assert.deepEqual(runs, 1, `${name}: initial run`); 494 + arr.push("c"); 495 + $c.get(); 496 + assert.deepEqual(runs, 2, `${name}: refires after push`); 497 + } 498 + }); 499 + 500 + it("replacing with an equal-content array still notifies (no dedup, signal-utils semantics)", () => { 501 + const arr = new SignalArray(["a", "b"]); 502 + let runs = 0; 503 + const $length = new Signal.Computed(() => { 504 + runs++; 505 + return arr.length; 506 + }); 507 + 508 + assert.deepEqual($length.get(), 2); 509 + assert.deepEqual(runs, 1); 510 + 511 + arr.replace(["a", "b"]); 512 + assert.deepEqual($length.get(), 2); 513 + assert.deepEqual(runs, 2); 514 + }); 515 + });