diff --git a/package.json b/package.json
index 9d2cf14e..898de4e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.20.99",
+ "version": "0.20.100",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js
index 48a1e61e..8f528a9f 100644
--- a/src/js/dataHelpers.js
+++ b/src/js/dataHelpers.js
@@ -1112,6 +1112,47 @@ export function buildPostgateEmbeddingRules(quotesEnabled) {
return quotesEnabled ? null : [{ $type: DISABLE_EMBEDDING_RULE_TYPE }];
}
+export const MAX_DETACHED_EMBEDDING_URIS = 50;
+
+export function createDetachedQuote(uri) {
+ return { $type: "app.bsky.embed.record#viewDetached", uri, detached: true };
+}
+
+// Returns the quote embed of `post` if it embeds a post authored by `did`,
+// as `{ uri, isDetached }`, or null otherwise.
+export function getOwnQuoteEmbed(post, did) {
+ const quotedPost = getQuotedPost(post);
+ if (!quotedPost) {
+ return null;
+ }
+ if (quotedPost.$type === "app.bsky.embed.record#viewDetached") {
+ return parseUri(quotedPost.uri).repo === did
+ ? { uri: quotedPost.uri, isDetached: true }
+ : null;
+ }
+ if (
+ quotedPost.$type === "app.bsky.embed.record#viewRecord" &&
+ quotedPost.author?.did === did
+ ) {
+ return { uri: quotedPost.uri, isDetached: false };
+ }
+ return null;
+}
+
+export function replaceQuotedPost(post, quotedPost) {
+ const embed = post.embed;
+ if (embed?.$type === "app.bsky.embed.record#view") {
+ return { ...post, embed: { ...embed, record: quotedPost } };
+ }
+ if (embed?.$type === "app.bsky.embed.recordWithMedia#view") {
+ return {
+ ...post,
+ embed: { ...embed, record: { ...embed.record, record: quotedPost } },
+ };
+ }
+ return post;
+}
+
// Adds a feed item to the beginning of a feed, preserving pinned post position.
export function addFeedItemToFeed(feedItem, feed) {
const newFeed = [];
diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js
index 79e8e5e3..8173d016 100644
--- a/src/js/dataLayer/mutations.js
+++ b/src/js/dataLayer/mutations.js
@@ -17,6 +17,10 @@ import {
buildCdnUrl,
getReplyRootFromPost,
insertReplyIntoThread,
+ MAX_DETACHED_EMBEDDING_URIS,
+ createDetachedQuote,
+ createEmbedFromPost,
+ replaceQuotedPost,
} from "/js/dataHelpers.js";
import {
addPostLanguagesToHistory,
@@ -32,6 +36,15 @@ import { PostCreator } from "/js/postCreator.js";
import { untrack } from "/js/signals.js";
import { generateTid } from "/js/atproto.js";
+export class DetachedQuoteLimitError extends Error {
+ constructor() {
+ super(
+ `Posts can have at most ${MAX_DETACHED_EMBEDDING_URIS} detached quotes`,
+ );
+ this.name = "DetachedQuoteLimitError";
+ }
+}
+
// Handles mutations to the data, making optimistic updates if needed.
export class Mutations {
constructor(
@@ -1929,6 +1942,94 @@ export class Mutations {
}
}
+ async toggleQuoteDetachment(quotingPost, quotedPostUri, detach) {
+ const currentUser = this.dataStore.$currentUser.get();
+ if (!currentUser) throw new Error("No current user");
+ const { repo, rkey } = parseUri(quotedPostUri);
+ if (currentUser.did !== repo) {
+ throw new Error("Only the quoted post's author can detach quotes");
+ }
+
+ let quotedPost = detach
+ ? createDetachedQuote(quotedPostUri)
+ : this._getCachedQuoteEmbed(quotedPostUri);
+ const patchId = quotedPost
+ ? this.patchStore.addPostPatch(quotingPost.uri, {
+ type: "setQuotedPost",
+ quotedPost,
+ })
+ : null;
+ try {
+ let priorPostgateRecord = null;
+ try {
+ priorPostgateRecord = await this.api.getPostgateRecord(rkey);
+ } catch (error) {
+ if (!isRecordNotFoundError(error)) throw error;
+ }
+ const priorUris = priorPostgateRecord?.value?.detachedEmbeddingUris ?? [];
+ if (priorPostgateRecord || detach) {
+ let detachedEmbeddingUris;
+ if (detach) {
+ if (
+ !priorUris.includes(quotingPost.uri) &&
+ priorUris.length >= MAX_DETACHED_EMBEDDING_URIS
+ ) {
+ throw new DetachedQuoteLimitError();
+ }
+ detachedEmbeddingUris = [
+ ...priorUris.filter((uri) => uri !== quotingPost.uri),
+ quotingPost.uri,
+ ];
+ } else {
+ detachedEmbeddingUris = priorUris.filter(
+ (uri) => uri !== quotingPost.uri,
+ );
+ }
+ await this.api.putPostgateRecord(
+ rkey,
+ {
+ ...priorPostgateRecord?.value,
+ $type: "app.bsky.feed.postgate",
+ post: quotedPostUri,
+ createdAt: getCurrentTimestamp(),
+ detachedEmbeddingUris,
+ },
+ priorPostgateRecord?.cid ?? null,
+ );
+ }
+
+ if (!quotedPost) {
+ try {
+ quotedPost = createEmbedFromPost(
+ await this.api.getPost(quotedPostUri),
+ );
+ } catch (error) {
+ console.warn("Failed to load re-attached quote", error);
+ }
+ }
+ const latestPost = this.dataStore.$posts.get(quotingPost.uri);
+ if (latestPost && quotedPost) {
+ this.dataStore.$posts.set(
+ quotingPost.uri,
+ replaceQuotedPost(latestPost, quotedPost),
+ );
+ }
+ } finally {
+ if (patchId !== null) {
+ this.patchStore.removePostPatch(quotingPost.uri, patchId);
+ }
+ }
+ }
+
+ _getCachedQuoteEmbed(postUri) {
+ const post = untrack(
+ () =>
+ this.dataStore.$posts.get(postUri) ??
+ this.dataStore.$embeddedPosts.get(postUri),
+ );
+ return post ? createEmbedFromPost(post) : null;
+ }
+
async updateThreadgateAllow(post, threadgateAllow) {
const currentUser = this.dataStore.$currentUser.get();
if (!currentUser) throw new Error("No current user");
diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js
index 55e531be..3b2323b7 100644
--- a/src/js/dataLayer/patchStore.js
+++ b/src/js/dataLayer/patchStore.js
@@ -1,5 +1,9 @@
import { deepClone, SimpleUUID } from "/js/utils.js";
-import { pinPostInFeed, unpinPostInFeed } from "/js/dataHelpers.js";
+import {
+ pinPostInFeed,
+ unpinPostInFeed,
+ replaceQuotedPost,
+} from "/js/dataHelpers.js";
import { Signal, SignalMap, ReactiveStore } from "/js/signals.js";
// The store saves patch data for optimistic updates.
@@ -127,6 +131,8 @@ export class PatchStore extends ReactiveStore {
embeddingDisabled: patchBody.embeddingDisabled,
},
};
+ case "setQuotedPost":
+ return replaceQuotedPost(post, patchBody.quotedPost);
case "setThreadgateAllow": {
const record = {
$type: "app.bsky.feed.threadgate",
diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js
index b5a02d2b..8431c64d 100644
--- a/src/js/postInteractionHandler.js
+++ b/src/js/postInteractionHandler.js
@@ -2,7 +2,11 @@ import { hapticsImpactMedium } from "/js/haptics.js";
import { showToast } from "/js/toasts.js";
import { confirmModal } from "/js/modals/confirm.modal.js";
import { html } from "/js/lib/lit-html.js";
-import { buildPostgateEmbeddingRules } from "/js/dataHelpers.js";
+import {
+ buildPostgateEmbeddingRules,
+ MAX_DETACHED_EMBEDDING_URIS,
+} from "/js/dataHelpers.js";
+import { DetachedQuoteLimitError } from "/js/dataLayer/mutations.js";
import "/js/components/post-interaction-settings-dialog.js";
import "/js/components/app-icon.js";
@@ -176,6 +180,45 @@ export class PostInteractionHandler {
}
}
+ async handleToggleQuoteDetachment(post, quotedPostUri, doDetach) {
+ if (
+ doDetach &&
+ !(await confirmModal(
+ "This will remove your post from this quote post for all users, and replace it with a placeholder.",
+ {
+ title: "Detach quote post?",
+ confirmButtonText: "Detach",
+ },
+ ))
+ ) {
+ return;
+ }
+ try {
+ await this.dataLayer.mutations.toggleQuoteDetachment(
+ post,
+ quotedPostUri,
+ doDetach,
+ );
+ showToast(doDetach ? "Quote detached" : "Quote re-attached", {
+ style: "success",
+ });
+ } catch (error) {
+ if (error instanceof DetachedQuoteLimitError) {
+ console.warn(error);
+ showToast(
+ `You can detach at most ${MAX_DETACHED_EMBEDDING_URIS} quotes from a post`,
+ { style: "error" },
+ );
+ return;
+ }
+ console.error(error);
+ showToast(
+ doDetach ? "Failed to detach quote" : "Failed to re-attach quote",
+ { style: "error" },
+ );
+ }
+ }
+
async handleHidePost(post) {
if (
!(await confirmModal("This post will be hidden from feeds and threads.", {
diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js
index 7403058e..c52e551a 100644
--- a/src/js/templates/largePost.template.js
+++ b/src/js/templates/largePost.template.js
@@ -317,6 +317,12 @@ export function largePostTemplate({
postInteractionHandler.handlePinPost(post, doPin),
onClickEditInteractionSettings: (post) =>
postInteractionHandler.handleEditInteractionSettings(post),
+ onClickToggleQuoteDetachment: (post, quotedPostUri, doDetach) =>
+ postInteractionHandler.handleToggleQuoteDetachment(
+ post,
+ quotedPostUri,
+ doDetach,
+ ),
onClickReport: (post) =>
postInteractionHandler.handleReport(post),
pluginService,
diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js
index 658bb236..f2e724c5 100644
--- a/src/js/templates/postActionBar.template.js
+++ b/src/js/templates/postActionBar.template.js
@@ -16,6 +16,7 @@ import {
getRKey,
canReplyToPost,
getFeedGeneratorProxyUrl,
+ getOwnQuoteEmbed,
} from "/js/dataHelpers.js";
import { richTextToString } from "/js/facetHelpers.js";
import { SignInModal } from "/js/modals/signIn.modal.js";
@@ -54,6 +55,7 @@ function postContextMenuTemplate({
isAuthenticated,
isUserPost,
isPinnedToProfile,
+ ownQuoteEmbed,
enableFeedFeedback,
feedContext,
pluginItems,
@@ -67,9 +69,11 @@ function postContextMenuTemplate({
onClickDelete,
onClickPin,
onClickEditInteractionSettings,
+ onClickToggleQuoteDetachment,
}) {
const canPin = isUserPost && !post.record?.reply;
const canEditInteractionSettings = isUserPost && !post.record?.reply;
+ const canHidePostForMe = !post.viewer?.isHidden;
const pluginGroups = [...groupBy(pluginItems, "pluginId").values()];
return html`
${isAuthenticated && canPin
@@ -154,16 +158,44 @@ function postContextMenuTemplate({
${!isUserPost
? html`
- ${!post.viewer?.isHidden
+ ${canHidePostForMe || ownQuoteEmbed
? html`
- onClickHidePost(post)}
- >
- Hide ${post.record?.reply ? "reply" : "post"} for me
-
+ ${canHidePostForMe
+ ? html`
+ onClickHidePost(post)}
+ >
+ Hide ${post.record?.reply ? "reply" : "post"}
+ for me
+
+ `
+ : null}
+ ${ownQuoteEmbed
+ ? html`
+
+ onClickToggleQuoteDetachment(
+ post,
+ ownQuoteEmbed.uri,
+ !ownQuoteEmbed.isDetached,
+ )}
+ >
+ ${ownQuoteEmbed.isDetached
+ ? "Re-attach quote"
+ : "Detach quote"}
+
+ `
+ : null}
`
: null}
@@ -403,6 +435,7 @@ export function postActionBarTemplate({
onClickReport = noop,
onClickPin = noop,
onClickEditInteractionSettings = noop,
+ onClickToggleQuoteDetachment = noop,
enableFeedFeedback = false,
pluginService,
}) {
@@ -416,6 +449,9 @@ export function postActionBarTemplate({
const isBookmarked = !!post.viewer?.bookmarked;
const canQuotePost = !post.viewer?.embeddingDisabled;
const canReply = canReplyToPost(post);
+ const ownQuoteEmbed = currentUser
+ ? getOwnQuoteEmbed(post, currentUser.did)
+ : null;
return html`
@@ -555,6 +591,7 @@ export function postActionBarTemplate({
isAuthenticated,
isUserPost,
isPinnedToProfile,
+ ownQuoteEmbed,
enableFeedFeedback,
feedContext,
feedGenerator,
@@ -569,6 +606,7 @@ export function postActionBarTemplate({
onClickDelete,
onClickPin,
onClickEditInteractionSettings,
+ onClickToggleQuoteDetachment,
});
}}
>
diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js
index 48e1e0f6..7bf33612 100644
--- a/src/js/templates/smallPost.template.js
+++ b/src/js/templates/smallPost.template.js
@@ -258,6 +258,12 @@ export function smallPostTemplate({
postInteractionHandler.handlePinPost(post, doPin),
onClickEditInteractionSettings: (post) =>
postInteractionHandler.handleEditInteractionSettings(post),
+ onClickToggleQuoteDetachment: (post, quotedPostUri, doDetach) =>
+ postInteractionHandler.handleToggleQuoteDetachment(
+ post,
+ quotedPostUri,
+ doDetach,
+ ),
onClickReport: (post) => postInteractionHandler.handleReport(post),
enableFeedFeedback,
pluginService,
diff --git a/tests/e2e/specs/flows/quoteDetachment.test.js b/tests/e2e/specs/flows/quoteDetachment.test.js
new file mode 100644
index 00000000..c6a4e730
--- /dev/null
+++ b/tests/e2e/specs/flows/quoteDetachment.test.js
@@ -0,0 +1,178 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { userProfile } from "../../testData.js";
+import { MockServer } from "../../mockServer.js";
+import { createPost } from "../../../shared/factories.js";
+
+test.describe("Quote detachment flow", () => {
+ const ownPostUri = `at://${userProfile.did}/app.bsky.feed.post/own1`;
+ const quotingPostUri = "at://did:plc:quoter/app.bsky.feed.post/quoting1";
+ const ownPost = createPost({
+ uri: ownPostUri,
+ text: "My own post",
+ authorHandle: userProfile.handle,
+ authorDisplayName: userProfile.displayName,
+ });
+
+ function makeQuotingPost(quotedRecord) {
+ return createPost({
+ uri: quotingPostUri,
+ text: "Look at this post",
+ authorHandle: "quoter.bsky.social",
+ authorDisplayName: "Quoter",
+ embed: { $type: "app.bsky.embed.record#view", record: quotedRecord },
+ });
+ }
+
+ test("detaches a quote of the user's post, preserving embeddingRules", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addTimelinePosts([
+ makeQuotingPost({
+ $type: "app.bsky.embed.record#viewRecord",
+ uri: ownPostUri,
+ cid: ownPost.cid,
+ author: ownPost.author,
+ value: ownPost.record,
+ labels: [],
+ indexedAt: "2025-01-01T00:00:00.000Z",
+ embeds: [],
+ }),
+ ]);
+ const embeddingRules = [{ $type: "app.bsky.feed.postgate#disableRule" }];
+ const priorDetachedUri = "at://did:plc:other/app.bsky.feed.post/q0";
+ mockServer.postgateRecords.set("own1", {
+ cid: "bafypriorpostgate",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: ownPostUri,
+ embeddingRules,
+ detachedEmbeddingUris: [priorDetachedUri],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ },
+ });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/");
+
+ const quotingPost = page
+ .locator('[data-testid="small-post"]')
+ .filter({ hasText: "Look at this post" })
+ .first();
+ await expect(quotingPost).toBeVisible({ timeout: 10000 });
+
+ await quotingPost.locator('[data-testid="post-action-more"]').click();
+ const menuItem = page.locator(
+ '[data-testid="menu-action-post-toggle-quote-detachment"]',
+ );
+ await expect(menuItem).toHaveAttribute("data-teststate", "attached");
+ await menuItem.click();
+ await page.locator('[data-testid="modal-confirm-button"]').click();
+
+ await expect(
+ quotingPost.locator('[data-testid="removed-quote"]'),
+ ).toBeVisible();
+ await expect(quotingPost.locator(".quoted-post-link")).toHaveCount(0);
+
+ expect(mockServer.putPostgateCalls.length).toBe(1);
+ const body = mockServer.putPostgateCalls[0];
+ expect(body.rkey).toBe("own1");
+ expect(body.swapRecord).toBe("bafypriorpostgate");
+ expect(body.record.embeddingRules).toEqual(embeddingRules);
+ expect(body.record.detachedEmbeddingUris).toEqual([
+ priorDetachedUri,
+ quotingPostUri,
+ ]);
+ });
+
+ test("re-attaches a detached quote", async ({ page }) => {
+ const mockServer = new MockServer();
+ mockServer.addPosts([ownPost]);
+ mockServer.addTimelinePosts([
+ makeQuotingPost({
+ $type: "app.bsky.embed.record#viewDetached",
+ uri: ownPostUri,
+ detached: true,
+ }),
+ ]);
+ mockServer.postgateRecords.set("own1", {
+ cid: "bafypriorpostgate",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: ownPostUri,
+ detachedEmbeddingUris: [quotingPostUri],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ },
+ });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/");
+
+ const quotingPost = page
+ .locator('[data-testid="small-post"]')
+ .filter({ hasText: "Look at this post" })
+ .first();
+ await expect(quotingPost).toBeVisible({ timeout: 10000 });
+
+ await quotingPost.locator('[data-testid="post-action-more"]').click();
+ const menuItem = page.locator(
+ '[data-testid="menu-action-post-toggle-quote-detachment"]',
+ );
+ await expect(menuItem).toHaveAttribute("data-teststate", "detached");
+ await menuItem.click();
+
+ await expect(quotingPost.locator(".quoted-post-link")).toBeVisible();
+ await expect(
+ quotingPost.locator('[data-testid="removed-quote"]'),
+ ).toHaveCount(0);
+
+ expect(mockServer.putPostgateCalls.length).toBe(1);
+ expect(mockServer.putPostgateCalls[0].record.detachedEmbeddingUris).toEqual(
+ [],
+ );
+ });
+
+ test("doesn't offer detachment on quotes of other users' posts", async ({
+ page,
+ }) => {
+ const otherPost = createPost({
+ uri: "at://did:plc:other/app.bsky.feed.post/theirs",
+ text: "Someone else's post",
+ authorHandle: "other.bsky.social",
+ authorDisplayName: "Other",
+ });
+ const mockServer = new MockServer();
+ mockServer.addTimelinePosts([
+ makeQuotingPost({
+ $type: "app.bsky.embed.record#viewRecord",
+ uri: otherPost.uri,
+ cid: otherPost.cid,
+ author: otherPost.author,
+ value: otherPost.record,
+ labels: [],
+ indexedAt: "2025-01-01T00:00:00.000Z",
+ embeds: [],
+ }),
+ ]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/");
+
+ const quotingPost = page
+ .locator('[data-testid="small-post"]')
+ .filter({ hasText: "Look at this post" })
+ .first();
+ await expect(quotingPost).toBeVisible({ timeout: 10000 });
+ await quotingPost.locator('[data-testid="post-action-more"]').click();
+ await expect(
+ page.locator('[data-testid="menu-action-post-mute-thread"]'),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-testid="menu-action-post-toggle-quote-detachment"]'),
+ ).toHaveCount(0);
+ });
+});
diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js
index c83381c1..209b150e 100644
--- a/tests/unit/specs/dataLayer/mutations.test.js
+++ b/tests/unit/specs/dataLayer/mutations.test.js
@@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
-import { Mutations } from "/js/dataLayer/mutations.js";
+import { Mutations, DetachedQuoteLimitError } from "/js/dataLayer/mutations.js";
import { DataStore } from "/js/dataLayer/dataStore.js";
import { createSessionState } from "/js/dataLayer/sessionState.js";
import { DraftMediaStore } from "/js/drafts.js";
@@ -3159,6 +3159,300 @@ describe("updatePostgateEmbeddingRules", () => {
});
});
+describe("toggleQuoteDetachment", () => {
+ const userDid = "did:plc:me";
+ const quotedPostUri = `at://${userDid}/app.bsky.feed.post/abc`;
+ const quotingPostUri = "at://did:plc:other/app.bsky.feed.post/quote1";
+ const quotedViewRecord = {
+ $type: "app.bsky.embed.record#viewRecord",
+ uri: quotedPostUri,
+ cid: "cid-abc",
+ author: { did: userDid, handle: "me.test" },
+ value: { text: "original" },
+ };
+ const detachedQuote = {
+ $type: "app.bsky.embed.record#viewDetached",
+ uri: quotedPostUri,
+ detached: true,
+ };
+ const quotedPost = {
+ uri: quotedPostUri,
+ cid: "cid-abc",
+ author: { did: userDid, handle: "me.test" },
+ record: { text: "original" },
+ };
+ const disableRule = { $type: "app.bsky.feed.postgate#disableRule" };
+
+ function quotingPostWith(record) {
+ return {
+ uri: quotingPostUri,
+ cid: "cid-quote",
+ author: { did: "did:plc:other", handle: "other.test", viewer: {} },
+ record: { text: "look" },
+ embed: { $type: "app.bsky.embed.record#view", record },
+ viewer: {},
+ };
+ }
+
+ function recordNotFoundError() {
+ return new ApiError({
+ status: 400,
+ statusText: "Bad Request",
+ data: { error: "RecordNotFound" },
+ headers: {},
+ url: "",
+ });
+ }
+
+ function setup(mockApi, quotingPost) {
+ const dataStore = new DataStore(createSessionState(null));
+ const patchStore = new PatchStore();
+ const mockPreferencesProvider = {
+ requirePreferences: () => Preferences.createLoggedOutPreferences(),
+ };
+ dataStore.$currentUser.set({ did: userDid, handle: "me.test" });
+ dataStore.$posts.set(quotingPost.uri, quotingPost);
+ return {
+ mutations: makeMutations(
+ mockApi,
+ dataStore,
+ patchStore,
+ mockPreferencesProvider,
+ ),
+ dataStore,
+ patchStore,
+ };
+ }
+
+ it("creates a record and detaches the embed when there is no prior record", async () => {
+ let putArgs = null;
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ const mockApi = {
+ getPostgateRecord: async () => {
+ throw recordNotFoundError();
+ },
+ putPostgateRecord: async (rkey, record, swapRecord) => {
+ putArgs = { rkey, record, swapRecord };
+ return { cid: "cid-gate" };
+ },
+ };
+ const { mutations, dataStore, patchStore } = setup(mockApi, quotingPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, true);
+
+ assert.deepEqual(putArgs.rkey, "abc");
+ assert.deepEqual(putArgs.swapRecord, null);
+ assert.deepEqual(putArgs.record.post, quotedPostUri);
+ assert.deepEqual(putArgs.record.detachedEmbeddingUris, [quotingPostUri]);
+ assert.deepEqual(
+ dataStore.$posts.get(quotingPostUri).embed.record,
+ detachedQuote,
+ );
+ assert.deepEqual(patchStore.$postPatches.get(quotingPostUri), []);
+ });
+
+ it("applies the detached embed optimistically before the write lands", async () => {
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ let patchesDuringWrite = null;
+ const mockApi = {
+ getPostgateRecord: async () => {
+ throw recordNotFoundError();
+ },
+ putPostgateRecord: async () => {
+ patchesDuringWrite = patchStore.$postPatches.get(quotingPostUri);
+ return { cid: "cid-gate" };
+ },
+ };
+ const { mutations, patchStore } = setup(mockApi, quotingPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, true);
+
+ assert.deepEqual(patchesDuringWrite.length, 1);
+ const patched = patchStore.applyPostPatches(
+ quotingPost,
+ patchesDuringWrite,
+ );
+ assert.deepEqual(patched.embed.record, detachedQuote);
+ });
+
+ it("merges into the prior record, preserving embeddingRules", async () => {
+ let putArgs = null;
+ const otherUri = "at://did:plc:third/app.bsky.feed.post/quote2";
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ const mockApi = {
+ getPostgateRecord: async () => ({
+ cid: "cid-prior",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: quotedPostUri,
+ embeddingRules: [disableRule],
+ detachedEmbeddingUris: [otherUri],
+ },
+ }),
+ putPostgateRecord: async (rkey, record, swapRecord) => {
+ putArgs = { rkey, record, swapRecord };
+ return { cid: "cid-next" };
+ },
+ };
+ const { mutations } = setup(mockApi, quotingPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, true);
+
+ assert.deepEqual(putArgs.swapRecord, "cid-prior");
+ assert.deepEqual(putArgs.record.embeddingRules, [disableRule]);
+ assert.deepEqual(putArgs.record.detachedEmbeddingUris, [
+ otherUri,
+ quotingPostUri,
+ ]);
+ });
+
+ it("rejects detaching past the cap without writing", async () => {
+ let putCalled = false;
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ const fullUris = Array.from(
+ { length: 50 },
+ (_, i) => `at://did:plc:third/app.bsky.feed.post/q${i}`,
+ );
+ const mockApi = {
+ getPostgateRecord: async () => ({
+ cid: "cid-prior",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: quotedPostUri,
+ detachedEmbeddingUris: fullUris,
+ },
+ }),
+ putPostgateRecord: async () => {
+ putCalled = true;
+ return { cid: "x" };
+ },
+ };
+ const { mutations, dataStore, patchStore } = setup(mockApi, quotingPost);
+
+ await assert.rejects(
+ () => mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, true),
+ DetachedQuoteLimitError,
+ );
+ assert.deepEqual(putCalled, false);
+ assert.deepEqual(patchStore.$postPatches.get(quotingPostUri), []);
+ assert.deepEqual(
+ dataStore.$posts.get(quotingPostUri).embed.record,
+ quotedViewRecord,
+ );
+ });
+
+ it("re-attaches by removing the uri and restoring the cached quote", async () => {
+ let putArgs = null;
+ const quotingPost = quotingPostWith(detachedQuote);
+ const mockApi = {
+ getPostgateRecord: async () => ({
+ cid: "cid-prior",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: quotedPostUri,
+ embeddingRules: [disableRule],
+ detachedEmbeddingUris: [quotingPostUri],
+ },
+ }),
+ putPostgateRecord: async (rkey, record, swapRecord) => {
+ putArgs = { rkey, record, swapRecord };
+ return { cid: "cid-next" };
+ },
+ };
+ const { mutations, dataStore } = setup(mockApi, quotingPost);
+ dataStore.$embeddedPosts.set(quotedPostUri, quotedPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, false);
+
+ assert.deepEqual(putArgs.record.detachedEmbeddingUris, []);
+ assert.deepEqual(putArgs.record.embeddingRules, [disableRule]);
+ const restored = dataStore.$posts.get(quotingPostUri).embed.record;
+ assert.deepEqual(restored.$type, "app.bsky.embed.record#viewRecord");
+ assert.deepEqual(restored.uri, quotedPostUri);
+ assert.deepEqual(restored.value, { text: "original" });
+ });
+
+ it("fetches the quoted post after re-attaching when it isn't cached", async () => {
+ const quotingPost = quotingPostWith(detachedQuote);
+ const mockApi = {
+ getPostgateRecord: async () => ({
+ cid: "cid-prior",
+ value: {
+ $type: "app.bsky.feed.postgate",
+ post: quotedPostUri,
+ detachedEmbeddingUris: [quotingPostUri],
+ },
+ }),
+ putPostgateRecord: async () => ({ cid: "cid-next" }),
+ getPost: async () => quotedPost,
+ };
+ const { mutations, dataStore } = setup(mockApi, quotingPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, false);
+
+ const restored = dataStore.$posts.get(quotingPostUri).embed.record;
+ assert.deepEqual(restored.$type, "app.bsky.embed.record#viewRecord");
+ assert.deepEqual(restored.uri, quotedPostUri);
+ });
+
+ it("skips the write when re-attaching with no prior record", async () => {
+ let putCalled = false;
+ const quotingPost = quotingPostWith(detachedQuote);
+ const mockApi = {
+ getPostgateRecord: async () => {
+ throw recordNotFoundError();
+ },
+ putPostgateRecord: async () => {
+ putCalled = true;
+ return { cid: "x" };
+ },
+ getPost: async () => quotedPost,
+ };
+ const { mutations } = setup(mockApi, quotingPost);
+
+ await mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, false);
+
+ assert.deepEqual(putCalled, false);
+ });
+
+ it("removes the patch and rethrows on failure", async () => {
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ const mockApi = {
+ getPostgateRecord: async () => {
+ throw recordNotFoundError();
+ },
+ putPostgateRecord: async () => {
+ throw new Error("network down");
+ },
+ };
+ const { mutations, dataStore, patchStore } = setup(mockApi, quotingPost);
+
+ await assert.rejects(
+ () => mutations.toggleQuoteDetachment(quotingPost, quotedPostUri, true),
+ /network down/,
+ );
+ assert.deepEqual(patchStore.$postPatches.get(quotingPostUri), []);
+ assert.deepEqual(
+ dataStore.$posts.get(quotingPostUri).embed.record,
+ quotedViewRecord,
+ );
+ });
+
+ it("refuses to write for a quoted post the user doesn't own", async () => {
+ const quotingPost = quotingPostWith(quotedViewRecord);
+ const { mutations } = setup({}, quotingPost);
+ await assert.rejects(
+ () =>
+ mutations.toggleQuoteDetachment(
+ quotingPost,
+ "at://did:plc:other/app.bsky.feed.post/abc",
+ true,
+ ),
+ /quoted post's author/,
+ );
+ });
+});
+
describe("updateThreadgateAllow", () => {
const userDid = "did:plc:me";
const postUri = `at://${userDid}/app.bsky.feed.post/abc`;