Bug 1965529 - Add saving custom image functionality to profiles avatar picker. r=profiles-reviewers,fluent-reviewers,bolsson,jhirsch

Differential Revision: https://phabricator.services.mozilla.com/D250926
This commit is contained in:
Niklas Baumgardner 2025-06-16 19:32:15 +00:00 committed by nbaumgardner@mozilla.com
parent 6fa0204b44
commit e87c1712fb
20 changed files with 642 additions and 78 deletions

View file

@ -104,11 +104,9 @@ var gProfiles = {
"label",
SelectableProfileService.currentProfile.name
);
let avatar = SelectableProfileService.currentProfile.avatar;
profilesButton.setAttribute(
"image",
`chrome://browser/content/profiles/assets/16_${avatar}.svg`
);
let avatarURL =
await SelectableProfileService.currentProfile.getAvatarURL(16);
profilesButton.setAttribute("image", `${avatarURL}`);
},
/**
@ -131,7 +129,7 @@ var gProfiles = {
menuitem.setAttribute("label", profile.name);
menuitem.style.setProperty("--menu-profiles-theme-bg", themeBg);
menuitem.style.setProperty("--menu-profiles-theme-fg", themeFg);
menuitem.style.listStyleImage = `url(chrome://browser/content/profiles/assets/48_${profile.avatar}.svg)`;
menuitem.style.listStyleImage = `url(${await profile.getAvatarURL(48)})`;
menuitem.classList.add("menuitem-iconic", "menuitem-iconic-profile");
if (profile.id === currentProfile.id) {
@ -325,8 +323,7 @@ var gProfiles = {
themeFg
);
let avatar = currentProfile.avatar;
profileIconEl.style.listStyleImage = `url("chrome://browser/content/profiles/assets/80_${avatar}.svg")`;
profileIconEl.style.listStyleImage = `url(${await currentProfile.getAvatarURL(80)})`;
}
let subtitle = PanelMultiView.getViewNode(document, "profiles-subtitle");
@ -348,10 +345,7 @@ var gProfiles = {
let { themeFg, themeBg } = profile.theme;
button.style.setProperty("--appmenu-profiles-theme-bg", themeBg);
button.style.setProperty("--appmenu-profiles-theme-fg", themeFg);
button.setAttribute(
"image",
`chrome://browser/content/profiles/assets/16_${profile.avatar}.svg`
);
button.setAttribute("image", `${await profile.getAvatarURL(16)}`);
profilesList.appendChild(button);
}

View file

@ -183,8 +183,8 @@ export class ProfilesParent extends JSWindowActorParent {
let profiles = await SelectableProfileService.getAllProfiles();
let themes = await this.getSafeForContentThemes();
return {
currentProfile: currentProfile.toObject(),
profiles: profiles.map(p => p.toObject()),
currentProfile: await currentProfile.toContentSafeObject(),
profiles: await Promise.all(profiles.map(p => p.toContentSafeObject())),
themes,
isInAutomation: Cu.isInAutomation,
};
@ -282,7 +282,8 @@ export class ProfilesParent extends JSWindowActorParent {
// Make sure SelectableProfileService is initialized
await SelectableProfileService.init();
Glean.profilesDelete.displayed.record();
let profileObj = SelectableProfileService.currentProfile.toObject();
let profileObj =
await SelectableProfileService.currentProfile.toContentSafeObject();
let windowCount = lazy.EveryWindow.readyWindows.length;
let tabCount = lazy.EveryWindow.readyWindows
.flatMap(win => win.gBrowser.openTabs.length)
@ -326,14 +327,20 @@ export class ProfilesParent extends JSWindowActorParent {
};
}
case "Profiles:UpdateProfileAvatar": {
let avatar = message.data.avatar;
SelectableProfileService.currentProfile.avatar = avatar;
let { avatarOrFile } = message.data;
await SelectableProfileService.currentProfile.setAvatar(avatarOrFile);
let value = SelectableProfileService.currentProfile.isCustomAvatar
? "custom"
: avatarOrFile;
if (source === "about:editprofile") {
Glean.profilesExisting.avatar.record({ value: avatar });
Glean.profilesExisting.avatar.record({ value });
} else if (source === "about:newprofile") {
Glean.profilesNew.avatar.record({ value: avatar });
Glean.profilesNew.avatar.record({ value });
}
break;
let profileObj =
await SelectableProfileService.currentProfile.toContentSafeObject();
return profileObj;
}
case "Profiles:UpdateProfileTheme": {
let themeId = message.data;

View file

@ -5,6 +5,42 @@
import { ProfilesDatastoreService } from "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs";
import { SelectableProfileService } from "resource:///modules/profiles/SelectableProfileService.sys.mjs";
const STANDARD_AVATARS = new Set([
"barbell",
"bike",
"book",
"briefcase",
"canvas",
"craft",
"default-favicon",
"diamond",
"flower",
"folder",
"hammer",
"heart",
"heart-rate",
"history",
"leaf",
"lightbulb",
"makeup",
"message",
"musical-note",
"palette",
"paw-print",
"plane",
"present",
"shopping",
"soccer",
"sparkle-single",
"star",
"video-game-controller",
]);
const STANDARD_AVATAR_SIZES = [16, 20, 24, 48, 60, 80];
function standardAvatarURL(avatar, size = "80") {
return `chrome://browser/content/profiles/assets/${size}_${avatar}.svg`;
}
/**
* The selectable profile
*/
@ -19,10 +55,15 @@ export class SelectableProfile {
// The user-editable name
#name;
// Name of the user's chosen avatar, which corresponds to a list of built-in
// SVG avatars.
// Name of the user's chosen avatar, which corresponds to a list of standard
// SVG avatars. Or if the avatar is a custom image, the filename of the image
// stored in the avatars directory.
#avatar;
// lastAvatarURL is saved when URL.createObjectURL is invoked so we can
// revoke the url at a later time.
#lastAvatarURL;
// Cached theme properties, used to allow displaying a SelectableProfile
// without loading the AddonManager to get theme info.
#themeId;
@ -122,16 +163,116 @@ export class SelectableProfile {
return this.#avatar;
}
/**
* Get the path of the current avatar.
* If the avatar is standard, the return value will be of the form
* 'chrome://browser/content/profiles/assets/{avatar}.svg'.
* If the avatar is custom, the return value will be the path to the file on
* disk.
*
* @returns {string} Path to the current avatar.
*/
getAvatarPath(size) {
if (!this.hasCustomAvatar) {
return standardAvatarURL(this.avatar, size);
}
return PathUtils.join(
ProfilesDatastoreService.constructor.PROFILE_GROUPS_DIR,
"avatars",
this.avatar
);
}
/**
* Get the URL of the current avatar.
* If the avatar is standard, the return value will be of the form
* 'chrome://browser/content/profiles/assets/${size}_${avatar}.svg'.
* If the avatar is custom, the return value will be a blob URL.
*
* @param {string|number} size optional Must be one of the sizes in
* STANDARD_AVATAR_SIZES. Will be converted to a string.
*
* @returns {Promise<string>} Resolves to the URL of the current avatar
*/
async getAvatarURL(size) {
if (!this.hasCustomAvatar) {
return standardAvatarURL(this.avatar, size);
}
if (this.#lastAvatarURL) {
URL.revokeObjectURL(this.#lastAvatarURL);
}
const fileExists = await IOUtils.exists(this.getAvatarPath());
if (!fileExists) {
throw new Error("Custom avatar file doesn't exist.");
}
const file = await File.createFromFileName(this.getAvatarPath());
this.#lastAvatarURL = URL.createObjectURL(file);
return this.#lastAvatarURL;
}
/**
* Get the avatar file. This is only used for custom avatars to generate an
* object url. Standard avatars should use getAvatarURL or getAvatarPath.
*
* @returns {Promise<File>} Resolves to a file of the avatar
*/
async getAvatarFile() {
if (!this.hasCustomAvatar) {
throw new Error(
"Profile does not have custom avatar. Custom avatar file doesn't exist."
);
}
return File.createFromFileName(this.getAvatarPath());
}
get hasCustomAvatar() {
return !STANDARD_AVATARS.has(this.avatar);
}
/**
* Update the avatar, then trigger saving the profile, which will notify()
* other running instances.
*
* @param {string} aAvatar Name of the avatar
* @param {string|File} aAvatarOrFile Name of the avatar or File os custom avatar
*/
set avatar(aAvatar) {
this.#avatar = aAvatar;
async setAvatar(aAvatarOrFile) {
if (aAvatarOrFile === this.avatar) {
// The avatar is the same so do nothing. See the comment in
// SelectableProfileService.maybeSetupDataStore for resetting the avatar
// to draw the avatar icon in the dock.
} else if (STANDARD_AVATARS.has(aAvatarOrFile)) {
this.#avatar = aAvatarOrFile;
} else {
await this.#uploadCustomAvatar(aAvatarOrFile);
}
this.saveUpdatesToDB();
await this.saveUpdatesToDB();
}
async #uploadCustomAvatar(file) {
const avatarsDir = PathUtils.join(
ProfilesDatastoreService.constructor.PROFILE_GROUPS_DIR,
"avatars"
);
// Create avatars directory if it does not exist
await IOUtils.makeDirectory(avatarsDir, { ignoreExisting: true });
let uuid = Services.uuid.generateUUID().toString().slice(1, -1);
const filePath = PathUtils.join(avatarsDir, uuid);
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
await IOUtils.write(filePath, uint8Array, { tmpPath: `${filePath}.tmp` });
this.#avatar = uuid;
}
/**
@ -155,7 +296,7 @@ export class SelectableProfile {
return "star-avatar-alt";
}
return "";
return "custom-avatar-alt";
}
// Note, theme properties are set and returned as a group.
@ -205,16 +346,66 @@ export class SelectableProfile {
SelectableProfileService.updateProfile(this);
}
toObject() {
/**
* Returns on object with only fields needed for the database.
*
* @returns {object} An object with only fields need for the database
*/
async toDbObject() {
let profileObj = {
id: this.id,
path: this.#path,
name: this.name,
avatar: this.avatar,
...this.theme,
};
return profileObj;
}
/**
* Returns an object representation of the profile.
* Note: No custom avatar URLs are included because URL.createObjectURL needs
* to be invoked in the content process for the avatar to be visible.
*
* @returns {object} An object representation of the profile
*/
async toContentSafeObject() {
let profileObj = {
id: this.id,
path: this.#path,
name: this.name,
avatar: this.avatar,
avatarL10nId: this.avatarL10nId,
hasCustomAvatar: this.hasCustomAvatar,
...this.theme,
};
if (this.hasCustomAvatar) {
let path = this.getAvatarPath();
let file = await this.getAvatarFile();
profileObj.avatarPaths = Object.fromEntries(
STANDARD_AVATAR_SIZES.map(s => [`path${s}`, path])
);
profileObj.avatarFiles = Object.fromEntries(
STANDARD_AVATAR_SIZES.map(s => [`file${s}`, file])
);
profileObj.avatarURLs = {};
} else {
profileObj.avatarPaths = Object.fromEntries(
STANDARD_AVATAR_SIZES.map(s => [`path${s}`, this.getAvatarPath(s)])
);
profileObj.avatarURLs = Object.fromEntries(
await Promise.all(
STANDARD_AVATAR_SIZES.map(async s => [
`url${s}`,
await this.getAvatarURL(s),
])
)
);
}
return profileObj;
}
}

View file

@ -55,7 +55,7 @@ const COMMAND_LINE_ACTIVATE = "profiles-activate";
const gSupportsBadging = "nsIMacDockSupport" in Ci || "nsIWinTaskbar" in Ci;
function loadImage(url) {
function loadBuiltInAvatarImage(uri, channel) {
return new Promise((resolve, reject) => {
let imageTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
let imageContainer;
@ -67,15 +67,8 @@ function loadImage(url) {
});
imageTools.decodeImageFromChannelAsync(
url,
Services.io.newChannelFromURI(
url,
null,
Services.scriptSecurityManager.getSystemPrincipal(),
null, // aTriggeringPrincipal
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
Ci.nsIContentPolicy.TYPE_IMAGE
),
uri,
channel,
(image, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Components.Exception("Image loading failed", status));
@ -88,6 +81,66 @@ function loadImage(url) {
});
}
async function getCustomAvatarImageType(blob, channel) {
let octets = await new Promise((resolve, reject) => {
let reader = new FileReader();
reader.addEventListener("load", () => {
resolve(Array.from(reader.result).map(c => c.charCodeAt(0)));
});
reader.addEventListener("error", reject);
reader.readAsBinaryString(blob);
});
let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
Ci.nsIContentSniffer
);
let type = sniffer.getMIMETypeFromContent(channel, octets, octets.length);
return type;
}
async function loadCustomAvatarImage(profile, channel) {
let blob = await profile.getAvatarFile();
const imageTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
let type = await getCustomAvatarImageType(blob, channel);
let buffer = await blob.arrayBuffer();
const image = imageTools.decodeImageFromArrayBuffer(buffer, type);
return image;
}
async function loadImage(profile) {
let uri;
if (SelectableProfileService.currentProfile.hasCustomAvatar) {
const file = await IOUtils.getFile(
SelectableProfileService.currentProfile.getAvatarPath(48)
);
uri = Services.io.newFileURI(file);
} else {
uri = Services.io.newURI(
SelectableProfileService.currentProfile.getAvatarPath(48)
);
}
const channel = Services.io.newChannelFromURI(
uri,
null,
Services.scriptSecurityManager.getSystemPrincipal(),
null,
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
Ci.nsIContentPolicy.TYPE_IMAGE
);
if (profile.isCustomAvatar) {
return loadCustomAvatarImage(profile, channel);
}
return loadBuiltInAvatarImage(uri, channel);
}
/**
* The service that manages selectable profiles
*/
@ -589,13 +642,7 @@ class SelectableProfileServiceClass extends EventEmitter {
if (count > 1 && !this.#badge) {
this.#badge = {
image: await loadImage(
Services.io.newURI(
`chrome://browser/content/profiles/assets/48_${
this.#currentProfile.avatar
}.svg`
)
),
image: await loadImage(this.#currentProfile),
iconPaintContext: this.#currentProfile.iconPaintContext,
description: this.#currentProfile.name,
};
@ -611,11 +658,18 @@ class SelectableProfileServiceClass extends EventEmitter {
.getOverlayIconController(win.docShell);
TASKBAR_ICON_CONTROLLERS.set(win, iconController);
iconController.setOverlayIcon(
this.#badge.image,
this.#badge.description,
this.#badge.iconPaintContext
);
if (this.#currentProfile.isCustomAvatar) {
iconController.setOverlayIcon(
this.#badge.image,
this.#badge.description
);
} else {
iconController.setOverlayIcon(
this.#badge.image,
this.#badge.description,
this.#badge.iconPaintContext
);
}
}
}
} else if (count <= 1 && this.#badge) {
@ -1116,7 +1170,7 @@ class SelectableProfileServiceClass extends EventEmitter {
lazy.setTimeout(() => {
// To avoid displeasing the linter, assign to a temporary variable.
let avatar = SelectableProfileService.currentProfile.avatar;
SelectableProfileService.currentProfile.avatar = avatar;
SelectableProfileService.currentProfile.setAvatar(avatar);
}, 1000);
}
}
@ -1239,8 +1293,7 @@ class SelectableProfileServiceClass extends EventEmitter {
* @param {SelectableProfile} aSelectableProfile The SelectableProfile to be updated
*/
async updateProfile(aSelectableProfile) {
let profileObj = aSelectableProfile.toObject();
delete profileObj.avatarL10nId;
let profileObj = await aSelectableProfile.toDbObject();
await this.#connection.execute(
`UPDATE Profiles

View file

@ -39,6 +39,12 @@ export class DeleteProfileCard extends MozLitElement {
this.data = await RPMSendQuery("Profiles:GetDeleteProfileContent");
if (this.data.profile.hasCustomAvatar) {
const objURL = URL.createObjectURL(this.data.profile.avatarFiles.file16);
this.data.profile.avatarURLs.url16 = objURL;
this.data.profile.avatarURLs.url80 = objURL;
}
let titleEl = document.querySelector("title");
titleEl.setAttribute(
"data-l10n-args",
@ -64,7 +70,7 @@ export class DeleteProfileCard extends MozLitElement {
setFavicon() {
let favicon = document.getElementById("favicon");
favicon.href = `chrome://browser/content/profiles/assets/16_${this.data.profile.avatar}.svg`;
favicon.href = this.data.profile.avatarURLs.url16;
}
cancelDelete() {
@ -95,8 +101,7 @@ export class DeleteProfileCard extends MozLitElement {
width="80"
height="80"
data-l10n-id=${this.data.profile.avatarL10nId}
src="chrome://browser/content/profiles/assets/80_${this.data.profile
.avatar}.svg"
src=${this.data.profile.avatarURLs.url80}
/>
<div id="profile-content">
<div>

View file

@ -7,7 +7,7 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src resource: chrome:; object-src 'none'; img-src chrome:;"
content="default-src resource: chrome:; object-src 'none'; img-src blob: chrome:;"
/>
<link id="favicon" rel="icon" type="image/svg+xml" />

View file

@ -125,7 +125,11 @@ export class EditProfileCard extends MozLitElement {
window.addEventListener("beforeunload", this);
window.addEventListener("pagehide", this);
document.addEventListener("click", this);
if (RPMGetBoolPref(UPDATED_AVATAR_SELECTOR_PREF, false)) {
document.addEventListener("click", this);
document.addEventListener("Profiles:CustomAvatarUpload", this);
}
this.init().then(() => (this.initialized = true));
}
@ -146,9 +150,27 @@ export class EditProfileCard extends MozLitElement {
this.profiles = profiles;
this.themes = themes;
if (this.profile.hasCustomAvatar) {
this.createAvatarURL();
}
this.setFavicon();
}
createAvatarURL() {
if (this.profile.avatarURLs.url16) {
URL.revokeObjectURL(this.profile.avatarURLs.url16);
delete this.profile.avatarURLs.url16;
delete this.profile.avatarURLs.url80;
}
if (this.profile.avatarFiles?.file16) {
const objURL = URL.createObjectURL(this.profile.avatarFiles.file16);
this.profile.avatarURLs.url16 = objURL;
this.profile.avatarURLs.url80 = objURL;
}
}
async getUpdateComplete() {
const result = await super.getUpdateComplete();
@ -163,7 +185,7 @@ export class EditProfileCard extends MozLitElement {
setFavicon() {
let favicon = document.getElementById("favicon");
favicon.href = `chrome://browser/content/profiles/assets/16_${this.profile.avatar}.svg`;
favicon.href = this.profile.avatarURLs.url16;
}
getAvatarL10nId(value) {
@ -208,6 +230,11 @@ export class EditProfileCard extends MozLitElement {
this.avatarSelector.hidden = true;
break;
}
case "Profiles:CustomAvatarUpload": {
let { file } = event.detail;
this.updateAvatar(file);
break;
}
}
}
@ -254,8 +281,16 @@ export class EditProfileCard extends MozLitElement {
return;
}
this.profile.avatar = newAvatar;
RPMSendAsyncMessage("Profiles:UpdateProfileAvatar", this.profile);
let updatedProfile = await RPMSendQuery("Profiles:UpdateProfileAvatar", {
avatarOrFile: newAvatar,
});
this.profile = updatedProfile;
if (this.profile.hasCustomAvatar) {
this.createAvatarURL();
}
this.requestUpdate();
this.setFavicon();
}
@ -413,8 +448,7 @@ export class EditProfileCard extends MozLitElement {
<img
id="header-avatar"
data-l10n-id=${this.profile.avatarL10nId}
src="chrome://browser/content/profiles/assets/80_${this.profile
.avatar}.svg"
src=${this.profile.avatarURLs.url80}
/>
<a
id="profile-avatar-selector-link"
@ -433,8 +467,7 @@ export class EditProfileCard extends MozLitElement {
return html`<img
id="header-avatar"
data-l10n-id=${this.profile.avatarL10nId}
src="chrome://browser/content/profiles/assets/20_${this.profile
.avatar}.svg"
src=${this.profile.avatarURLs.url80}
/>`;
}

View file

@ -7,7 +7,7 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src chrome:; object-src 'none'; img-src chrome:;"
content="default-src chrome:; object-src 'none'; img-src blob: chrome:;"
/>
<link id="favicon" rel="icon" type="image/svg+xml" />

View file

@ -31,6 +31,10 @@ export class NewProfileCard extends EditProfileCard {
this.profiles = profiles;
this.themes = themes;
if (this.profile.hasCustomAvatar) {
super.createAvatarURL();
}
await Promise.all([
this.setInitialInput(),
this.setRandomTheme(isInAutomation),

View file

@ -7,7 +7,7 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src chrome:; object-src 'none'; img-src chrome:;"
content="default-src chrome:; object-src 'none'; img-src blob: chrome:;"
/>
<link id="favicon" rel="icon" type="image/svg+xml" />

View file

@ -29,3 +29,51 @@
--button-icon-fill: transparent;
--button-icon-stroke: currentColor;
}
.custom-avatar-area {
width: 230px;
height: 194px;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
}
#upload-text {
color: var(--link-color);
text-decoration: underline;
}
#drag-text {
font-size: var(--font-size-small);
}
#custom-avatar-image {
max-width: 100%;
max-height: 100%;
}
.custom-avatar-actions {
justify-content: center;
}
#custom-image {
width: 100%;
height: 100%;
position: absolute;
z-index: 1;
opacity: 0;
cursor: pointer;
}
#file-messages {
width: 100%;
height: 100%;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--space-small);
}

