mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-03 09:48:38 +02:00
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:
parent
6fa0204b44
commit
e87c1712fb
20 changed files with 642 additions and 78 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue