diff --git a/package.json b/package.json index 8f881020..017ccdce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.189", + "version": "0.17.190", "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 a4ab28de..c9736413 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -552,46 +552,44 @@ plugin-rich-text { } } -.inbox-button { - position: relative; +.header-icon-button { display: flex; align-items: center; justify-content: center; + background: transparent; + border: none; + padding: 0; cursor: pointer; color: var(--text-color-muted); - height: 30px; - width: 30px; border-radius: var(--icon-button-border-radius); transition: background-color 0.2s; } @media (hover: hover) { - .inbox-button:hover { + .header-icon-button:not(:disabled):hover { background-color: var(--back-button-hover-color); } } +.header-icon-button:disabled { + opacity: 0.5; + cursor: default; +} + +.inbox-button { + position: relative; + height: 30px; + width: 30px; +} + .inbox-button .icon { width: 24px; height: 24px; } .feeds-button { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-color-muted); height: 30px; width: 30px; - border-radius: var(--icon-button-border-radius); - transition: background-color 0.2s; -} - -@media (hover: hover) { - .feeds-button:hover { - background-color: var(--back-button-hover-color); - } } .feeds-button .icon { @@ -5617,7 +5615,6 @@ emoji-picker-dialog emoji-picker, .feeds-list-item { display: flex; align-items: flex-start; - gap: 12px; padding: 16px; border-bottom: var(--hair) solid var(--post-border-color); text-decoration: none; @@ -5648,6 +5645,7 @@ emoji-picker-dialog emoji-picker, .feeds-list-item-content { flex: 1; min-width: 0; + padding-left: 12px; } .feeds-list-item-title { @@ -5660,7 +5658,6 @@ emoji-picker-dialog emoji-picker, .feeds-list-item-creator { font-size: 14px; color: var(--text-color-muted); - margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -5689,6 +5686,30 @@ emoji-picker-dialog emoji-picker, user-select: none; } +.feeds-list-item-unpin-button { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + padding: 0; + color: var(--highlight-color); + cursor: pointer; +} + +.feeds-list-item-unpin-button:disabled { + cursor: default; + opacity: 0.5; +} + +.feeds-list-item-unpin-button svg { + width: 20px; + height: 20px; +} + .feeds-list-item-drag-handle:active { cursor: grabbing; } @@ -5728,14 +5749,7 @@ emoji-picker-dialog emoji-picker, } .header-edit-button { - background: transparent; - border: none; padding: 8px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-color-muted); } .header-edit-button svg { @@ -5743,6 +5757,11 @@ emoji-picker-dialog emoji-picker, height: 24px; } +.new-chat-button:disabled { + opacity: 0.5; + cursor: default; +} + .header svg { display: block; } @@ -5853,6 +5872,7 @@ emoji-picker-dialog emoji-picker, .feeds-list-item-skeleton { pointer-events: none; + min-height: 75px; } .feeds-list-item-skeleton-avatar { diff --git a/src/img/icons/custom/hashtag-solid.svg b/src/img/icons/custom/hashtag-solid.svg new file mode 100644 index 00000000..375cf80d --- /dev/null +++ b/src/img/icons/custom/hashtag-solid.svg @@ -0,0 +1 @@ + diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 193d0597..96c679dd 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -435,9 +435,9 @@ export class Mutations { } } - async reorderPinnedItems(orderedValues) { + async setPinnedItems(values) { const preferences = this.preferencesProvider.requirePreferences(); - const newPreferences = preferences.reorderPinnedItems(orderedValues); + const newPreferences = preferences.setPinnedItems(values); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { @@ -448,14 +448,11 @@ export class Mutations { // Update pinned items in memory const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (pinnedItems) { - const indexOf = (item) => { - const idx = orderedValues.indexOf(valueForPinnedItem(item)); - return idx === -1 ? orderedValues.length : idx; - }; - const reordered = [...pinnedItems].sort( - (a, b) => indexOf(a) - indexOf(b), + const byValue = new Map( + pinnedItems.map((item) => [valueForPinnedItem(item), item]), ); - this.dataStore.$pinnedItems.set(reordered); + const next = values.map((value) => byValue.get(value)).filter(Boolean); + this.dataStore.$pinnedItems.set(next); } } diff --git a/src/js/preferences.js b/src/js/preferences.js index 23e6ae0f..94122b17 100644 --- a/src/js/preferences.js +++ b/src/js/preferences.js @@ -135,20 +135,25 @@ export class Preferences { return clone; } - reorderPinnedItems(orderedValues) { + setPinnedItems(values) { const clone = this.clone(); const savedFeedsPreference = Preferences.getSavedFeedsPreference(clone.obj); if (!savedFeedsPreference) { throw new Error("Saved feeds preference not found"); } + const valueSet = new Set(values); + for (const item of savedFeedsPreference.items) { + item.pinned = valueSet.has(item.value); + } const byValue = new Map( savedFeedsPreference.items.map((item) => [item.value, item]), ); - const reordered = orderedValues - .map((value) => byValue.get(value)) - .filter((item) => item && item.pinned); - const unpinned = savedFeedsPreference.items.filter((item) => !item.pinned); - savedFeedsPreference.items = [...reordered, ...unpinned]; + const reordered = values.map((value) => byValue.get(value)).filter(Boolean); + const reorderedSet = new Set(reordered); + const rest = savedFeedsPreference.items.filter( + (item) => !reorderedSet.has(item), + ); + savedFeedsPreference.items = [...reordered, ...rest]; return clone; } diff --git a/src/js/signals.js b/src/js/signals.js index e01437b3..20f5e4b7 100644 --- a/src/js/signals.js +++ b/src/js/signals.js @@ -310,6 +310,95 @@ export class SignalSet { } } +export class SignalArray { + __debugName = ""; + + #collection = new Signal.State(null, { equals: () => false }); + #array; + + constructor(values = []) { + this.#array = [...values]; + } + + get length() { + this.#collection.get(); + return this.#array.length; + } + + at(index) { + this.#collection.get(); + return this.#array.at(index); + } + + indexOf(value, fromIndex) { + this.#collection.get(); + return this.#array.indexOf(value, fromIndex); + } + + includes(value, fromIndex) { + this.#collection.get(); + return this.#array.includes(value, fromIndex); + } + + slice(start, end) { + this.#collection.get(); + return this.#array.slice(start, end); + } + + map(fn, thisArg) { + this.#collection.get(); + return this.#array.map(fn, thisArg); + } + + filter(fn, thisArg) { + this.#collection.get(); + return this.#array.filter(fn, thisArg); + } + + forEach(callback, thisArg) { + this.#collection.get(); + this.#array.forEach(callback, thisArg); + } + + [Symbol.iterator]() { + this.#collection.get(); + return this.#array[Symbol.iterator](); + } + + set(index, value) { + this.#array[index] = value; + this.#collection.set(null); + } + + push(...values) { + this.#array.push(...values); + this.#collection.set(null); + return this.#array.length; + } + + pop() { + const value = this.#array.pop(); + this.#collection.set(null); + return value; + } + + splice(start, deleteCount, ...items) { + const removed = this.#array.splice(start, deleteCount, ...items); + this.#collection.set(null); + return removed; + } + + replace(values) { + this.#array = [...values]; + this.#collection.set(null); + } + + clear() { + this.#array.length = 0; + this.#collection.set(null); + } +} + export class ComputedMap { __debugName = ""; diff --git a/src/js/templates/icons/hashtagIcon.template.js b/src/js/templates/icons/hashtagIcon.template.js index ea5adbc1..b1fb3392 100644 --- a/src/js/templates/icons/hashtagIcon.template.js +++ b/src/js/templates/icons/hashtagIcon.template.js @@ -1,16 +1,35 @@ import { html } from "/js/lib/lit-html.js"; +import { classnames } from "/js/utils.js"; -// Source: src/img/icons/custom/hashtag-line.svg -export function hashtagIconTemplate() { - return html`
- - - +// Source: src/img/icons/custom/hashtag-line.svg, src/img/icons/custom/hashtag-solid.svg +export function hashtagIconTemplate({ filled = false } = {}) { + return html`
+ ${filled + ? html` + + ` + : html` + + `}
`; } diff --git a/src/js/templates/icons/feedIcon.template.js b/src/js/templates/icons/listIcon.template.js similarity index 95% rename from src/js/templates/icons/feedIcon.template.js rename to src/js/templates/icons/listIcon.template.js index 859a9739..1cc5d5f1 100644 --- a/src/js/templates/icons/feedIcon.template.js +++ b/src/js/templates/icons/listIcon.template.js @@ -1,8 +1,8 @@ import { html } from "/js/lib/lit-html.js"; import { classnames } from "/js/utils.js"; -export function feedIconTemplate({ filled = false } = {}) { - return html`
+export function listIconTemplate({ filled = false } = {}) { + return html`
${filled ? html` 0 || draftUnpinned.size > 0) { + const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); + if (!pinnedItems) return; + const baseOrder = + draftOrder.length > 0 + ? [...draftOrder] + : pinnedItems.map(valueForPinnedItem); + const nextValues = + draftUnpinned.size > 0 + ? baseOrder.filter((value) => !draftUnpinned.has(value)) + : baseOrder; state.$isSaving.set(true); try { - await dataLayer.mutations.reorderPinnedItems(draft); + await dataLayer.mutations.setPinnedItems(nextValues); } catch { - showToast("Couldn't save feed order"); + showToast("Couldn't save changes"); return; } finally { state.$isSaving.set(false); } } - state.$draftOrder.set(null); + state.$draftOrder.clear(); + state.$draftUnpinned.clear(); state.$isEditing.set(false); } function cancelEditing() { if (state.$isSaving.get()) return; - state.$draftOrder.set(null); + state.$draftOrder.clear(); + state.$draftUnpinned.clear(); state.$isEditing.set(false); } + function editControlsTemplate({ value, isSaving }) { + return html` + `; + } + function rowTemplate({ item, currentUser, isEditing, isSaving }) { - const dragHandle = isEditing - ? html`` - : ""; const value = valueForPinnedItem(item); + const editControls = isEditing + ? editControlsTemplate({ value, isSaving }) + : ""; if (item.type === "timeline") { return html`
Following
Feed by @bsky.app
- ${dragHandle} + ${editControls}
`; } if (item.type === "list") { @@ -108,7 +140,7 @@ class FeedsView extends View {
` : ""} - ${dragHandle} + ${editControls} `; } const feedGenerator = item.data; @@ -136,14 +168,15 @@ class FeedsView extends View { ` : ""} - ${dragHandle} + ${editControls} `; } pageEffect(root, () => { const currentUser = dataLayer.derived.$currentUser.get(); const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); - const draftOrder = state.$draftOrder.get(); + const draftOrder = state.$draftOrder; + const draftUnpinned = state.$draftUnpinned; const isEditing = state.$isEditing.get(); const isSaving = state.$isSaving.get(); @@ -151,7 +184,7 @@ class FeedsView extends View { let persistedOrder = null; if (pinnedItems) { persistedOrder = pinnedItems.map(valueForPinnedItem); - if (draftOrder) { + if (draftOrder.length > 0) { const byValue = new Map( pinnedItems.map((it) => [valueForPinnedItem(it), it]), ); @@ -159,6 +192,11 @@ class FeedsView extends View { .map((value) => byValue.get(value)) .filter(Boolean); } + if (draftUnpinned.size > 0) { + orderedItems = orderedItems.filter( + (it) => !draftUnpinned.has(valueForPinnedItem(it)), + ); + } } const canEdit = (pinnedItems?.length ?? 0) >= 2; @@ -169,7 +207,6 @@ class FeedsView extends View { title: "Feeds", subtitle: "", rightItemTemplate: () => { - if (!canEdit) return null; if (isEditing) { return html`
`; @@ -236,9 +274,9 @@ class FeedsView extends View { const nextOrder = elements.map((el) => el.dataset.pinnedValue); const baseline = persistedOrder ?? []; if (orderMatches(nextOrder, baseline)) { - state.$draftOrder.set(null); + state.$draftOrder.clear(); } else { - state.$draftOrder.set(nextOrder); + state.$draftOrder.replace(nextOrder); } }, }); @@ -248,7 +286,8 @@ class FeedsView extends View { }); function resetEditingState() { - state.$draftOrder.set(null); + state.$draftOrder.clear(); + state.$draftUnpinned.clear(); state.$isEditing.set(false); } diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 15079da4..838ebbe8 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -228,7 +228,7 @@ class HomeView extends View { onClickMenuButton: () => handleMenuClick(), rightItemTemplate: () => html` { await page.mouse.up(); } - test("gear button is hidden when there is only one pinned item", async ({ + test("gear button is disabled when there is only one pinned item", async ({ page, }) => { const mockServer = new MockServer(); @@ -170,7 +170,7 @@ test.describe("Feeds view", () => { }); await expect( feedsView.locator('[data-testid="feeds-edit-button"]'), - ).toHaveCount(0); + ).toBeDisabled(); }); test("drag handles are hidden until entering edit mode via the gear", async ({ @@ -244,6 +244,9 @@ test.describe("Feeds view", () => { const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); await dragRow(page, followingRow, feedBRow); + await expect( + feedsView.locator(".feeds-list-item").first(), + ).toHaveAttribute("data-pinned-value", feedA.uri); const titlesAfterDrag = await feedsView .locator(".feeds-list-item-title") .allTextContents(); @@ -372,6 +375,259 @@ test.describe("Feeds view", () => { expect(sawPut).toBe(false); }); + test("unpin buttons are hidden until entering edit mode", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feedA = createFeedGenerator({ + uri: "at://did:plc:a/app.bsky.feed.generator/a", + displayName: "Feed A", + creatorHandle: "creator-a.bsky.social", + }); + const feedB = createFeedGenerator({ + uri: "at://did:plc:b/app.bsky.feed.generator/b", + displayName: "Feed B", + creatorHandle: "creator-b.bsky.social", + }); + mockServer.addFeedGenerators([feedA, feedB]); + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); + await mockServer.setup(page); + await login(page); + await page.goto("/feeds"); + + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { + timeout: 10000, + }); + await expect( + feedsView.locator('[data-testid="feeds-list-item-unpin-button"]'), + ).toHaveCount(0); + + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + await expect( + feedsView.locator('[data-testid="feeds-list-item-unpin-button"]'), + ).toHaveCount(3); + }); + + test("clicking a row's unpin button hides it visually without a request", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feedA = createFeedGenerator({ + uri: "at://did:plc:a/app.bsky.feed.generator/a", + displayName: "Feed A", + creatorHandle: "creator-a.bsky.social", + }); + const feedB = createFeedGenerator({ + uri: "at://did:plc:b/app.bsky.feed.generator/b", + displayName: "Feed B", + creatorHandle: "creator-b.bsky.social", + }); + mockServer.addFeedGenerators([feedA, feedB]); + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); + await mockServer.setup(page); + await login(page); + await page.goto("/feeds"); + + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { + timeout: 10000, + }); + + let sawPut = false; + page.on("request", (req) => { + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { + sawPut = true; + } + }); + + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); + await feedARow + .locator('[data-testid="feeds-list-item-unpin-button"]') + .click(); + + const titlesAfterUnpin = await feedsView + .locator(".feeds-list-item-title") + .allTextContents(); + expect(titlesAfterUnpin).toEqual(["Following", "Feed B"]); + expect(sawPut).toBe(false); + }); + + test("unpin + Save persists via putPreferences and exits edit mode", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feedA = createFeedGenerator({ + uri: "at://did:plc:a/app.bsky.feed.generator/a", + displayName: "Feed A", + creatorHandle: "creator-a.bsky.social", + }); + const feedB = createFeedGenerator({ + uri: "at://did:plc:b/app.bsky.feed.generator/b", + displayName: "Feed B", + creatorHandle: "creator-b.bsky.social", + }); + mockServer.addFeedGenerators([feedA, feedB]); + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); + await mockServer.setup(page); + await login(page); + await page.goto("/feeds"); + + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { + timeout: 10000, + }); + + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); + await feedARow + .locator('[data-testid="feeds-list-item-unpin-button"]') + .click(); + + const putRequest = page.waitForRequest( + (req) => + req.url().includes("/xrpc/app.bsky.actor.putPreferences") && + req.method() === "POST", + ); + await feedsView.locator('[data-testid="feeds-save-button"]').click(); + const request = await putRequest; + const body = request.postDataJSON(); + const savedFeedsPref = body.preferences.find( + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", + ); + const pinnedValues = savedFeedsPref.items + .filter((it) => it.pinned) + .map((it) => it.value); + expect(pinnedValues).toEqual(["following", feedB.uri]); + + await expect( + feedsView.locator('[data-testid="feeds-edit-button"]'), + ).toBeVisible(); + await expect( + feedsView.locator('[data-testid="feeds-save-button"]'), + ).toHaveCount(0); + }); + + test("Cancel discards the unsaved unpin and restores the row", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feedA = createFeedGenerator({ + uri: "at://did:plc:a/app.bsky.feed.generator/a", + displayName: "Feed A", + creatorHandle: "creator-a.bsky.social", + }); + const feedB = createFeedGenerator({ + uri: "at://did:plc:b/app.bsky.feed.generator/b", + displayName: "Feed B", + creatorHandle: "creator-b.bsky.social", + }); + mockServer.addFeedGenerators([feedA, feedB]); + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); + await mockServer.setup(page); + await login(page); + await page.goto("/feeds"); + + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { + timeout: 10000, + }); + + let sawPut = false; + page.on("request", (req) => { + if (req.url().includes("/xrpc/app.bsky.actor.putPreferences")) { + sawPut = true; + } + }); + + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); + await feedARow + .locator('[data-testid="feeds-list-item-unpin-button"]') + .click(); + + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(2); + + await feedsView.locator('[data-testid="feeds-cancel-button"]').click(); + + await expect( + feedsView.locator('[data-testid="feeds-edit-button"]'), + ).toBeVisible(); + const titlesAfterCancel = await feedsView + .locator(".feeds-list-item-title") + .allTextContents(); + expect(titlesAfterCancel).toEqual(["Following", "Feed A", "Feed B"]); + expect(sawPut).toBe(false); + }); + + test("unpin + drag reorder + Save persists the combined change", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feedA = createFeedGenerator({ + uri: "at://did:plc:a/app.bsky.feed.generator/a", + displayName: "Feed A", + creatorHandle: "creator-a.bsky.social", + }); + const feedB = createFeedGenerator({ + uri: "at://did:plc:b/app.bsky.feed.generator/b", + displayName: "Feed B", + creatorHandle: "creator-b.bsky.social", + }); + mockServer.addFeedGenerators([feedA, feedB]); + mockServer.setPinnedFeeds([feedA.uri, feedB.uri]); + await mockServer.setup(page); + await login(page); + await page.goto("/feeds"); + + const feedsView = page.locator("#feeds-view"); + await expect(feedsView.locator(".feeds-list-item")).toHaveCount(3, { + timeout: 10000, + }); + + await feedsView.locator('[data-testid="feeds-edit-button"]').click(); + + const feedARow = feedsView.locator(`[data-pinned-value="${feedA.uri}"]`); + await feedARow + .locator('[data-testid="feeds-list-item-unpin-button"]') + .click(); + + const followingRow = feedsView.locator( + '[data-testid="feeds-list-item-following"]', + ); + const feedBRow = feedsView.locator(`[data-pinned-value="${feedB.uri}"]`); + await dragRow(page, followingRow, feedBRow); + + const putRequests = []; + page.on("request", (req) => { + if ( + req.url().includes("/xrpc/app.bsky.actor.putPreferences") && + req.method() === "POST" + ) { + putRequests.push(req); + } + }); + + await feedsView.locator('[data-testid="feeds-save-button"]').click(); + await expect( + feedsView.locator('[data-testid="feeds-edit-button"]'), + ).toBeVisible(); + + expect(putRequests.length).toBeGreaterThanOrEqual(1); + const lastBody = putRequests[putRequests.length - 1].postDataJSON(); + const savedFeedsPref = lastBody.preferences.find( + (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", + ); + const pinnedValues = savedFeedsPref.items + .filter((it) => it.pinned) + .map((it) => it.value); + expect(pinnedValues).toEqual([feedB.uri, "following"]); + }); + test("navigating away discards the unsaved draft order", async ({ page, }) => { diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index cf4b04d1..037f151e 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -2036,7 +2036,7 @@ describe("unpinFeed", () => { }); }); -describe("reorderPinnedItems", () => { +describe("setPinnedItems", () => { const feedA = "at://did:plc:x/app.bsky.feed.generator/a"; const feedB = "at://did:plc:x/app.bsky.feed.generator/b"; const listA = "at://did:plc:x/app.bsky.graph.list/a"; @@ -2102,7 +2102,7 @@ describe("reorderPinnedItems", () => { const before = dataStore.$pinnedItems .get() .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); - const promise = mutations.reorderPinnedItems([listA, "following", feedA]); + const promise = mutations.setPinnedItems([listA, "following", feedA]); const during = dataStore.$pinnedItems .get() .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); @@ -2117,7 +2117,7 @@ describe("reorderPinnedItems", () => { it("sends the reordered preferences to updatePreferences", async () => { const { mutations, resolveUpdate, getUpdatedPreferences } = setup(); - const promise = mutations.reorderPinnedItems([feedA, listA, "following"]); + const promise = mutations.setPinnedItems([feedA, listA, "following"]); resolveUpdate(); await promise; const items = getUpdatedPreferences().obj[0].items; @@ -2163,7 +2163,7 @@ describe("reorderPinnedItems", () => { console.error = () => {}; try { await assert.rejects( - () => mutations.reorderPinnedItems([listA, feedA]), + () => mutations.setPinnedItems([listA, feedA]), (err) => err === error, ); } finally { diff --git a/tests/unit/specs/preferences.test.js b/tests/unit/specs/preferences.test.js index 38cf94ad..d8f9bd44 100644 --- a/tests/unit/specs/preferences.test.js +++ b/tests/unit/specs/preferences.test.js @@ -255,7 +255,7 @@ describe("Preferences.pinFeed", () => { }); }); -describe("Preferences.reorderPinnedItems", () => { +describe("Preferences.setPinnedItems", () => { const buildObj = () => [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", @@ -270,7 +270,7 @@ describe("Preferences.reorderPinnedItems", () => { it("reorders the pinned slice and preserves unpinned entries at the end", () => { const preferences = new Preferences(buildObj(), []); - const newPreferences = preferences.reorderPinnedItems([ + const newPreferences = preferences.setPinnedItems([ "list-a", "following", "feed-a", @@ -284,7 +284,7 @@ describe("Preferences.reorderPinnedItems", () => { it("does not mutate the original preferences", () => { const preferences = new Preferences(buildObj(), []); - preferences.reorderPinnedItems(["feed-a", "following", "list-a"]); + preferences.setPinnedItems(["feed-a", "following", "list-a"]); const items = Preferences.getSavedFeedsPreference(preferences.obj).items; assert.deepEqual( items.map((it) => it.value), @@ -292,9 +292,9 @@ describe("Preferences.reorderPinnedItems", () => { ); }); - it("drops unknown values and unpinned values silently", () => { + it("drops unknown values silently and pins listed unpinned items", () => { const preferences = new Preferences(buildObj(), []); - const newPreferences = preferences.reorderPinnedItems([ + const newPreferences = preferences.setPinnedItems([ "feed-a", "unknown-value", "feed-unpinned", @@ -303,8 +303,28 @@ describe("Preferences.reorderPinnedItems", () => { ]); const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; assert.deepEqual( - items.map((it) => it.value), - ["feed-a", "following", "list-a", "feed-unpinned"], + items.map((it) => ({ value: it.value, pinned: it.pinned })), + [ + { value: "feed-a", pinned: true }, + { value: "feed-unpinned", pinned: true }, + { value: "following", pinned: true }, + { value: "list-a", pinned: true }, + ], + ); + }); + + it("unpins currently-pinned items that are omitted from the target list", () => { + const preferences = new Preferences(buildObj(), []); + const newPreferences = preferences.setPinnedItems(["following", "list-a"]); + const items = Preferences.getSavedFeedsPreference(newPreferences.obj).items; + assert.deepEqual( + items.map((it) => ({ value: it.value, pinned: it.pinned })), + [ + { value: "following", pinned: true }, + { value: "list-a", pinned: true }, + { value: "feed-a", pinned: false }, + { value: "feed-unpinned", pinned: false }, + ], ); }); }); diff --git a/tests/unit/specs/signals.test.js b/tests/unit/specs/signals.test.js index f5936da4..337074ba 100644 --- a/tests/unit/specs/signals.test.js +++ b/tests/unit/specs/signals.test.js @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { Signal, SignalSet, SignalMap } from "/js/signals.js"; +import { Signal, SignalSet, SignalMap, SignalArray } from "/js/signals.js"; describe("SignalSet - Set behavior", () => { it("starts empty by default", () => { @@ -306,3 +306,210 @@ describe("SignalMap - reactivity", () => { assert.deepEqual(runs, 3); }); }); + +describe("SignalArray - Array behavior", () => { + it("starts empty by default", () => { + const arr = new SignalArray(); + assert.deepEqual(arr.length, 0); + assert.deepEqual([...arr], []); + }); + + it("seeds from an iterable passed to the constructor", () => { + const arr = new SignalArray(["a", "b", "c"]); + assert.deepEqual(arr.length, 3); + assert.deepEqual([...arr], ["a", "b", "c"]); + }); + + it("does not share the backing storage with the seed array", () => { + const seed = ["a", "b"]; + const arr = new SignalArray(seed); + seed.push("c"); + assert.deepEqual([...arr], ["a", "b"]); + }); + + it("at() returns the element at the given index", () => { + const arr = new SignalArray(["a", "b", "c"]); + assert.deepEqual(arr.at(0), "a"); + assert.deepEqual(arr.at(-1), "c"); + assert.deepEqual(arr.at(99), undefined); + }); + + it("indexOf and includes work like a native Array", () => { + const arr = new SignalArray(["a", "b", "c"]); + assert.deepEqual(arr.indexOf("b"), 1); + assert.deepEqual(arr.indexOf("missing"), -1); + assert(arr.includes("c")); + assert.deepEqual(arr.includes("missing"), false); + }); + + it("push appends and returns the new length", () => { + const arr = new SignalArray(["a"]); + assert.deepEqual(arr.push("b", "c"), 3); + assert.deepEqual([...arr], ["a", "b", "c"]); + }); + + it("pop removes and returns the last element", () => { + const arr = new SignalArray(["a", "b"]); + assert.deepEqual(arr.pop(), "b"); + assert.deepEqual([...arr], ["a"]); + assert.deepEqual(new SignalArray().pop(), undefined); + }); + + it("set replaces the element at an index", () => { + const arr = new SignalArray(["a", "b", "c"]); + arr.set(1, "B"); + assert.deepEqual([...arr], ["a", "B", "c"]); + }); + + it("splice inserts, removes, and returns removed items", () => { + const arr = new SignalArray(["a", "b", "c", "d"]); + const removed = arr.splice(1, 2, "X", "Y", "Z"); + assert.deepEqual(removed, ["b", "c"]); + assert.deepEqual([...arr], ["a", "X", "Y", "Z", "d"]); + }); + + it("replace swaps the whole contents in one shot", () => { + const arr = new SignalArray(["a", "b"]); + arr.replace(["x", "y", "z"]); + assert.deepEqual([...arr], ["x", "y", "z"]); + arr.replace([]); + assert.deepEqual([...arr], []); + }); + + it("clear empties the array", () => { + const arr = new SignalArray(["a", "b"]); + arr.clear(); + assert.deepEqual(arr.length, 0); + assert.deepEqual([...arr], []); + }); + + it("map/filter/slice return plain arrays", () => { + const arr = new SignalArray([1, 2, 3, 4]); + const doubled = arr.map((n) => n * 2); + assert.deepEqual(doubled, [2, 4, 6, 8]); + assert(Array.isArray(doubled)); + assert.deepEqual( + arr.filter((n) => n % 2 === 0), + [2, 4], + ); + assert.deepEqual(arr.slice(1, 3), [2, 3]); + }); + + it("forEach visits each element in order", () => { + const arr = new SignalArray(["a", "b", "c"]); + const seen = []; + arr.forEach((value, index) => seen.push([index, value])); + assert.deepEqual(seen, [ + [0, "a"], + [1, "b"], + [2, "c"], + ]); + }); +}); + +describe("SignalArray - reactivity", () => { + it("a length reader recomputes on push, pop, splice, replace, and clear", () => { + const arr = new SignalArray(); + let runs = 0; + const $length = new Signal.Computed(() => { + runs++; + return arr.length; + }); + + assert.deepEqual($length.get(), 0); + assert.deepEqual(runs, 1); + + arr.push("a"); + assert.deepEqual($length.get(), 1); + assert.deepEqual(runs, 2); + + arr.pop(); + assert.deepEqual($length.get(), 0); + assert.deepEqual(runs, 3); + + arr.splice(0, 0, "a", "b"); + assert.deepEqual($length.get(), 2); + assert.deepEqual(runs, 4); + + arr.replace(["x"]); + assert.deepEqual($length.get(), 1); + assert.deepEqual(runs, 5); + + arr.clear(); + assert.deepEqual($length.get(), 0); + assert.deepEqual(runs, 6); + }); + + it("an iteration reader recomputes on mutation", () => { + const arr = new SignalArray(["a"]); + let runs = 0; + const $joined = new Signal.Computed(() => { + runs++; + return [...arr].join(","); + }); + + assert.deepEqual($joined.get(), "a"); + assert.deepEqual(runs, 1); + + arr.push("b"); + assert.deepEqual($joined.get(), "a,b"); + assert.deepEqual(runs, 2); + + arr.replace(["z"]); + assert.deepEqual($joined.get(), "z"); + assert.deepEqual(runs, 3); + }); + + it("set(index, value) notifies iteration and length readers", () => { + const arr = new SignalArray(["a", "b"]); + let runs = 0; + const $joined = new Signal.Computed(() => { + runs++; + return [...arr].join(","); + }); + + assert.deepEqual($joined.get(), "a,b"); + arr.set(1, "B"); + assert.deepEqual($joined.get(), "a,B"); + assert.deepEqual(runs, 2); + }); + + it("map/filter/indexOf/at/includes readers all subscribe to the collection", () => { + const cases = [ + { name: "map", read: (arr) => arr.map((v) => v).join(",") }, + { name: "filter", read: (arr) => arr.filter(() => true).join(",") }, + { name: "indexOf", read: (arr) => arr.indexOf("b") }, + { name: "at", read: (arr) => arr.at(0) }, + { name: "includes", read: (arr) => arr.includes("a") }, + ]; + for (const { name, read } of cases) { + const arr = new SignalArray(["a", "b"]); + let runs = 0; + const $c = new Signal.Computed(() => { + runs++; + return read(arr); + }); + $c.get(); + assert.deepEqual(runs, 1, `${name}: initial run`); + arr.push("c"); + $c.get(); + assert.deepEqual(runs, 2, `${name}: refires after push`); + } + }); + + it("replacing with an equal-content array still notifies (no dedup, signal-utils semantics)", () => { + const arr = new SignalArray(["a", "b"]); + let runs = 0; + const $length = new Signal.Computed(() => { + runs++; + return arr.length; + }); + + assert.deepEqual($length.get(), 2); + assert.deepEqual(runs, 1); + + arr.replace(["a", "b"]); + assert.deepEqual($length.get(), 2); + assert.deepEqual(runs, 2); + }); +});