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`
`
: ""}
@@ -13,7 +18,11 @@ function avatarAndTitleTemplate({ title, subtitle, avatarTemplate }) {
class="header-title-container"
data-testid="header-title-container"
>
-
+
${subtitle
? html``}
${titleHref
? html``
- : avatarAndTitleTemplate({ title, subtitle, avatarTemplate })}
+ : avatarAndTitleTemplate({
+ title,
+ subtitle,
+ avatarTemplate,
+ titleRightItemTemplate,
+ })}
${showLoadingSpinner
? html`
@@ -87,8 +91,14 @@ class ChatView extends View {
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);
+ });
+});