View file

@ -12,8 +12,20 @@ import { html } from "chrome://global/content/vendor/lit.all.mjs";
export class ProfileAvatarSelector extends MozLitElement {
static properties = {
value: { type: String },
state: { type: String },
};
static queries = {
input: "#custom-image",
saveButton: "#save-button",
};
constructor() {
super();
this.state = "custom";
}
getAvatarL10nId(value) {
switch (value) {
case "book":
@ -33,7 +45,7 @@ export class ProfileAvatarSelector extends MozLitElement {
return "";
}
iconTabContent() {
iconTabContentTemplate() {
let avatars = [
"star",
"flower",
@ -90,12 +102,104 @@ export class ProfileAvatarSelector extends MozLitElement {
>`;
}
customTabUploadFileContentTemplate() {
return html`<div class="custom-avatar-area">
<input
@change=${this.handleFileUpload}
id="custom-image"
type="file"
accept="image/*"
label="Upload a file"
/>
<div id="file-messages">
<img src="chrome://browser/skin/open.svg" />
<span
id="upload-text"
data-l10n-id="avatar-selector-upload-file"
></span>
<span id="drag-text" data-l10n-id="avatar-selector-drag-file"></span>
</div>
</div>`;
}
handleCancelClick(event) {
event.stopImmediatePropagation();
this.state = "custom";
if (this.blobURL) {
URL.revokeObjectURL(this.blobURL);
}
this.file = null;
}
async handleSaveClick(event) {
event.stopImmediatePropagation();
document.dispatchEvent(
new CustomEvent("Profiles:CustomAvatarUpload", {
detail: { file: this.file },
})
);
if (this.blobURL) {
URL.revokeObjectURL(this.blobURL);
}
this.state = "custom";
this.hidden = true;
}
customTabViewImageTemplate() {
return html`<div class="custom-avatar-area">
<img id="custom-avatar-image" src=${this.blobURL} />
</div>
<moz-button-group class="custom-avatar-actions"
><moz-button
@click=${this.handleCancelClick}
data-l10n-id="avatar-selector-cancel-button"
></moz-button
><moz-button
type="primary"
id="save-button"
@click=${this.handleSaveClick}
data-l10n-id="avatar-selector-save-button"
></moz-button
></moz-button-group>`;
}
handleFileUpload(event) {
const [file] = event.target.files;
this.file = file;
if (this.blobURL) {
URL.revokeObjectURL(this.blobURL);
}
this.blobURL = URL.createObjectURL(file);
this.state = "crop";
}
contentTemplate() {
switch (this.state) {
case "icon": {
return this.iconTabContentTemplate();
}
case "custom": {
return this.customTabUploadFileContentTemplate();
}
case "crop": {
return this.customTabViewImageTemplate();
}
}
return null;
}
render() {
return html`<link
rel="stylesheet"
href="chrome://browser/content/profiles/profile-avatar-selector.css"
/>
<moz-card>
<moz-card id="avatar-selector">
<div class="button-group">
<moz-button
type="primary"
@ -103,7 +207,7 @@ export class ProfileAvatarSelector extends MozLitElement {
></moz-button
><moz-button data-l10n-id="avatar-selector-custom-tab"></moz-button>
</div>
${this.iconTabContent()}
${this.contentTemplate()}
</moz-card>`;
}
}

View file

@ -50,8 +50,8 @@ export class ProfileCard extends MozLitElement {
this.backgroundImage.style.stroke = themeFg;
}
setAvatarImage() {
this.avatarImage.style.backgroundImage = `url("chrome://browser/content/profiles/assets/80_${this.profile.avatar}.svg")`;
async setAvatarImage() {
this.avatarImage.style.backgroundImage = `url(${await this.profile.getAvatarURL(80)})`;
let { themeFg, themeBg } = this.profile.theme;
this.avatarImage.style.fill = themeBg;
this.avatarImage.style.stroke = themeFg;

View file

@ -14,7 +14,7 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src resource: chrome:; object-src 'none'; img-src chrome:;"
content="default-src resource: chrome:; object-src 'none'; img-src blob: chrome:;"
/>
<link rel="localization" href="branding/brand.ftl" />

View file

@ -171,7 +171,7 @@ add_task(async function test_new_profile_avatar() {
let expectedAvatar = "briefcase";
// Before we load the new page, set the profile's avatar to something other
// than the expected item.
profile.avatar = "flower";
await profile.setAvatar("flower");
await SelectableProfileService.updateProfile(profile);
await BrowserTestUtils.withNewTab(

View file

@ -31,7 +31,6 @@ add_task(async function test_edit_profile_custom_avatar() {
},
async browser => {
await SpecialPowers.spawn(browser, [], async () => {
await new Promise(r => content.setTimeout(r, 4000));
let editProfileCard =
content.document.querySelector("edit-profile-card").wrappedJSObject;
@ -55,5 +54,119 @@ add_task(async function test_edit_profile_custom_avatar() {
});
}
);
await SpecialPowers.popPrefEnv();
});
add_task(async function test_edit_profile_custom_avatar_upload() {
if (!AppConstants.MOZ_SELECTABLE_PROFILES) {
// `mochitest-browser` suite `add_task` does not yet support
// `properties.skip_if`.
ok(true, "Skipping because !AppConstants.MOZ_SELECTABLE_PROFILES");
return;
}
const profile = await setup();
await SpecialPowers.pushPrefEnv({
set: [["browser.profiles.updated-avatar-selector", true]],
});
const mockAvatarFilePath = await IOUtils.createUniqueFile(
PathUtils.tempDir,
"avatar.png"
);
const mockAvatarFile = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
mockAvatarFile.initWithPath(mockAvatarFilePath);
const MockFilePicker = SpecialPowers.MockFilePicker;
MockFilePicker.init(window.browsingContext);
MockFilePicker.setFiles([mockAvatarFile]);
MockFilePicker.returnValue = MockFilePicker.returnOK;
let curProfile = await SelectableProfileService.getProfile(profile.id);
Assert.ok(
!curProfile.hasCustomAvatar,
"Current profile does not have a custom avatar"
);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: "about:editprofile",
},
async browser => {
await SpecialPowers.spawn(browser, [], async () => {
let editProfileCard =
content.document.querySelector("edit-profile-card").wrappedJSObject;
await ContentTaskUtils.waitForCondition(
() => editProfileCard.initialized,
"Waiting for edit-profile-card to be initialized"
);
await editProfileCard.updateComplete;
const avatarSelector = editProfileCard.avatarSelector;
EventUtils.synthesizeMouseAtCenter(
editProfileCard.avatarSelectorLink,
{},
content
);
Assert.ok(
ContentTaskUtils.isVisible(avatarSelector),
"Should be showing the profile avatar selector"
);
avatarSelector.state = "custom";
await avatarSelector.updateComplete;
await ContentTaskUtils.waitForCondition(
() => ContentTaskUtils.isVisible(avatarSelector.input),
"Waiting for avatar selector input to be visible"
);
const inputReceived = new Promise(resolve =>
avatarSelector.input.addEventListener(
"input",
event => {
resolve(event.target.files[0].name);
},
{ once: true }
)
);
EventUtils.synthesizeMouseAtCenter(avatarSelector.input, {}, content);
await inputReceived;
await ContentTaskUtils.waitForCondition(
() => ContentTaskUtils.isVisible(avatarSelector.saveButton),
"Waiting for avatar selector save button to be visible"
);
EventUtils.synthesizeMouseAtCenter(
avatarSelector.saveButton,
{},
content
);
// Sometimes the async message takes a bit longer to arrive.
await new Promise(resolve => content.setTimeout(resolve, 100));
});
}
);
curProfile = await SelectableProfileService.getProfile(profile.id);
Assert.ok(
curProfile.hasCustomAvatar,
"Current profile has a custom avatar image"
);
MockFilePicker.cleanup();
await SpecialPowers.popPrefEnv();
});

View file

@ -148,7 +148,7 @@ add_task(async function test_edit_profile_avatar() {
// Before we load the edit page, set the profile's avatar to something other
// than the 0th item.
profile.avatar = "flower";
await profile.setAvatar("flower");
let expectedAvatar = "book";
is(

View file

@ -47,6 +47,12 @@ edit-profile-page-delete-button =
edit-profile-page-avatar-selector-opener-link = Edit
avatar-selector-icon-tab = Icon
avatar-selector-custom-tab = Custom
avatar-selector-cancel-button =
.label = Cancel
avatar-selector-save-button =
.label = Save
avatar-selector-upload-file = Upload a file
avatar-selector-drag-file = Or drag a file here
edit-profile-page-no-name = Name this profile to help you find it later. Rename it any time.
edit-profile-page-duplicate-name = Profile name already in use. Try a new name.
@ -134,6 +140,8 @@ shopping-avatar-alt =
.alt = Shopping cart
star-avatar-alt =
.alt = Star
custom-avatar-alt =
.alt = Custom avatar
## Labels for default avatar icons

View file

@ -1319,15 +1319,19 @@ static nsLiteralCString sStyleSrcUnsafeInlineAllowList[] = {
static nsLiteralCString sImgSrcDataBlobAllowList[] = {
"about:addons"_ns,
"about:debugging"_ns,
"about:deleteprofile"_ns,
"about:devtools-toolbox"_ns,
"about:editprofile"_ns,
"about:firefoxview"_ns,
"about:home"_ns,
"about:inference"_ns,
"about:logins"_ns,
"about:newprofile"_ns,
"about:newtab"_ns,
"about:preferences"_ns,
"about:privatebrowsing"_ns,
"about:processes"_ns,
"about:profilemanager"_ns,
"about:protections"_ns,
"about:reader"_ns,
"about:sessionrestore"_ns,

View file

@ -145,10 +145,10 @@ export let RemotePageAccessManager = {
RPMSendQuery: [
"Profiles:GetEditProfileContent",
"Profiles:UpdateProfileTheme",
"Profiles:UpdateProfileAvatar",
],
RPMSendAsyncMessage: [
"Profiles:UpdateProfileName",
"Profiles:UpdateProfileAvatar",
"Profiles:OpenDeletePage",
"Profiles:CloseProfileTab",
"Profiles:MoreThemes",
@ -160,10 +160,10 @@ export let RemotePageAccessManager = {
RPMSendQuery: [
"Profiles:GetNewProfileContent",
"Profiles:UpdateProfileTheme",
"Profiles:UpdateProfileAvatar",
],
RPMSendAsyncMessage: [
"Profiles:UpdateProfileName",
"Profiles:UpdateProfileAvatar",
"Profiles:DeleteProfile",
"Profiles:CloseProfileTab",
"Profiles:MoreThemes",