Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Add feature gates for new scopes

Grace Kind (Jul 18, 2026, 6:59 PM -0500) cb83bce1 d4f68da3

+368 -32
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.10", 3 + "version": "0.18.11", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1
src/_includes/head.html
··· 33 33 environment: "{{ environment }}", 34 34 version: "{{ version }}", 35 35 oauthScopes: "{{ oauthScopes }}", 36 + oauthOptionalScopes: "{{ oauthOptionalScopes }}", 36 37 playwright: "{{ playwright }}" === "true", 37 38 oatproxyClientId: "{{ oatproxyClientId }}", 38 39 };
+22 -1
src/index.html
··· 184 184 import { PluginService } from "/js/plugins/pluginService.js"; 185 185 import { MainLayout } from "/js/mainLayout.js"; 186 186 187 + const DRAFT_SCOPES = [ 188 + "rpc:app.bsky.draft.getDrafts", 189 + "rpc:app.bsky.draft.createDraft", 190 + "rpc:app.bsky.draft.updateDraft", 191 + "rpc:app.bsky.draft.deleteDraft", 192 + ]; 193 + 194 + async function checkDraftsEnabled() { 195 + const results = await Promise.all( 196 + DRAFT_SCOPES.map((scope) => auth.hasScope(scope)), 197 + ); 198 + return results.every(Boolean); 199 + } 200 + 187 201 async function main() { 188 202 // Body class for styling 189 203 if (isNative()) { ··· 227 241 ? new ChatNotificationService(api) 228 242 : null; 229 243 const postComposerService = session 230 - ? new PostComposerService(dataLayer, identityResolver, pluginService) 244 + ? new PostComposerService( 245 + dataLayer, 246 + identityResolver, 247 + pluginService, 248 + { 249 + draftsEnabled: await checkDraftsEnabled(), 250 + }, 251 + ) 231 252 : null; 232 253 const accountSwitcherService = session 233 254 ? new AccountSwitcherService(dataLayer)
+25 -4
src/js/auth.js
··· 301 301 302 302 const FORCE_LOGOUT_QUERY_PARAM = "force-logout"; 303 303 304 - export function getMissingScopes(grantedScope, requiredScope) { 304 + export function getMissingScopes( 305 + grantedScope, 306 + requiredScope, 307 + optionalScope = "", 308 + ) { 305 309 const granted = new Set(grantedScope.split(/\s+/).filter(Boolean)); 310 + const optional = new Set(optionalScope.split(/\s+/).filter(Boolean)); 306 311 return requiredScope 307 312 .split(/\s+/) 308 313 .filter(Boolean) 309 - .filter((scope) => !granted.has(scope)); 314 + .filter((scope) => !granted.has(scope) && !optional.has(scope)); 310 315 } 311 316 312 317 export class Auth { ··· 340 345 async listAccounts() { 341 346 const accounts = (await this.provider.listAccounts?.()) ?? []; 342 347 const requiredScope = window.env?.oauthScopes ?? ""; 348 + const optionalScope = window.env?.oauthOptionalScopes ?? ""; 343 349 // Mark accounts with out-of-date scopes as "needsReauth" 344 350 return accounts.map((account) => { 345 351 const scopesOutOfDate = 346 - getMissingScopes(account.scope ?? "", requiredScope).length > 0; 352 + getMissingScopes(account.scope ?? "", requiredScope, optionalScope) 353 + .length > 0; 347 354 return { 348 355 ...account, 349 356 needsReauth: account.needsReauth || scopesOutOfDate, ··· 410 417 return null; 411 418 } 412 419 420 + // Check for scope, ignoring query params 421 + async hasScope(scope) { 422 + const session = await this.provider.getSession(); 423 + if (!session) return false; 424 + if (!session.scope) return true; 425 + return session.scope 426 + .split(/\s+/) 427 + .some((granted) => granted === scope || granted.startsWith(scope + "?")); 428 + } 429 + 413 430 async ensureCurrentScopes() { 414 431 const session = await this.provider.getSession(); 415 432 if (!session?.scope) return; 416 - const missing = getMissingScopes(session.scope, window.env.oauthScopes); 433 + const missing = getMissingScopes( 434 + session.scope, 435 + window.env.oauthScopes, 436 + window.env.oauthOptionalScopes ?? "", 437 + ); 417 438 if (missing.length === 0) return; 418 439 console.warn("OAuth scopes are out of date, forcing re-auth:", missing); 419 440 try {
+27 -1
src/js/components/post-composer.js
··· 231 231 this._pendingQuotedRecord ?? null, 232 232 ); 233 233 this._pendingQuotedRecord = null; 234 + this.state.$draftsEnabled = new Signal.State( 235 + this._pendingDraftsEnabled ?? false, 236 + ); 237 + this._pendingDraftsEnabled = null; 234 238 this._disposers = [ 235 239 effect(() => { 236 240 this.render(); ··· 258 262 this.state.$quotedRecord.set(value); 259 263 } 260 264 265 + get draftsEnabled() { 266 + if (!this.state) return this._pendingDraftsEnabled ?? false; 267 + return untrack(() => this.state.$draftsEnabled.get()); 268 + } 269 + 270 + set draftsEnabled(value) { 271 + if (!this.state) { 272 + this._pendingDraftsEnabled = value; 273 + return; 274 + } 275 + this.state.$draftsEnabled.set(value); 276 + } 277 + 261 278 render() { 262 279 const promptText = this.replyTo ? "Write your reply" : "What's up?"; 263 280 const isSending = this.state.$isSending.get(); 264 281 const isSavingDraft = this.state.$isSavingDraft.get(); 282 + const draftsEnabled = this.state.$draftsEnabled.get(); 265 283 const externalLinkEmbedData = this.state.$externalLinkEmbedData.get(); 266 284 const selectedImages = this.state.$selectedImages.get(); 267 285 const selectedVideo = this.state.$selectedVideo.get(); ··· 315 333 > 316 334 Cancel 317 335 </button> 318 - ${!this.replyTo 336 + ${!this.replyTo && draftsEnabled 319 337 ? html`<button 320 338 class="post-composer-drafts-button" 321 339 data-testid="composer-drafts-button" ··· 1096 1114 } 1097 1115 1098 1116 async handleDraftsButtonClick() { 1117 + if (!this.draftsEnabled) return; 1099 1118 if (untrack(() => this.state.$isSavingDraft.get())) return; 1100 1119 if (!this.hasContent() || (this._draftId && !this._isDirty)) { 1101 1120 this.openDraftsDialog(); ··· 1331 1350 } 1332 1351 if (!this.hasContent()) { 1333 1352 return true; 1353 + } 1354 + if (!this.draftsEnabled) { 1355 + return confirmModal("Are you sure you'd like to discard this post?", { 1356 + title: "Discard post?", 1357 + confirmButtonStyle: "danger", 1358 + confirmButtonText: "Discard", 1359 + }); 1334 1360 } 1335 1361 if (this._draftId && !this._isDirty) { 1336 1362 return true;
+3 -1
src/js/postComposerService.js
··· 6 6 import { hapticsImpactLight } from "/js/haptics.js"; 7 7 8 8 export class PostComposerService { 9 - constructor(dataLayer, identityResolver, pluginService) { 9 + constructor(dataLayer, identityResolver, pluginService, { draftsEnabled }) { 10 10 this.dataLayer = dataLayer; 11 11 this.identityResolver = identityResolver; 12 12 this.pluginService = pluginService; 13 13 this.currentPostComposer = null; 14 + this.draftsEnabled = draftsEnabled; 14 15 } 15 16 16 17 async composePost({ ··· 39 40 ? createEmbedFromPost(quotedPost) 40 41 : null; 41 42 this.currentPostComposer.currentUser = currentUser; 43 + this.currentPostComposer.draftsEnabled = this.draftsEnabled; 42 44 this.currentPostComposer.addEventListener("send-post", async (e) => { 43 45 const { post, draft, successCallback, errorCallback } = e.detail; 44 46 try {
+9 -2
src/js/views/chatDetail.view.js
··· 68 68 }) { 69 69 await auth.requireAuth(); 70 70 71 + const canViewGroupDetails = await auth.hasScope( 72 + "rpc:chat.bsky.convo.getConvoMembers", 73 + ); 74 + 71 75 const convoId = params.convoId; 72 76 73 77 const state = new ReactiveStore("chatDetailView"); ··· 1392 1396 }, 1393 1397 title, 1394 1398 subtitle, 1395 - titleHref: groupDetails ? linkToGroupChatDetails(convoId) : null, 1399 + titleHref: 1400 + groupDetails && canViewGroupDetails 1401 + ? linkToGroupChatDetails(convoId) 1402 + : null, 1396 1403 backButtonFallbackRoute: "/messages", 1397 1404 rightItemTemplate: () => html` 1398 1405 <button ··· 1406 1413 <span>...</span> 1407 1414 </button> 1408 1415 <context-menu> 1409 - ${groupDetails 1416 + ${groupDetails && canViewGroupDetails 1410 1417 ? html`<context-menu-item 1411 1418 data-testid="menu-action-group-chat-details" 1412 1419 @click=${() => {
+37 -18
src/oauthScopes.js
··· 96 96 "rpc:app.bsky.video.getJobStatus", 97 97 "rpc:app.bsky.video.getUploadLimits", 98 98 "rpc:app.bsky.video.uploadVideo", 99 - "rpc:app.bsky.draft.getDrafts", 100 - "rpc:app.bsky.draft.createDraft", 101 - "rpc:app.bsky.draft.updateDraft", 102 - "rpc:app.bsky.draft.deleteDraft", 103 99 ]; 104 100 105 101 const BSKY_OAUTH_REPO_SCOPES = [ ··· 142 138 "rpc:chat.bsky.convo.updateRead", 143 139 "rpc:chat.bsky.group.getJoinLinkPreviews", 144 140 "rpc:chat.bsky.group.requestJoin", 145 - "rpc:chat.bsky.convo.getConvoMembers", 146 141 ]; 147 142 148 143 const CHAT_OAUTH_REPO_SCOPES = ["repo:chat.bsky.actor.declaration"]; ··· 152 147 "rpc:com.atproto.repo.uploadBlob", 153 148 ]; 154 149 155 - function buildOauthScopesString() { 156 - let scopesString = "atproto blob:*/*"; 157 - for (let scope of [ 158 - ...BSKY_OAUTH_RPC_SCOPES, 159 - ...CHAT_OAUTH_RPC_SCOPES, 160 - ...ATPROTO_OAUTH_RPC_SCOPES, 161 - ]) { 162 - scopesString += " " + scope + "?aud=*"; 150 + const OPTIONAL_OAUTH_RPC_SCOPES = [ 151 + "rpc:chat.bsky.convo.getConvoMembers", 152 + "rpc:app.bsky.draft.getDrafts", 153 + "rpc:app.bsky.draft.createDraft", 154 + "rpc:app.bsky.draft.updateDraft", 155 + "rpc:app.bsky.draft.deleteDraft", 156 + ]; 157 + 158 + function expandScope(scope) { 159 + if (scope.startsWith("rpc:")) { 160 + return [scope + "?aud=*"]; 163 161 } 164 - for (let scope of [...BSKY_OAUTH_REPO_SCOPES, ...CHAT_OAUTH_REPO_SCOPES]) { 165 - for (let action of ["create", "update", "delete"]) { 166 - scopesString += " " + scope + "?action=" + action; 167 - } 162 + if (scope.startsWith("repo:")) { 163 + return ["create", "update", "delete"].map( 164 + (action) => scope + "?action=" + action, 165 + ); 168 166 } 169 - return scopesString; 167 + return [scope]; 168 + } 169 + 170 + function expandScopes(scopes) { 171 + return scopes.flatMap(expandScope); 172 + } 173 + 174 + function buildOauthScopesString() { 175 + const scopes = [ 176 + "atproto", 177 + "blob:*/*", 178 + ...expandScopes(BSKY_OAUTH_RPC_SCOPES), 179 + ...expandScopes(CHAT_OAUTH_RPC_SCOPES), 180 + ...expandScopes(ATPROTO_OAUTH_RPC_SCOPES), 181 + ...expandScopes(BSKY_OAUTH_REPO_SCOPES), 182 + ...expandScopes(CHAT_OAUTH_REPO_SCOPES), 183 + ...expandScopes(OPTIONAL_OAUTH_RPC_SCOPES), 184 + ]; 185 + return scopes.join(" "); 170 186 } 171 187 172 188 export const OAUTH_SCOPES = buildOauthScopesString(); 189 + export const OPTIONAL_OAUTH_SCOPES = expandScopes( 190 + OPTIONAL_OAUTH_RPC_SCOPES, 191 + ).join(" ");
+2 -1
src/src.11tydata.js
··· 1 1 import { execSync } from "child_process"; 2 - import { OAUTH_SCOPES } from "./oauthScopes.js"; 2 + import { OAUTH_SCOPES, OPTIONAL_OAUTH_SCOPES } from "./oauthScopes.js"; 3 3 import pkg from "../package.json" with { type: "json" }; 4 4 5 5 export default { ··· 11 11 environment: process.env.ENVIRONMENT ?? "development", 12 12 playwright: process.env.PLAYWRIGHT ? "true" : "", 13 13 oauthScopes: OAUTH_SCOPES, 14 + oauthOptionalScopes: OPTIONAL_OAUTH_SCOPES, 14 15 oatproxyClientId: process.env.OATPROXY_CLIENT_ID ?? "", 15 16 };
+2 -2
tests/e2e/helpers.js
··· 11 11 await page.mouse.up(); 12 12 } 13 13 14 - export async function login(page) { 14 + export async function login(page, { scope = OAUTH_SCOPES } = {}) { 15 15 const oauthSession = { 16 16 did: userProfile.did, 17 17 serviceEndpoint: "https://fake.bsky.social", ··· 22 22 authServerMetadata: { 23 23 token_endpoint: `https://fake.bsky.social/oauth/token`, 24 24 }, 25 - scope: OAUTH_SCOPES, 25 + scope, 26 26 }; 27 27 await page.addInitScript((session) => { 28 28 localStorage.setItem("oauth_session", JSON.stringify(session));
+29
tests/e2e/specs/flows/drafts.test.js
··· 1 1 import { test, expect } from "../../base.js"; 2 2 import { login } from "../../helpers.js"; 3 3 import { MockServer } from "../../mockServer.js"; 4 + import { OAUTH_SCOPES } from "../../../../src/oauthScopes.js"; 4 5 5 6 async function openComposer(page) { 6 7 const homeView = page.locator("#home-view"); ··· 78 79 await expect( 79 80 page.locator('[data-testid="drafts-dialog"] [data-testid="empty-state"]'), 80 81 ).toBeVisible({ timeout: 10000 }); 82 + }); 83 + 84 + test("hides drafts and confirms discard when the session lacks the draft scopes", async ({ 85 + page, 86 + }) => { 87 + const mockServer = new MockServer(); 88 + await mockServer.setup(page); 89 + 90 + const scopeWithoutDrafts = OAUTH_SCOPES.split(" ") 91 + .filter((scope) => !scope.includes("app.bsky.draft.")) 92 + .join(" "); 93 + await login(page, { scope: scopeWithoutDrafts }); 94 + await page.goto("/"); 95 + 96 + const composer = await openComposer(page); 97 + await composer.locator(".rich-text-input").click(); 98 + await composer.locator(".rich-text-input").type("Ephemeral thought"); 99 + await expect( 100 + composer.locator('[data-testid="composer-drafts-button"]'), 101 + ).toHaveCount(0); 102 + await composer.locator(".post-composer-cancel-button").click(); 103 + 104 + // A plain discard confirmation replaces the save-draft choice prompt 105 + const confirmModal = page.locator('[data-testid="confirm-modal"]'); 106 + await expect(confirmModal).toBeVisible({ timeout: 10000 }); 107 + await expect(page.locator('[data-testid="choice-modal"]')).toHaveCount(0); 108 + await confirmModal.locator('[data-testid="modal-confirm-button"]').click(); 109 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 81 110 }); 82 111 83 112 test("delete a draft from the list with confirmation", async ({ page }) => {
+39
tests/e2e/specs/views/chatDetail.view.test.js
··· 1 1 import { test, expect } from "../../base.js"; 2 2 import { login } from "../../helpers.js"; 3 + import { OAUTH_SCOPES } from "../../../../src/oauthScopes.js"; 3 4 import { MockServer } from "../../mockServer.js"; 4 5 import { 5 6 createConvo, ··· 738 739 await page.goto("/messages/convo-1"); 739 740 740 741 const chatDetailView = page.locator("#chat-detail-view"); 742 + await chatDetailView.locator('[data-testid="chat-menu-button"]').click(); 743 + await expect( 744 + chatDetailView.locator('[data-testid="menu-action-chat-open-in-bsky"]'), 745 + ).toBeVisible(); 746 + await expect( 747 + chatDetailView.locator('[data-testid="menu-action-group-chat-details"]'), 748 + ).toHaveCount(0); 749 + }); 750 + 751 + test("should not offer group chat details when the session lacks the members scope", async ({ 752 + page, 753 + }) => { 754 + const mockServer = new MockServer(); 755 + const alice = createProfile({ 756 + did: "did:plc:alice1", 757 + handle: "alice.bsky.social", 758 + displayName: "Alice", 759 + }); 760 + const convo = createGroupConvo({ 761 + id: "convo-1", 762 + name: "Cool Group", 763 + otherMembers: [alice], 764 + }); 765 + mockServer.addConvos([convo]); 766 + await mockServer.setup(page); 767 + 768 + const scopeWithoutMembers = OAUTH_SCOPES.split(" ") 769 + .filter((scope) => !scope.includes("chat.bsky.convo.getConvoMembers")) 770 + .join(" "); 771 + await login(page, { scope: scopeWithoutMembers }); 772 + await page.goto("/messages/convo-1"); 773 + 774 + const chatDetailView = page.locator("#chat-detail-view"); 775 + await expect( 776 + chatDetailView.locator('[data-testid="header-title"]'), 777 + ).toContainText("Cool Group", { timeout: 10000 }); 778 + await expect(chatDetailView.locator(".header-title-link")).toHaveCount(0); 779 + 741 780 await chatDetailView.locator('[data-testid="chat-menu-button"]').click(); 742 781 await expect( 743 782 chatDetailView.locator('[data-testid="menu-action-chat-open-in-bsky"]'),
+117
tests/unit/specs/auth.test.js
··· 531 531 assert.deepEqual(byDid["did:plc:carol"].needsReauth, true); 532 532 }); 533 533 534 + it("listAccounts does not flip needsReauth when only optional scopes are missing", async () => { 535 + globalThis.window = { 536 + ...originalWindow, 537 + env: { 538 + oauthScopes: "atproto rpc:a rpc:future", 539 + oauthOptionalScopes: "rpc:future", 540 + }, 541 + }; 542 + const provider = makeMultiAccountProvider({ 543 + accounts: [ 544 + { 545 + did: "did:plc:alice", 546 + handle: "alice.test", 547 + scope: "atproto rpc:a", 548 + needsReauth: false, 549 + }, 550 + ], 551 + currentDid: "did:plc:alice", 552 + }); 553 + const manager = new Auth(provider); 554 + const accounts = await manager.listAccounts(); 555 + assert.deepEqual(accounts[0].needsReauth, false); 556 + }); 557 + 534 558 it("listAccounts leaves needsReauth alone for providers that don't expose scope", async () => { 535 559 const provider = makeMultiAccountProvider({ 536 560 accounts: [ ··· 725 749 const result = getMissingScopes("rpc:a?aud=did:web:foo", "rpc:a?aud=*"); 726 750 assert.deepEqual(result, ["rpc:a?aud=*"]); 727 751 }); 752 + 753 + it("does not report missing scopes that are optional", () => { 754 + const result = getMissingScopes( 755 + "atproto rpc:a", 756 + "atproto rpc:a rpc:future", 757 + "rpc:future", 758 + ); 759 + assert.deepEqual(result.length, 0); 760 + }); 761 + 762 + it("still reports missing required scopes alongside missing optional ones", () => { 763 + const result = getMissingScopes( 764 + "atproto rpc:a", 765 + "atproto rpc:a rpc:b rpc:future", 766 + "rpc:future", 767 + ); 768 + assert.deepEqual(result, ["rpc:b"]); 769 + }); 770 + 771 + it("optional scopes that are granted change nothing", () => { 772 + const result = getMissingScopes( 773 + "atproto rpc:a rpc:future", 774 + "atproto rpc:a rpc:future", 775 + "rpc:future", 776 + ); 777 + assert.deepEqual(result.length, 0); 778 + }); 779 + }); 780 + 781 + describe("Auth.hasScope", () => { 782 + it("returns false when there is no session", async () => { 783 + const manager = new Auth(makeMockProvider()); 784 + assert.deepEqual(await manager.hasScope("rpc:a"), false); 785 + }); 786 + 787 + it("returns true when the session has no scope string (BasicAuth)", async () => { 788 + const provider = makeMockProvider(); 789 + provider.getSession = mock.fn(() => Promise.resolve({ scope: undefined })); 790 + const manager = new Auth(provider); 791 + assert.deepEqual(await manager.hasScope("rpc:a"), true); 792 + }); 793 + 794 + it("returns true when the scope is granted exactly", async () => { 795 + const provider = makeMockProvider(); 796 + provider.getSession = mock.fn(() => 797 + Promise.resolve({ scope: "atproto rpc:a rpc:b" }), 798 + ); 799 + const manager = new Auth(provider); 800 + assert.deepEqual(await manager.hasScope("rpc:a"), true); 801 + }); 802 + 803 + it("returns true when a param variant of the scope is granted", async () => { 804 + const provider = makeMockProvider(); 805 + provider.getSession = mock.fn(() => 806 + Promise.resolve({ scope: "atproto rpc:a?aud=*" }), 807 + ); 808 + const manager = new Auth(provider); 809 + assert.deepEqual(await manager.hasScope("rpc:a"), true); 810 + }); 811 + 812 + it("returns false when the scope is not granted", async () => { 813 + const provider = makeMockProvider(); 814 + provider.getSession = mock.fn(() => 815 + Promise.resolve({ scope: "atproto rpc:a?aud=*" }), 816 + ); 817 + const manager = new Auth(provider); 818 + assert.deepEqual(await manager.hasScope("rpc:b"), false); 819 + }); 820 + 821 + it("does not treat a scope as a prefix of a longer scope name", async () => { 822 + const provider = makeMockProvider(); 823 + provider.getSession = mock.fn(() => 824 + Promise.resolve({ scope: "rpc:abc?aud=*" }), 825 + ); 826 + const manager = new Auth(provider); 827 + assert.deepEqual(await manager.hasScope("rpc:a"), false); 828 + }); 728 829 }); 729 830 730 831 describe("Auth.ensureCurrentScopes", () => { ··· 762 863 const provider = makeMockProvider(); 763 864 provider.getSession = mock.fn(() => 764 865 Promise.resolve({ scope: "atproto rpc:a rpc:b" }), 866 + ); 867 + const manager = new Auth(provider); 868 + await manager.ensureCurrentScopes(); 869 + assert.deepEqual(capturedHrefs.length, 0); 870 + assert.deepEqual(provider.logout.mock.callCount(), 0); 871 + }); 872 + 873 + it("does nothing when only optional scopes are missing", async () => { 874 + const capturedHrefs = mockWindowLocation(); 875 + globalThis.window.env = { 876 + oauthScopes: "atproto rpc:a rpc:future", 877 + oauthOptionalScopes: "rpc:future", 878 + }; 879 + const provider = makeMockProvider(); 880 + provider.getSession = mock.fn(() => 881 + Promise.resolve({ scope: "atproto rpc:a" }), 765 882 ); 766 883 const manager = new Auth(provider); 767 884 await manager.ensureCurrentScopes();
+54 -1
tests/unit/specs/components/post-composer.test.js
··· 23 23 document.body.appendChild(container); 24 24 } 25 25 26 - function createPostComposer() { 26 + function createPostComposer({ draftsEnabled = true } = {}) { 27 27 const element = document.createElement("post-composer"); 28 + element.draftsEnabled = draftsEnabled; 28 29 element.currentUser = { 29 30 did: "did:plc:test", 30 31 handle: "test.bsky.social", ··· 1038 1039 element.querySelector('[data-testid="composer-drafts-button"]') !== 1039 1040 null, 1040 1041 ); 1042 + }); 1043 + 1044 + it("does not render the Drafts button when drafts are disabled", () => { 1045 + const element = createPostComposer({ draftsEnabled: false }); 1046 + connectElement(element); 1047 + assert.deepEqual( 1048 + element.querySelector('[data-testid="composer-drafts-button"]'), 1049 + null, 1050 + ); 1051 + }); 1052 + 1053 + it("shows the Drafts button when draftsEnabled is set after connect", async () => { 1054 + const element = createPostComposer({ draftsEnabled: false }); 1055 + connectElement(element); 1056 + assert.deepEqual( 1057 + element.querySelector('[data-testid="composer-drafts-button"]'), 1058 + null, 1059 + ); 1060 + element.draftsEnabled = true; 1061 + await nextFrame(); 1062 + assert( 1063 + element.querySelector('[data-testid="composer-drafts-button"]') !== 1064 + null, 1065 + ); 1066 + }); 1067 + 1068 + it("prompts a plain discard confirm instead of the save choice when drafts are disabled", async () => { 1069 + const element = createPostComposer({ draftsEnabled: false }); 1070 + connectElement(element); 1071 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1072 + let confirmationSeen = false; 1073 + globalThis.__testConfirmation = (resolve) => { 1074 + confirmationSeen = true; 1075 + resolve(true); 1076 + }; 1077 + globalThis.__testChoice = () => { 1078 + throw new Error( 1079 + "choice prompt should not be shown when drafts are disabled", 1080 + ); 1081 + }; 1082 + const result = await element.confirmClose(); 1083 + assert.deepEqual(result, true); 1084 + assert(confirmationSeen); 1085 + }); 1086 + 1087 + it("stays open when the discard confirm is declined with drafts disabled", async () => { 1088 + const element = createPostComposer({ draftsEnabled: false }); 1089 + connectElement(element); 1090 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1091 + globalThis.__testConfirmation = (resolve) => resolve(false); 1092 + const result = await element.confirmClose(); 1093 + assert.deepEqual(result, false); 1041 1094 }); 1042 1095 1043 1096 it("does not render the Drafts button for replies", () => {