diff --git a/package.json b/package.json index dd1d29cb..4584ce2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.83", + "version": "0.18.84", "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 83bd2a59..829168ad 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -934,6 +934,20 @@ header { font-weight: 600; } +.header .header-title-muted-icon { + display: inline-flex; + vertical-align: -3px; + width: 16px; + height: 16px; + margin-left: 6px; + color: var(--text-color-muted); +} + +.header .header-title-muted-icon svg { + width: 100%; + height: 100%; +} + .header .header-subtitle { font-size: 13px; color: var(--text-color-muted); @@ -3934,6 +3948,63 @@ image-carousel { background: color-mix(in srgb, var(--highlight-color) 12%, transparent); } +.chat-info-panel-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 16px; +} + +.chat-info-panel-action { + gap: 6px; +} + +.chat-info-panel-action app-icon { + width: 18px; + height: 18px; + display: inline-flex; +} + +.chat-info-panel-action app-icon svg { + width: 100%; + height: 100%; +} + +.chat-info-panel-action-skeleton { + width: 90px; + height: 34px; +} + +.chat-info-panel-action.muted { + color: var(--error-color); + background-color: color-mix(in srgb, var(--error-color) 12%, transparent); +} + +@media (hover: hover) { + .rounded-button.chat-info-panel-action.muted:not(:disabled):hover { + background-color: color-mix(in srgb, var(--error-color) 20%, transparent); + } +} + +.convo-item.is-muted { + opacity: 0.6; +} + +.convo-item .convo-muted-icon { + display: inline-flex; + width: 14px; + height: 14px; + margin-left: 2px; + vertical-align: -2px; + color: var(--text-color-muted); +} + +.convo-item .convo-muted-icon svg { + width: 100%; + height: 100%; +} + .message-day-title { font-size: 12px; color: var(--text-color-muted); @@ -7100,6 +7171,7 @@ context-menu-item-group > context-menu-item > button { display: flex; align-items: center; opacity: 0; + touch-action: none; top: calc( env(safe-area-inset-top, 0px) + 8px + var(--toast-stack-offset, 0px) ); diff --git a/src/img/icons/custom/bell-off-line.svg b/src/img/icons/custom/bell-off-line.svg new file mode 100644 index 00000000..156cf006 --- /dev/null +++ b/src/img/icons/custom/bell-off-line.svg @@ -0,0 +1 @@ + diff --git a/src/img/icons/custom/bell-off.svg b/src/img/icons/custom/bell-off.svg new file mode 100644 index 00000000..7b6db26e --- /dev/null +++ b/src/img/icons/custom/bell-off.svg @@ -0,0 +1 @@ + diff --git a/src/js/api.js b/src/js/api.js index f4c7c74a..62554152 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -871,6 +871,32 @@ export class Api { return res.data; } + async muteConvo(convoId) { + const res = await this.request("chat.bsky.convo.muteConvo", { + method: "POST", + body: { + convoId, + }, + headers: { + "atproto-proxy": this.chatAppViewServiceDid, + }, + }); + return res.data; + } + + async unmuteConvo(convoId) { + const res = await this.request("chat.bsky.convo.unmuteConvo", { + method: "POST", + body: { + convoId, + }, + headers: { + "atproto-proxy": this.chatAppViewServiceDid, + }, + }); + return res.data; + } + async getConvoAvailability(memberDids, { labelers = [] } = {}) { const res = await this.request("chat.bsky.convo.getConvoAvailability", { query: { members: memberDids }, diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index cf5e3c2f..adc0c72d 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -472,7 +472,7 @@ export class Derived extends ReactiveStore { return preferences.getLabelerSettings(labelerDid); }); this.$convos = new ComputedMap((convoId) => - this.dataStore.$convos.get(convoId), + this.patchStore.$patchedConvos.get(convoId), ); this.$convoList = new Signal.Computed(() => { const data = this.dataStore.$convoList.get(); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 90e9b64e..eb85226d 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -1320,29 +1320,55 @@ export class Mutations { return updatedConvo; } - async rejectConvo(convo) { - await this.api.leaveConvo(convo.id); - this.dataStore.$convos.set(convo.id, null); - const convoList = this.dataStore.$convoList.get(); - if (convoList) { + async leaveConvo(convo) { + const convoId = convo.id; + await this.api.leaveConvo(convoId); + this.dataStore.$convos.set(convoId, null); + const list = this.dataStore.$convoList.get(); + if (list) { this.dataStore.$convoList.set({ - convos: convoList.convos.filter( - (listConvo) => listConvo.id !== convo.id, - ), - cursor: convoList.cursor, + convos: list.convos.filter((listConvo) => listConvo.id !== convoId), + cursor: list.cursor, }); } - const convoRequestList = this.dataStore.$convoRequestList.get(); - if (convoRequestList) { + } + + async rejectConvo(convo) { + const convoId = convo.id; + await this.api.leaveConvo(convoId); + this.dataStore.$convos.set(convoId, null); + const requestList = this.dataStore.$convoRequestList.get(); + if (requestList) { this.dataStore.$convoRequestList.set({ - convos: convoRequestList.convos.filter( - (listConvo) => listConvo.id !== convo.id, + convos: requestList.convos.filter( + (listConvo) => listConvo.id !== convoId, ), - cursor: convoRequestList.cursor, + cursor: requestList.cursor, }); } } + async setConvoMuted(convo, muted) { + const convoId = convo.id; + const patchId = this.patchStore.addConvoPatch(convoId, { + type: "setConvoMuted", + muted, + }); + try { + if (muted) { + await this.api.muteConvo(convoId); + } else { + await this.api.unmuteConvo(convoId); + } + const latest = this.dataStore.$convos.get(convoId); + if (latest) { + this.dataStore.$convos.set(convoId, { ...latest, muted }); + } + } finally { + this.patchStore.removeConvoPatch(convoId, patchId); + } + } + async markConvoAsRead(convoId) { const convo = untrack(() => this.dataStore.$convos.get(convoId)); if (!convo?.unreadCount) return; diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 4f126b20..280248ae 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -41,6 +41,13 @@ export class PatchStore extends ReactiveStore { } return patchedMessage; }); + this.$convoPatches = new SignalMap(); + this.$patchedConvos = new ComputedMap((convoId) => { + const convo = this.dataStore.$convos.get(convoId); + if (!convo) return convo ?? null; + const patches = this.$convoPatches.get(convoId) || []; + return this.applyConvoPatches(convo, patches); + }); this.$preferencePatches = new Signal.State([]); this.$currentUserPatches = new Signal.State([]); this.$authorFeedPatches = new SignalMap(); @@ -292,8 +299,10 @@ export class PatchStore extends ReactiveStore { ...message, reactions: message.reactions.filter( (reaction) => - reaction.sender.did !== currentUserDid && - reaction.value !== value, + !( + reaction.sender.did === currentUserDid && + reaction.value === value + ), ), }; default: @@ -301,6 +310,46 @@ export class PatchStore extends ReactiveStore { } } + /* Convo Patches */ + + _getConvoPatches(convoId) { + return this.$convoPatches.get(convoId) || []; + } + + addConvoPatch(convoId, patchBody) { + const patchId = this.uuid.create(); + this.$convoPatches.set(convoId, [ + ...this._getConvoPatches(convoId), + { id: patchId, body: patchBody }, + ]); + return patchId; + } + + removeConvoPatch(convoId, patchId) { + this.$convoPatches.set( + convoId, + this._getConvoPatches(convoId).filter(({ id }) => id !== patchId), + ); + } + + applyConvoPatches(convo, patches) { + const convoPatches = patches ?? this._getConvoPatches(convo.id); + let patchedConvo = convo; + for (const patch of convoPatches) { + patchedConvo = this.applyConvoPatch(patchedConvo, patch.body); + } + return patchedConvo; + } + + applyConvoPatch(convo, patchBody) { + switch (patchBody.type) { + case "setConvoMuted": + return { ...convo, muted: patchBody.muted }; + default: + throw new Error(`Unknown patch type: ${patchBody.type}`); + } + } + /* Preference Patches */ addPreferencePatch(patchBody) { diff --git a/src/js/templates/header.template.js b/src/js/templates/header.template.js index 168fa4e7..008d0e07 100644 --- a/src/js/templates/header.template.js +++ b/src/js/templates/header.template.js @@ -4,7 +4,12 @@ import { arrowLeftIconTemplate } from "/js/templates/icons/arrowLeft.template.js import { classnames } from "/js/utils.js"; import "/js/components/container-link.js"; -function avatarAndTitleTemplate({ title, subtitle, avatarTemplate }) { +function avatarAndTitleTemplate({ + title, + subtitle, + avatarTemplate, + titleRightItemTemplate, +}) { return html`${avatarTemplate ? html`
${avatarTemplate()}
` : ""} @@ -13,7 +18,11 @@ function avatarAndTitleTemplate({ title, subtitle, avatarTemplate }) { class="header-title-container" data-testid="header-title-container" > - ${title} + ${title}${titleRightItemTemplate + ? titleRightItemTemplate() + : ""} ${subtitle ? html`${subtitle}`} ${titleHref ? html` - ${avatarAndTitleTemplate({ title, subtitle, avatarTemplate })} + ${avatarAndTitleTemplate({ + title, + subtitle, + avatarTemplate, + titleRightItemTemplate, + })} ` - : avatarAndTitleTemplate({ title, subtitle, avatarTemplate })} + : avatarAndTitleTemplate({ + title, + subtitle, + avatarTemplate, + titleRightItemTemplate, + })} ${showLoadingSpinner ? html`
` diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index 8a6bc1e1..6aef6ced 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -19,6 +19,7 @@ import { messagePlusIconTemplate } from "/js/templates/icons/messagePlusIcon.tem import "/js/components/infinite-scroll-container.js"; import "/js/components/container-link.js"; import "/js/components/new-chat-dialog.js"; +import "/js/components/app-icon.js"; class ChatView extends View { async render({ @@ -69,8 +70,11 @@ class ChatView extends View { const isUnread = convo.unreadCount > 0; return html`
@@ -87,8 +91,14 @@ class ChatView extends View {
${groupDetails ? groupDetails.name : getDisplayName(otherUser)} + ${convo.muted + ? html`` + : ""}
- ${timeAgo ? html`
${timeAgo}
` : ""}
diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index f30cba13..579b7c3b 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -53,6 +53,7 @@ import "/js/components/emoji-picker-dialog.js"; import "/js/components/reactions-dialog.js"; import "/js/components/context-menu.js"; import "/js/components/context-menu-item.js"; +import "/js/components/app-icon.js"; class ChatDetailView extends View { async render({ root, @@ -1406,6 +1407,14 @@ class ChatDetailView extends View { }, title, subtitle, + titleRightItemTemplate: convo?.muted + ? () => + html`` + : null, titleHref: groupDetails && canViewGroupDetails ? linkToGroupChatDetails(convoId) diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index 4ffb74a1..cf8c83d7 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -13,7 +13,10 @@ import { import { formatFullDate } from "/js/utils.js"; import { Signal } from "/js/signals.js"; import { ApiError } from "/js/api.js"; +import { showToast } from "/js/toasts.js"; +import { confirmModal } from "/js/modals/confirm.modal.js"; import "/js/components/infinite-scroll-container.js"; +import "/js/components/app-icon.js"; function sortMembers({ members, ownerDid, currentUserDid }) { const rank = (member) => { @@ -55,7 +58,14 @@ function memberTrailingTemplate({ member, ownerDid }) {
`; } -function groupHeaderCardTemplate({ convo, groupDetails, currentUserDid }) { +function groupHeaderCardTemplate({ + convo, + groupDetails, + currentUserDid, + isMuteSaving, + onToggleMute, + onLeave, +}) { const otherMembers = convo.members.filter( (member) => member.did !== currentUserDid, ); @@ -73,6 +83,34 @@ function groupHeaderCardTemplate({ convo, groupDetails, currentUserDid }) { Created ${formatFullDate(groupDetails.createdAt)}
` : ""} +
+ + +
`; } @@ -93,6 +131,14 @@ function groupHeaderCardSkeletonTemplate() { class="skeleton-line-shorter skeleton-animate group-chat-header-skeleton-handle-line" > +
+
+
+
`; } @@ -133,10 +179,16 @@ function notGroupConvoTemplate() { } class GroupChatDetailsView extends View { - async render({ root, params, context: { dataLayer, isAuthenticated } }) { + async render({ + root, + params, + router, + context: { dataLayer, isAuthenticated }, + }) { await auth.requireAuth(); const convoId = params.convoId; + const $isMuteSaving = new Signal.State(false); const $requestError = new Signal.Computed(() => { return ( @@ -152,6 +204,57 @@ class GroupChatDetailsView extends View { await dataLayer.requests.loadConvoMembers(convoId); } + async function handleToggleMute(convo, muted) { + $isMuteSaving.set(true); + try { + await dataLayer.mutations.setConvoMuted(convo, muted); + showToast(muted ? "Group chat muted" : "Group chat unmuted"); + } catch (err) { + console.error(err); + showToast("Failed to mute group chat", { style: "error" }); + } finally { + $isMuteSaving.set(false); + } + } + + async function handleLeave(convo, groupDetails) { + const groupName = groupDetails?.name ?? "this group chat"; + const didLeave = await confirmModal( + `Are you sure you want to leave "${groupName}"? You won't be able to rejoin unless you're invited.`, + { + title: "Leave group chat", + confirmButtonText: "Leave group chat", + confirmButtonStyle: "danger", + pendingText: "Leaving", + onConfirm: async () => { + try { + await dataLayer.mutations.leaveConvo(convo); + } catch (err) { + if (err instanceof ApiError) { + if (err.data?.error === "OwnerCannotLeave") { + showToast("Owner must lock the group before leaving.", { + style: "error", + }); + throw err; + } + if (err.data?.error === "InvalidConvo") { + showToast("Conversation not found.", { style: "error" }); + throw err; + } + } + console.error(err); + showToast("Could not leave chat", { style: "error" }); + throw err; + } + }, + }, + ); + if (didLeave) { + router.go("/messages"); + showToast("Left group chat"); + } + } + bindPageTitle(root, () => "Group chat settings"); pageEffect(root, () => { @@ -169,6 +272,7 @@ class GroupChatDetailsView extends View { }) : null; const hasMore = !!memberFeed?.cursor; + const isMuteSaving = $isMuteSaving.get(); render( html`
@@ -189,6 +293,9 @@ class GroupChatDetailsView extends View { convo, groupDetails, currentUserDid: currentUser?.did ?? null, + isMuteSaving, + onToggleMute: (muted) => handleToggleMute(convo, muted), + onLeave: (details) => handleLeave(convo, details), }) : groupHeaderCardSkeletonTemplate()} ${membersHeadingTemplate({ groupDetails })} diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index fe321e60..edf403fc 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -1163,6 +1163,32 @@ export class MockServer { }); }); + await page.route("**/xrpc/chat.bsky.convo.muteConvo*", (route) => { + const body = route.request().postDataJSON(); + const convo = this.convos.find((c) => c.id === body?.convoId); + if (convo) { + convo.muted = true; + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ convo: convo ?? null }), + }); + }); + + await page.route("**/xrpc/chat.bsky.convo.unmuteConvo*", (route) => { + const body = route.request().postDataJSON(); + const convo = this.convos.find((c) => c.id === body?.convoId); + if (convo) { + convo.muted = false; + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ convo: convo ?? null }), + }); + }); + await page.route( "**/xrpc/chat.bsky.convo.getConvoAvailability*", (route) => { diff --git a/tests/e2e/specs/flows/leaveGroupChat.test.js b/tests/e2e/specs/flows/leaveGroupChat.test.js new file mode 100644 index 00000000..e2fd49b7 --- /dev/null +++ b/tests/e2e/specs/flows/leaveGroupChat.test.js @@ -0,0 +1,121 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createGroupConvo, createProfile } from "../../../shared/factories.js"; + +test.describe("Leave group chat from settings", () => { + test("should leave a group chat from settings and route back to /messages", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice], + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + await view.locator('[data-testid="group-settings-leave-button"]').click(); + + const confirmModal = page.locator('[data-testid="confirm-modal"]'); + await expect(confirmModal).toBeVisible({ timeout: 10000 }); + await expect( + confirmModal.locator('[data-testid="modal-message"]'), + ).toContainText("Cool Group"); + await confirmModal.locator('[data-testid="modal-confirm-button"]').click(); + + await expect(page).toHaveURL(/\/messages$/, { timeout: 10000 }); + await expect(page.locator('[data-testid="toast"]')).toContainText( + "Left group chat", + ); + + const chatView = page.locator("#chat-view"); + await expect(chatView.locator(".convo-item")).toHaveCount(0, { + timeout: 10000, + }); + }); + + test("should not leave if the confirm modal is cancelled", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice], + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + await view.locator('[data-testid="group-settings-leave-button"]').click(); + + const confirmModal = page.locator('[data-testid="confirm-modal"]'); + await expect(confirmModal).toBeVisible({ timeout: 10000 }); + await confirmModal.locator('[data-testid="modal-cancel-button"]').click(); + + await expect(page).toHaveURL(/\/messages\/convo-1\/settings$/); + }); + + test("should surface an owner-cannot-leave error as a toast", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice], + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await page.route("**/xrpc/chat.bsky.convo.leaveConvo*", (route) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: "OwnerCannotLeave", + message: "Owner must lock the group before leaving.", + }), + }), + ); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + await view.locator('[data-testid="group-settings-leave-button"]').click(); + + const confirmModal = page.locator('[data-testid="confirm-modal"]'); + await confirmModal.locator('[data-testid="modal-confirm-button"]').click(); + + await expect(page.locator('[data-testid="toast"]')).toContainText( + "Owner must lock the group before leaving.", + { timeout: 10000 }, + ); + await expect(page).toHaveURL(/\/messages\/convo-1\/settings$/); + }); +}); diff --git a/tests/e2e/specs/flows/muteConversation.test.js b/tests/e2e/specs/flows/muteConversation.test.js new file mode 100644 index 00000000..46c59d8c --- /dev/null +++ b/tests/e2e/specs/flows/muteConversation.test.js @@ -0,0 +1,85 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createGroupConvo, createProfile } from "../../../shared/factories.js"; + +test.describe("Mute conversation flow", () => { + test("should mute a group chat from the group settings screen and show a bell-off in the list", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice], + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + const toggle = view.locator('[data-testid="group-settings-mute-toggle"]'); + await expect(toggle).toHaveAttribute("data-teststate", "unmuted", { + timeout: 10000, + }); + + await toggle.click(); + + await expect(page.locator('[data-testid="toast"]')).toContainText( + "Group chat muted", + { timeout: 10000 }, + ); + await expect(toggle).toHaveAttribute("data-teststate", "muted"); + + await page.goto("/messages"); + const chatView = page.locator("#chat-view"); + const row = chatView.locator('[data-testid="convo-item-group"]').first(); + await expect(row).toHaveAttribute("data-teststate", "muted", { + timeout: 10000, + }); + await expect(row.locator('[data-testid="convo-muted-icon"]')).toBeVisible(); + }); + + test("should unmute a previously muted group chat from the settings screen", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice], + muted: true, + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + const toggle = view.locator('[data-testid="group-settings-mute-toggle"]'); + await expect(toggle).toHaveAttribute("data-teststate", "muted", { + timeout: 10000, + }); + + await toggle.click(); + + await expect(page.locator('[data-testid="toast"]')).toContainText( + "Group chat unmuted", + { timeout: 10000 }, + ); + await expect(toggle).toHaveAttribute("data-teststate", "unmuted"); + }); +}); diff --git a/tests/e2e/specs/views/groupChatDetails.view.test.js b/tests/e2e/specs/views/groupChatDetails.view.test.js index 3acbd74f..9674bafe 100644 --- a/tests/e2e/specs/views/groupChatDetails.view.test.js +++ b/tests/e2e/specs/views/groupChatDetails.view.test.js @@ -240,6 +240,54 @@ test.describe("Group chat details view", () => { await expect(view.locator(".profile-list-item")).toHaveCount(0); }); + test("should show mute toggle and leave button in chat actions", async ({ + page, + }) => { + const mockServer = new MockServer(); + const { alice, bob } = createTestMembers(); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice, bob], + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + const toggle = view.locator('[data-testid="group-settings-mute-toggle"]'); + await expect(toggle).toBeVisible({ timeout: 10000 }); + await expect(toggle).toHaveAttribute("data-teststate", "unmuted"); + await expect( + view.locator('[data-testid="group-settings-leave-button"]'), + ).toContainText("Leave"); + }); + + test("should reflect muted state on the group mute toggle", async ({ + page, + }) => { + const mockServer = new MockServer(); + const { alice, bob } = createTestMembers(); + const convo = createGroupConvo({ + id: "convo-1", + name: "Cool Group", + otherMembers: [alice, bob], + muted: true, + }); + mockServer.addConvos([convo]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1/settings"); + + const view = page.locator("#group-chat-details-view"); + await expect( + view.locator('[data-testid="group-settings-mute-toggle"]'), + ).toHaveAttribute("data-teststate", "muted", { timeout: 10000 }); + }); + test("should show not found for an unknown conversation", async ({ page, }) => { diff --git a/tests/shared/factories.js b/tests/shared/factories.js index f6d9149a..3449f829 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -30,6 +30,7 @@ export function createConvo({ lastMessage, status = "accepted", unreadCount = 0, + muted = false, }) { return { id, @@ -37,6 +38,7 @@ export function createConvo({ members: [createTestUserMember(), otherMember], status, unreadCount, + muted, lastMessage: lastMessage || undefined, }; } @@ -51,6 +53,7 @@ export function createGroupConvo({ lockStatus = "unlocked", memberCount, ownerDid, + muted = false, }) { let members = [createTestUserMember(), ...otherMembers]; if (ownerDid) { @@ -68,7 +71,7 @@ export function createGroupConvo({ members, status, unreadCount, - muted: false, + muted, lastMessage: lastMessage || undefined, kind: { $type: "chat.bsky.convo.defs#groupConvo", diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 3cad6bb6..6d66c749 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -2728,8 +2728,9 @@ describe("acceptConvo", () => { describe("rejectConvo", () => { const convo = { id: "convo-1", status: "request" }; - it("should clear the convo and remove it from the convo list", async () => { - const otherConvo = { id: "convo-2", status: "accepted" }; + it("should clear the convo, call api.leaveConvo, and remove it from the request list only", async () => { + const otherAccepted = { id: "convo-2", status: "accepted" }; + const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(); const patchStore = new PatchStore(dataStore); const mockPreferencesProvider = { @@ -2737,9 +2738,13 @@ describe("rejectConvo", () => { }; dataStore.$convos.set(convo.id, convo); dataStore.$convoList.set({ - convos: [convo, otherConvo], + convos: [otherAccepted], cursor: "list-cursor", }); + dataStore.$convoRequestList.set({ + convos: [convo, otherRequest], + cursor: "request-cursor", + }); let leaveCalledWith = null; const mutations = makeMutations( { @@ -2755,15 +2760,24 @@ describe("rejectConvo", () => { await mutations.rejectConvo(convo); assert.deepEqual(leaveCalledWith, convo.id); - // Mutations sets the convo signal to null on reject (was `undefined` pre-refactor). assert.deepEqual(dataStore.$convos.get(convo.id), null); - const list = dataStore.$convoList.get(); - assert.deepEqual(list.convos.length, 1); - assert.deepEqual(list.convos[0].id, otherConvo.id); - assert.deepEqual(list.cursor, "list-cursor"); + const requestList = dataStore.$convoRequestList.get(); + assert.deepEqual(requestList.convos.length, 1); + assert.deepEqual(requestList.convos[0].id, otherRequest.id); + assert.deepEqual(requestList.cursor, "request-cursor"); + // Accepted list must not be touched by rejectConvo. + const acceptedList = dataStore.$convoList.get(); + assert.deepEqual(acceptedList.convos.length, 1); + assert.deepEqual(acceptedList.convos[0].id, otherAccepted.id); + assert.deepEqual(acceptedList.cursor, "list-cursor"); }); +}); - it("should remove the convo from the request list", async () => { +describe("leaveConvo", () => { + const convo = { id: "convo-1", status: "accepted" }; + + it("should clear the convo, call api.leaveConvo, and remove it from the accepted list only", async () => { + const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(); const patchStore = new PatchStore(dataStore); @@ -2771,26 +2785,181 @@ describe("rejectConvo", () => { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); + dataStore.$convoList.set({ + convos: [convo, otherAccepted], + cursor: "list-cursor", + }); dataStore.$convoRequestList.set({ - convos: [convo, otherRequest], + convos: [otherRequest], cursor: "request-cursor", }); + let leaveCalledWith = null; const mutations = makeMutations( { - leaveConvo: async () => {}, + leaveConvo: async (id) => { + leaveCalledWith = id; + }, }, dataStore, patchStore, mockPreferencesProvider, ); - await mutations.rejectConvo(convo); + await mutations.leaveConvo(convo); + assert.deepEqual(leaveCalledWith, convo.id); + assert.deepEqual(dataStore.$convos.get(convo.id), null); + const acceptedList = dataStore.$convoList.get(); + assert.deepEqual(acceptedList.convos.length, 1); + assert.deepEqual(acceptedList.convos[0].id, otherAccepted.id); + assert.deepEqual(acceptedList.cursor, "list-cursor"); + // Request list must not be touched by leaveConvo. const requestList = dataStore.$convoRequestList.get(); assert.deepEqual(requestList.convos.length, 1); assert.deepEqual(requestList.convos[0].id, otherRequest.id); assert.deepEqual(requestList.cursor, "request-cursor"); }); + + it("should leave the store unchanged when api.leaveConvo throws", async () => { + const otherAccepted = { id: "convo-2", status: "accepted" }; + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$convos.set(convo.id, convo); + dataStore.$convoList.set({ + convos: [convo, otherAccepted], + cursor: "list-cursor", + }); + const mutations = makeMutations( + { + leaveConvo: async () => { + throw new Error("boom"); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await assert.rejects(() => mutations.leaveConvo(convo)); + + assert.deepEqual(dataStore.$convos.get(convo.id), convo); + const acceptedList = dataStore.$convoList.get(); + assert.deepEqual(acceptedList.convos.length, 2); + assert.deepEqual(acceptedList.convos[0].id, convo.id); + }); +}); + +describe("setConvoMuted", () => { + const convo = { id: "convo-1", muted: false }; + + it("should optimistically patch, then write to dataStore and clear the patch on success", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$convos.set(convo.id, convo); + let muteCalledWith = null; + let patchDuringApi = null; + const mutations = makeMutations( + { + muteConvo: async (id) => { + muteCalledWith = id; + patchDuringApi = patchStore.$patchedConvos.get(convo.id); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await mutations.setConvoMuted(convo, true); + + assert.deepEqual(muteCalledWith, convo.id); + // While the API call was in flight the patched view was already muted. + assert.deepEqual(patchDuringApi.muted, true); + // On success the dataStore is updated and the patch is cleared. + assert.deepEqual(dataStore.$convos.get(convo.id).muted, true); + assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); + }); + + it("should call api.unmuteConvo when muted=false", async () => { + const mutedConvo = { id: "convo-1", muted: true }; + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$convos.set(mutedConvo.id, mutedConvo); + let unmuteCalledWith = null; + const mutations = makeMutations( + { + unmuteConvo: async (id) => { + unmuteCalledWith = id; + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await mutations.setConvoMuted(mutedConvo, false); + + assert.deepEqual(unmuteCalledWith, mutedConvo.id); + assert.deepEqual(dataStore.$convos.get(mutedConvo.id).muted, false); + }); + + it("should revert the optimistic patch and leave the dataStore unchanged when the api throws", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$convos.set(convo.id, convo); + const mutations = makeMutations( + { + muteConvo: async () => { + throw new Error("boom"); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await assert.rejects(() => mutations.setConvoMuted(convo, true)); + + assert.deepEqual(dataStore.$convos.get(convo.id).muted, false); + assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); + assert.deepEqual(patchStore.$patchedConvos.get(convo.id).muted, false); + }); + + it("should not write to dataStore if the underlying convo was cleared during the api call", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.$convos.set(convo.id, convo); + const mutations = makeMutations( + { + muteConvo: async () => { + dataStore.$convos.set(convo.id, null); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await mutations.setConvoMuted(convo, true); + + assert.deepEqual(dataStore.$convos.get(convo.id), null); + assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); + }); }); describe("markConvoAsRead", () => { diff --git a/tests/unit/specs/dataLayer/patchStore.test.js b/tests/unit/specs/dataLayer/patchStore.test.js index f02a65a7..536d1cb9 100644 --- a/tests/unit/specs/dataLayer/patchStore.test.js +++ b/tests/unit/specs/dataLayer/patchStore.test.js @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { PatchStore } from "/js/dataLayer/patchStore.js"; +import { DataStore } from "/js/dataLayer/dataStore.js"; // applyPostPatches now requires the patches array explicitly. This helper // fetches the current patches for a post URI and applies them. @@ -694,3 +695,493 @@ describe("Author Feed Patches", () => { assert.deepEqual(patched.feed[0].reason, undefined); }); }); + +describe("Convo Patches - Patch Management", () => { + const convoId = "convo-1"; + const baseConvo = { id: convoId, muted: false }; + + it("should add a convo patch and return a patch ID", () => { + const patchStore = new PatchStore(); + const patchId = patchStore.addConvoPatch(convoId, { + type: "setConvoMuted", + muted: true, + }); + assert.deepEqual(typeof patchId, "number"); + assert(patchId >= 0); + }); + + it("should generate unique patch IDs across convos", () => { + const patchStore = new PatchStore(); + const id1 = patchStore.addConvoPatch(convoId, { + type: "setConvoMuted", + muted: true, + }); + const id2 = patchStore.addConvoPatch("convo-2", { + type: "setConvoMuted", + muted: true, + }); + assert(id1 !== id2); + }); + + it("should remove a convo patch by ID", () => { + const patchStore = new PatchStore(); + const patchId = patchStore.addConvoPatch(convoId, { + type: "setConvoMuted", + muted: true, + }); + + const patched = patchStore.applyConvoPatches(baseConvo); + assert.deepEqual(patched.muted, true); + + patchStore.removeConvoPatch(convoId, patchId); + + const unpatched = patchStore.applyConvoPatches(baseConvo); + assert.deepEqual(unpatched.muted, false); + }); + + it("should handle removing a non-existent patch ID gracefully", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); + let errorThrown = false; + try { + patchStore.removeConvoPatch(convoId, 999); + } catch (e) { + errorThrown = true; + } + assert.deepEqual(errorThrown, false); + }); +}); + +describe("Convo Patches - setConvoMuted", () => { + const convoId = "convo-1"; + + it("should apply setConvoMuted(true) to a previously unmuted convo", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); + const patched = patchStore.applyConvoPatches({ id: convoId, muted: false }); + assert.deepEqual(patched.muted, true); + assert.deepEqual(patched.id, convoId); + }); + + it("should apply setConvoMuted(false) to a previously muted convo", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: false }); + const patched = patchStore.applyConvoPatches({ id: convoId, muted: true }); + assert.deepEqual(patched.muted, false); + }); + + it("should apply patches in order (last one wins)", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: false }); + const patched = patchStore.applyConvoPatches({ id: convoId, muted: false }); + assert.deepEqual(patched.muted, false); + }); + + it("should preserve unrelated convo fields", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); + const patched = patchStore.applyConvoPatches({ + id: convoId, + muted: false, + rev: "rev-1", + unreadCount: 3, + members: [{ did: "did:plc:a" }], + }); + assert.deepEqual(patched.rev, "rev-1"); + assert.deepEqual(patched.unreadCount, 3); + assert.deepEqual(patched.members, [{ did: "did:plc:a" }]); + }); + + it("should return the convo unchanged when no patches exist", () => { + const patchStore = new PatchStore(); + const convo = { id: convoId, muted: false }; + const patched = patchStore.applyConvoPatches(convo); + assert.deepEqual(patched, convo); + }); +}); + +describe("Convo Patches - Error Handling", () => { + it("should throw for an unknown convo patch type", () => { + const patchStore = new PatchStore(); + patchStore.addConvoPatch("convo-1", { type: "unknownConvoPatch" }); + assert.throws(() => + patchStore.applyConvoPatches({ id: "convo-1", muted: false }), + ); + }); +}); + +describe("Post Patches - Reposts, Bookmarks, HidePost", () => { + const postURI = "at://did:test/app.bsky.feed.post/test"; + const basePost = { + uri: postURI, + likeCount: 5, + repostCount: 2, + bookmarkCount: 1, + viewer: { like: null, repost: null, bookmarked: false, isHidden: false }, + }; + + it("should apply createRepost / deleteRepost patches", () => { + const patchStore = new PatchStore(); + patchStore.addPostPatch(postURI, { type: "createRepost" }); + let result = applyPostPatches(patchStore, basePost); + assert.deepEqual(result.viewer.repost, "fake repost"); + assert.deepEqual(result.repostCount, 3); + + patchStore.addPostPatch(postURI, { type: "deleteRepost" }); + result = applyPostPatches(patchStore, basePost); + assert.deepEqual(result.viewer.repost, null); + assert.deepEqual(result.repostCount, 2); + }); + + it("should apply addBookmark / removeBookmark patches", () => { + const patchStore = new PatchStore(); + patchStore.addPostPatch(postURI, { type: "addBookmark" }); + let result = applyPostPatches(patchStore, basePost); + assert.deepEqual(result.viewer.bookmarked, true); + assert.deepEqual(result.bookmarkCount, 2); + + const bookmarkedPost = { + ...basePost, + viewer: { ...basePost.viewer, bookmarked: true }, + bookmarkCount: 2, + }; + const patchStore2 = new PatchStore(); + patchStore2.addPostPatch(postURI, { type: "removeBookmark" }); + result = applyPostPatches(patchStore2, bookmarkedPost); + assert.deepEqual(result.viewer.bookmarked, false); + assert.deepEqual(result.bookmarkCount, 1); + }); + + it("should apply hidePost patch", () => { + const patchStore = new PatchStore(); + patchStore.addPostPatch(postURI, { type: "hidePost" }); + const result = applyPostPatches(patchStore, basePost); + assert.deepEqual(result.viewer.isHidden, true); + }); +}); + +function applyProfilePatchesForDid(patchStore, profile) { + return patchStore.applyProfilePatches(profile); +} + +describe("Profile Patches - Mute/Block/NotificationSubscription", () => { + const did = "did:plc:test"; + const baseProfile = { + did, + followersCount: 5, + viewer: { + muted: false, + blocking: null, + following: null, + activitySubscription: null, + }, + }; + + it("should apply muteProfile / unmuteProfile patches", () => { + const patchStore = new PatchStore(); + patchStore.addProfilePatch(did, { type: "muteProfile" }); + let result = applyProfilePatchesForDid(patchStore, baseProfile); + assert.deepEqual(result.viewer.muted, true); + + const patchStore2 = new PatchStore(); + patchStore2.addProfilePatch(did, { type: "unmuteProfile" }); + result = applyProfilePatchesForDid(patchStore2, { + ...baseProfile, + viewer: { ...baseProfile.viewer, muted: true }, + }); + assert.deepEqual(result.viewer.muted, false); + }); + + it("should apply blockProfile / unblockProfile patches", () => { + const patchStore = new PatchStore(); + patchStore.addProfilePatch(did, { type: "blockProfile" }); + let result = applyProfilePatchesForDid(patchStore, baseProfile); + assert.deepEqual(result.viewer.blocking, "fake blocking"); + + const patchStore2 = new PatchStore(); + patchStore2.addProfilePatch(did, { type: "unblockProfile" }); + result = applyProfilePatchesForDid(patchStore2, { + ...baseProfile, + viewer: { ...baseProfile.viewer, blocking: "some-block-uri" }, + }); + assert.deepEqual(result.viewer.blocking, null); + }); + + it("should apply updatePostNotificationSubscription patch", () => { + const patchStore = new PatchStore(); + patchStore.addProfilePatch(did, { + type: "updatePostNotificationSubscription", + activitySubscription: { post: true, reply: false }, + }); + const result = applyProfilePatchesForDid(patchStore, baseProfile); + assert.deepEqual(result.viewer.activitySubscription, { + post: true, + reply: false, + }); + }); +}); + +describe("Message Patches", () => { + const messageId = "msg-1"; + const currentUserDid = "did:plc:me"; + const otherDid = "did:plc:other"; + const baseMessage = { + id: messageId, + reactions: [], + }; + + it("should add and remove a message patch by ID", () => { + const patchStore = new PatchStore(); + const patchId = patchStore.addMessagePatch(messageId, { + type: "addReaction", + reaction: { sender: { did: currentUserDid }, value: "👍" }, + }); + assert.deepEqual(typeof patchId, "number"); + + let patched = patchStore.applyMessagePatches(baseMessage); + assert.deepEqual(patched.reactions.length, 1); + assert.deepEqual(patched.reactions[0].value, "👍"); + + patchStore.removeMessagePatch(messageId, patchId); + patched = patchStore.applyMessagePatches(baseMessage); + assert.deepEqual(patched.reactions.length, 0); + }); + + it("should apply removeReaction patch for the current user's reaction", () => { + const patchStore = new PatchStore(); + const messageWithReaction = { + id: messageId, + reactions: [{ sender: { did: currentUserDid }, value: "👍" }], + }; + patchStore.addMessagePatch(messageId, { + type: "removeReaction", + currentUserDid, + value: "👍", + }); + const patched = patchStore.applyMessagePatches(messageWithReaction); + assert.deepEqual(patched.reactions.length, 0); + }); + + it("should keep other users' matching reactions and the user's other-emoji reactions when removing one reaction", () => { + const patchStore = new PatchStore(); + const messageWithReactions = { + id: messageId, + reactions: [ + { sender: { did: currentUserDid }, value: "👍" }, + { sender: { did: otherDid }, value: "👍" }, + { sender: { did: currentUserDid }, value: "❤️" }, + ], + }; + patchStore.addMessagePatch(messageId, { + type: "removeReaction", + currentUserDid, + value: "👍", + }); + const patched = patchStore.applyMessagePatches(messageWithReactions); + const surviving = patched.reactions.map( + (reaction) => `${reaction.sender.did}:${reaction.value}`, + ); + assert.deepEqual(surviving, [`${otherDid}:👍`, `${currentUserDid}:❤️`]); + }); + + it("should throw for an unknown message patch type", () => { + const patchStore = new PatchStore(); + patchStore.addMessagePatch(messageId, { type: "nope" }); + assert.throws(() => patchStore.applyMessagePatches(baseMessage)); + }); + + it("should expose the overlay via $patchedMessages", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + dataStore.$messages.set(messageId, baseMessage); + + assert.deepEqual(patchStore.$patchedMessages.get(messageId).reactions, []); + + patchStore.addMessagePatch(messageId, { + type: "addReaction", + reaction: { sender: { did: currentUserDid }, value: "🎉" }, + }); + const patched = patchStore.$patchedMessages.get(messageId); + assert.deepEqual(patched.reactions.length, 1); + assert.deepEqual(patched.reactions[0].value, "🎉"); + // Underlying store should not be mutated by the overlay. + assert.deepEqual(dataStore.$messages.get(messageId).reactions, []); + }); + + it("should return null from $patchedMessages when the underlying message is absent", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + assert.deepEqual(patchStore.$patchedMessages.get("missing"), null); + }); +}); + +describe("Preference Patches - unpinFeed", () => { + it("should apply unpinFeed by delegating to preferences.unpinFeed", () => { + const patchStore = new PatchStore(); + const seen = []; + const mockPreferences = { + clone: () => mockPreferences, + unpinFeed: (feedUri) => { + seen.push(feedUri); + return { after: feedUri }; + }, + }; + patchStore.addPreferencePatch({ + type: "unpinFeed", + feedUri: "at://feed/1", + }); + const result = patchStore.applyPreferencePatches(mockPreferences); + assert.deepEqual(seen, ["at://feed/1"]); + assert.deepEqual(result, { after: "at://feed/1" }); + }); +}); + +describe("Author Feed Patches - pinPost apply", () => { + const feedURI = "did:plc:author-posts"; + const targetPost = { uri: "at://did:plc:author/app.bsky.feed.post/x" }; + const otherPost = { uri: "at://did:plc:author/app.bsky.feed.post/y" }; + + it("should prepend the pinned post with a reasonPin marker", () => { + const patchStore = new PatchStore(); + patchStore.addAuthorFeedPatch(feedURI, { + type: "pinPost", + post: targetPost, + }); + const feed = { + feed: [{ post: otherPost }, { post: targetPost }], + cursor: "c", + }; + const patched = patchStore.applyAuthorFeedPatches(feedURI, feed); + assert.deepEqual(patched.feed[0].post.uri, targetPost.uri); + assert.deepEqual( + patched.feed[0].reason?.$type, + "app.bsky.feed.defs#reasonPin", + ); + // Second entry is the other post; the original targetPost occurrence is dropped. + assert.deepEqual(patched.feed.length, 2); + assert.deepEqual(patched.feed[1].post.uri, otherPost.uri); + assert.deepEqual(patched.cursor, "c"); + }); +}); + +describe("Post Patches - $patchedPosts overlay", () => { + it("should overlay post + author profile patches", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const postURI = "at://did:plc:author/app.bsky.feed.post/1"; + const post = { + uri: postURI, + likeCount: 3, + viewer: { like: null }, + author: { + did: "did:plc:author", + viewer: { following: null }, + followersCount: 10, + }, + }; + dataStore.$posts.set(postURI, post); + + patchStore.addPostPatch(postURI, { type: "addLike" }); + patchStore.addProfilePatch("did:plc:author", { type: "followProfile" }); + + const patched = patchStore.$patchedPosts.get(postURI); + assert.deepEqual(patched.viewer.like, "fake like"); + assert.deepEqual(patched.likeCount, 4); + assert.deepEqual(patched.author.viewer.following, "fake following"); + assert.deepEqual(patched.author.followersCount, 11); + // Underlying store isn't mutated. + assert.deepEqual(dataStore.$posts.get(postURI).likeCount, 3); + assert.deepEqual(dataStore.$posts.get(postURI).author.followersCount, 10); + }); + + it("should return null when the underlying post is absent", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + assert.deepEqual(patchStore.$patchedPosts.get("missing"), null); + }); +}); + +describe("Profile Patches - $patchedProfiles / $patchedDetailedProfiles overlays", () => { + const did = "did:plc:x"; + + it("should overlay profile patches on $profiles reads", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + dataStore.$profiles.set(did, { + did, + followersCount: 4, + viewer: { following: null }, + }); + patchStore.addProfilePatch(did, { type: "followProfile" }); + const patched = patchStore.$patchedProfiles.get(did); + assert.deepEqual(patched.viewer.following, "fake following"); + assert.deepEqual(patched.followersCount, 5); + // Underlying store unchanged. + assert.deepEqual(dataStore.$profiles.get(did).followersCount, 4); + }); + + it("should overlay profile patches on $detailedProfiles reads", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + dataStore.$detailedProfiles.set(did, { + did, + followersCount: 4, + viewer: { following: null }, + description: "hello", + }); + patchStore.addProfilePatch(did, { type: "followProfile" }); + const patched = patchStore.$patchedDetailedProfiles.get(did); + assert.deepEqual(patched.viewer.following, "fake following"); + assert.deepEqual(patched.description, "hello"); + }); + + it("should return null when the underlying profile is absent", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + assert.deepEqual(patchStore.$patchedProfiles.get("missing"), null); + assert.deepEqual(patchStore.$patchedDetailedProfiles.get("missing"), null); + }); +}); + +describe("Convo Patches - $patchedConvos", () => { + it("should overlay the patch on top of the dataStore convo", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const convo = { id: "convo-1", muted: false, rev: "rev-1" }; + dataStore.$convos.set("convo-1", convo); + + assert.deepEqual(patchStore.$patchedConvos.get("convo-1").muted, false); + + patchStore.addConvoPatch("convo-1", { + type: "setConvoMuted", + muted: true, + }); + const patched = patchStore.$patchedConvos.get("convo-1"); + assert.deepEqual(patched.muted, true); + assert.deepEqual(patched.rev, "rev-1"); + // Underlying store is not mutated by the patch overlay. + assert.deepEqual(dataStore.$convos.get("convo-1").muted, false); + }); + + it("should return null when the underlying convo is absent", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + assert.deepEqual(patchStore.$patchedConvos.get("nope"), null); + }); + + it("should isolate patches between different convos", () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + dataStore.$convos.set("convo-1", { id: "convo-1", muted: false }); + dataStore.$convos.set("convo-2", { id: "convo-2", muted: false }); + + patchStore.addConvoPatch("convo-1", { + type: "setConvoMuted", + muted: true, + }); + + assert.deepEqual(patchStore.$patchedConvos.get("convo-1").muted, true); + assert.deepEqual(patchStore.$patchedConvos.get("convo-2").muted, false); + }); +});