Bug 2040055 - Show loading state in modal while link is being created r=sharing-reviewers,fluent-reviewers,bolsson,jhirsch

Differential Revision: https://phabricator.services.mozilla.com/D301146
This commit is contained in:
Nathan Barrett
2026-05-21 17:35:17 +00:00
committed by nbarrett@mozilla.com
parent 8098b7f49c
commit 2047026553
11 changed files with 166 additions and 20 deletions
@@ -94,6 +94,7 @@ export function makeShareResult({ share = null } = {}) {
url: null,
isSchemaValid: null,
isSignedIn: null,
loadingPromise: null,
};
}
@@ -352,16 +353,29 @@ class ContentSharingUtilsClass {
* @param {string} context Used in error logging (e.g. "tabs", "tab group")
*/
async #createLinkAndOpenModal(shareResult, context) {
// Note: the result object contains either the URL or an error. It's safe
// to pass into the modal, which handles error UI as needed.
shareResult = await this.createShareableLink(shareResult);
shareResult.isSignedIn =
this.isSignedIn() && shareResult.error !== ERRORS.UNAUTHORIZED;
let resolveLoading;
const loadingPromise = new Promise(resolve => {
resolveLoading = resolve;
});
let window = Services.wm.getMostRecentBrowserWindow();
// Note: we deliberately do not await the open.
window.gDialogBox.open(CONTENT_SHARING_MODAL_URL, shareResult);
window.gDialogBox.open(CONTENT_SHARING_MODAL_URL, {
...shareResult,
loadingPromise,
});
// Note: the result object contains either the URL or an error. It's safe
// to pass into the modal, which handles error UI as needed.
try {
shareResult = await this.createShareableLink(shareResult);
shareResult.isSignedIn =
this.isSignedIn() && shareResult.error !== ERRORS.UNAUTHORIZED;
} finally {
// Resolve with a new object so Lit detects the shareResult change
resolveLoading({ ...shareResult, loadingPromise: null });
}
if (shareResult.error && !shareResult.isSignedIn) {
console.error(
`ContentSharingUtils: failed to share ${context}`,
@@ -65,19 +65,25 @@ export class ContentSharingModal extends MozLitElement {
copyButton: "#copy-button",
viewPageButton: "#view-page",
signInButton: "#sign-in",
loadingButton: "#loading-button",
tooManyLinks: ".too-many-links",
errorMessageBar: "moz-message-bar",
};
async getUpdateComplete() {
await super.getUpdateComplete();
await this.previewCard.updateComplete;
await this.previewCard?.updateComplete;
}
connectedCallback() {
super.connectedCallback();
this.shareResult = window.arguments?.[0];
if (this.shareResult?.loadingPromise) {
this.shareResult.loadingPromise.then(result => {
this.shareResult = result;
});
}
}
close() {
@@ -182,7 +188,23 @@ export class ContentSharingModal extends MozLitElement {
);
}
loadingTemplate() {
return html`<moz-button-group
><moz-button
disabled
id="loading-button"
iconsrc="chrome://global/skin/icons/loading.svg"
data-l10n-id="content-sharing-modal-generating-page"
type="icon"
></moz-button
></moz-button-group>`;
}
descriptionActionTemplate() {
if (this.shareResult.loadingPromise) {
return this.loadingTemplate();
}
// If we got the url or
// if there were no errors or
// if were not signed in and got an unauthorized error
@@ -4,6 +4,11 @@
import { HttpServer } from "resource://testing-common/httpd.sys.mjs";
import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
const SERVER_PATH = "/api/v1/create";
const SHARE_PATH = "/share/mockShare001";
@@ -21,6 +26,7 @@ class ContentSharingMockServerClass {
#originalServerUrl = null;
#mockResponse = null;
#mockResponseStatus = 201;
#responseDelay = 0;
get url() {
return this.#url;
}
@@ -47,6 +53,13 @@ class ContentSharingMockServerClass {
this.#mockResponseStatus = value;
}
get responseDelay() {
return this.#responseDelay;
}
set responseDelay(value) {
this.#responseDelay = value;
}
constructor() {
this.#httpServer = new HttpServer();
this.#httpServer.registerPathHandler(SERVER_PATH, (req, resp) =>
@@ -99,6 +112,7 @@ class ContentSharingMockServerClass {
this.#requests = [];
this.#mockResponse = { url: this.#mockShareURL };
this.#mockResponseStatus = 201;
this.#responseDelay = 0;
}
#handleRequest(httpRequest, httpResponse) {
@@ -119,14 +133,22 @@ class ContentSharingMockServerClass {
this.#requests.push({ body });
httpResponse.processAsync();
httpResponse.setStatusLine("", this.#mockResponseStatus, "");
if (this.#mockResponseStatus !== 401) {
httpResponse.setHeader("Set-Cookie", COOKIE_CONTENTS);
const respond = () => {
httpResponse.setStatusLine("", this.#mockResponseStatus, "");
if (this.#mockResponseStatus !== 401) {
httpResponse.setHeader("Set-Cookie", COOKIE_CONTENTS);
}
httpResponse.setHeader("Content-Type", "application/json", false);
httpResponse.write(JSON.stringify(this.#mockResponse));
httpResponse.finish();
};
if (this.#responseDelay > 0) {
lazy.setTimeout(respond, this.#responseDelay);
} else {
respond();
}
httpResponse.setHeader("Content-Type", "application/json", false);
httpResponse.write(JSON.stringify(this.#mockResponse));
httpResponse.finish();
}
}
@@ -8,10 +8,12 @@ prefs = [
["browser_testFeatureDisabled.js"]
["browser_testPrivateBrowsing.js"]
["browser_testLoadingState.js"]
["browser_testModalErrors.js"]
["browser_testPrivateBrowsing.js"]
["browser_testServerErrors.js"]
["browser_testShareBookmarks.js"]
@@ -0,0 +1,69 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(async function test_loadingStateWhileCreatingLink() {
await withContentSharingMockServer(async server => {
let tabs = [
BrowserTestUtils.addTab(gBrowser, "https://example.com"),
BrowserTestUtils.addTab(gBrowser, "https://example.com?1"),
];
await Promise.all(
tabs.map(async tab => {
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
})
);
server.responseDelay = 500;
const sharePromise = ContentSharingUtils.handleShareTabs(tabs);
await TestUtils.waitForCondition(() => window.gDialogBox.isOpen);
const modalEl = await TestUtils.waitForCondition(() =>
window.gDialogBox.dialog.frameContentWindow.document.querySelector(
"content-sharing-modal"
)
);
await TestUtils.waitForCondition(() => BrowserTestUtils.isVisible(modalEl));
await TestUtils.waitForCondition(() => modalEl.getUpdateComplete);
await modalEl.getUpdateComplete();
Assert.ok(
BrowserTestUtils.isVisible(modalEl.loadingButton),
"Loading button is visible while API call is in progress"
);
Assert.ok(
!modalEl.copyButton,
"Copy button is not rendered during loading"
);
Assert.ok(
!modalEl.signInButton,
"Sign in button is not rendered during loading"
);
Assert.ok(
!modalEl.errorMessageBar,
"Error message bar is not rendered during loading"
);
await sharePromise;
await TestUtils.waitForCondition(
() => !modalEl.shareResult?.loadingPromise
);
await TestUtils.waitForCondition(() => modalEl.getUpdateComplete);
await modalEl.getUpdateComplete();
Assert.ok(
BrowserTestUtils.isVisible(modalEl.copyButton),
"Copy button is visible after loading completes"
);
Assert.ok(
!modalEl.loadingButton,
"Loading button is not rendered after loading completes"
);
window.gDialogBox.dialog.close();
gBrowser.removeTabs(tabs);
});
});
@@ -32,6 +32,7 @@ add_task(async function test_tooManyLinks() {
url: server.mockResponse.url,
isSchemaValid: true,
isSignedIn: true,
loadingPromise: null,
});
Assert.equal(body.type, "tabs", "Share type is 'tabs'");
@@ -91,6 +92,7 @@ add_task(async function test_genericError() {
url: null,
isSchemaValid: true,
isSignedIn: true,
loadingPromise: null,
});
Assert.equal(body.type, "tabs", "Share type is 'tabs'");
@@ -88,6 +88,7 @@ add_task(async function test_createShareableLink() {
url: server.mockResponse.url,
isSchemaValid: true,
isSignedIn: true,
loadingPromise: null,
},
true
);
@@ -64,6 +64,7 @@ add_task(async function test_handleShareTabGroup() {
url: server.mockResponse.url,
isSchemaValid: true,
isSignedIn: true,
loadingPromise: null,
});
Assert.equal(body.type, "tab_group", "Share type is 'tab_group'");
@@ -33,6 +33,7 @@ add_task(async function test_handleShareTabs() {
url: server.mockResponse.url,
isSchemaValid: true,
isSignedIn: true,
loadingPromise: null,
});
Assert.equal(body.type, "tabs", "Share type is 'tabs'");
@@ -63,11 +63,6 @@ async function withContentSharingMockServer(task) {
async function assertContentSharingModal(window, expected) {
Assert.ok(window.gDialogBox.isOpen, "Content sharing modal should be open");
Assert.deepEqual(
window.gDialogBox.dialog.frameContentWindow.arguments[0],
expected,
"The window has the expected arguments"
);
// Wait for the modal to be fully rendered
const modalEl = await TestUtils.waitForCondition(() =>
@@ -76,8 +71,22 @@ async function assertContentSharingModal(window, expected) {
)
);
await TestUtils.waitForCondition(() => BrowserTestUtils.isVisible(modalEl));
// If the modal is still loading, wait for the loadingPromise to resolve
// before asserting on the final shareResult state.
if (modalEl.shareResult?.loadingPromise) {
await modalEl.shareResult.loadingPromise;
await modalEl.getUpdateComplete();
}
await TestUtils.waitForCondition(() => modalEl.getUpdateComplete);
await modalEl.getUpdateComplete();
Assert.deepEqual(
modalEl.shareResult,
expected,
"Modal has the expected share result"
);
await TestUtils.waitForCondition(
() => modalEl.links?.length === Math.min(expected.share.links.length, 3)
);
@@ -22,6 +22,9 @@ content-sharing-modal-view-page =
content-sharing-modal-copy-link =
.label = Copy link
content-sharing-modal-generating-page =
.label = Generating page…
content-sharing-modal-link-copied =
.label = Link copied