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`;