Skip to content

Instantly share code, notes, and snippets.

@tam710562
Last active March 20, 2024 15:53
Show Gist options
  • Save tam710562/67ce3c4387d72e94a83cf0ae9f890cec to your computer and use it in GitHub Desktop.
Save tam710562/67ce3c4387d72e94a83cf0ae9f890cec to your computer and use it in GitHub Desktop.
/*
* Easy Files
* Written by Tam710562
*/
(function () {
'use strict';
const gnoh = {
file: {
verifyAccept({ fileName, mimeType }, accept) {
if (!accept) {
return true;
}
const mimeTypes = accept.split(',')
.map(x => x.trim())
.filter(x => !!x && (x.startsWith('.') || /\w+\/([-+.\w]+|\*)/.test(x)));
for (const mt of mimeTypes) {
if (
mt.startsWith('.')
? new RegExp(mt.replace('.', '.+\\.') + '$').test(fileName)
: new RegExp(mt.replace('*', '.+')).test(mimeType)
) {
return true;
}
}
return false;
},
},
i18n: {
getMessageName(message, type) {
message = (type ? type + '\x04' : '') + message;
return message.replace(/[^a-z0-9]/g, function (i) {
return '_' + i.codePointAt(0) + '_';
}) + '0';
},
getMessage(message, type) {
return chrome.i18n.getMessage(this.getMessageName(message, type)) || message;
},
},
addStyle(css, id, isNotMin) {
this.styles = this.styles || {};
if (Array.isArray(css)) {
css = css.join(isNotMin === true ? '\n' : '');
}
id = id || this.uuid.generate(Object.keys(this.styles));
this.styles[id] = this.createElement('style', {
html: css || '',
'data-id': id,
}, document.head);
return this.styles[id];
},
array: {
chunks(arr, n) {
const result = [];
for (let i = 0; i < arr.length; i += n) {
result.push(arr.slice(i, i + n));
}
return result;
},
},
createElement(tagName, attribute, parent, inner, options) {
if (typeof tagName === 'undefined') {
return;
}
if (typeof options === 'undefined') {
options = {};
}
if (typeof options.isPrepend === 'undefined') {
options.isPrepend = false;
}
const el = document.createElement(tagName);
if (!!attribute && typeof attribute === 'object') {
for (const key in attribute) {
if (key === 'text') {
el.textContent = attribute[key];
} else if (key === 'html') {
el.innerHTML = attribute[key];
} else if (key === 'style' && typeof attribute[key] === 'object') {
for (const css in attribute.style) {
el.style.setProperty(css, attribute.style[css]);
}
} else if (key === 'events' && typeof attribute[key] === 'object') {
for (const event in attribute.events) {
if (typeof attribute.events[event] === 'function') {
el.addEventListener(event, attribute.events[event]);
}
}
} else if (typeof el[key] !== 'undefined') {
el[key] = attribute[key];
} else {
if (typeof attribute[key] === 'object') {
attribute[key] = JSON.stringify(attribute[key]);
}
el.setAttribute(key, attribute[key]);
}
}
}
if (!!inner) {
if (!Array.isArray(inner)) {
inner = [inner];
}
for (let i = 0; i < inner.length; i++) {
if (inner[i].nodeName) {
el.append(inner[i]);
} else {
el.append(this.createElementFromHTML(inner[i]));
}
}
}
if (typeof parent === 'string') {
parent = document.querySelector(parent);
}
if (!!parent) {
if (options.isPrepend) {
parent.prepend(el);
} else {
parent.append(el);
}
}
return el;
},
createElementFromHTML(html) {
return this.createElement('template', {
html: (html || '').trim(),
}).content;
},
string: {
toColor(str) {
let hash = 0;
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let r = (hash >> (0 * 8)) & 0xff;
let g = (hash >> (1 * 8)) & 0xff;
let b = (hash >> (2 * 8)) & 0xff;
return { r, g, b };
},
},
color: {
rgbToHex(r, g, b) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
},
getLuminance(r, g, b) {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
},
isLight(r, g, b) {
return gnoh.color.getLuminance(r, g, b) < 156;
},
shadeColor(r, g, b, percent) {
r = Math.max(Math.min(255, r + percent), 0);
g = Math.max(Math.min(255, g + percent), 0);
b = Math.max(Math.min(255, b + percent), 0);
return { r, g, b };
},
},
get constant() {
return {
dialogButtons: {
submit: {
label: this.i18n.getMessage('OK'),
type: 'submit'
},
cancel: {
label: this.i18n.getMessage('Cancel'),
cancel: true
},
primary: {
class: 'primary'
},
danger: {
class: 'danger'
},
default: {},
}
};
},
dialog(title, content, buttons = [], config) {
let modalBg;
let dialog;
let cancelEvent;
const id = this.uuid.generate();
const inner = document.querySelector('#main > .inner, #main > .webpageview');
if (!config) {
config = {};
}
if (typeof config.autoClose === 'undefined') {
config.autoClose = true;
}
function onKeyCloseDialog(windowId, key) {
if (
windowId === vivaldiWindowId
&& key === 'Esc'
) {
closeDialog(true);
}
}
function onClickCloseDialog(event) {
if (
config.autoClose
&& !event.target.closest('.dialog-custom[data-dialog-id="' + id + '"]')
) {
closeDialog(true);
}
}
function closeDialog(isCancel) {
if (isCancel === true && cancelEvent) {
cancelEvent.bind(this)();
}
if (modalBg) {
modalBg.remove();
}
vivaldi.tabsPrivate.onKeyboardShortcut.removeListener(onKeyCloseDialog);
document.removeEventListener('mousedown', onClickCloseDialog);
}
vivaldi.tabsPrivate.onKeyboardShortcut.addListener(onKeyCloseDialog);
document.addEventListener('mousedown', onClickCloseDialog);
const buttonElements = [];
for (let button of buttons) {
button.type = button.type || 'button';
const clickEvent = button.click;
if (button.cancel === true && typeof clickEvent === 'function') {
cancelEvent = clickEvent;
}
button.events = {
click(event) {
event.preventDefault();
if (typeof clickEvent === 'function') {
clickEvent.bind(this)();
}
if (button.closeDialog !== false) {
closeDialog();
}
}
};
delete button.click;
if (button.label) {
button.value = button.label;
delete button.label;
}
buttonElements.push(this.createElement('input', button));
}
const focusModal = this.createElement('span', {
class: 'focus_modal',
tabindex: '0',
});
const div = this.createElement('div', {
style: {
width: config.width ? config.width + 'px' : '',
margin: '0 auto',
}
});
dialog = this.createElement('form', {
'data-dialog-id': id,
class: 'dialog-custom modal-wrapper',
}, div);
if (config.class) {
dialog.classList.add(config.class);
}
const dialogHeader = this.createElement('header', {
class: 'dialog-header',
}, dialog, '<h1>' + (title || '') + '</h1>');
const dialogContent = this.createElement('div', {
class: 'dialog-content',
style: {
maxHeight: '65vh',
},
}, dialog, content);
if (buttons && buttons.length > 0) {
const dialogFooter = this.createElement('footer', {
class: 'dialog-footer',
}, dialog, buttonElements);
}
modalBg = this.createElement('div', {
id: 'modal-bg',
class: 'slide',
}, inner, [focusModal.cloneNode(true), div, focusModal.cloneNode(true)]);
return {
dialog: dialog,
dialogHeader: dialogHeader,
dialogContent: dialogContent,
buttons: buttonElements,
close: closeDialog,
};
},
timeOut(callback, conditon, timeOut = 300) {
let timeOutId = setTimeout(function wait() {
let result;
if (!conditon) {
result = document.getElementById('browser');
} else if (typeof conditon === 'string') {
result = document.querySelector(conditon);
} else if (typeof conditon === 'function') {
result = conditon();
} else {
return;
}
if (result) {
callback(result);
} else {
timeOutId = setTimeout(wait, timeOut);
}
}, timeOut);
function stop() {
if (timeOutId) {
clearTimeout(timeOutId);
}
}
return {
stop,
};
},
uuid: {
generate(ids) {
let d = Date.now() + performance.now();
let r;
const id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
if (Array.isArray(ids) && ids.includes(id)) {
return this.uuid.generate(ids);
}
return id;
},
},
};
const nameKey = 'easy-files';
const langs = {
showAllFiles: gnoh.i18n.getMessage('Show all files...'),
downloaded: gnoh.i18n.getMessage('Downloaded'),
chooseAFile: gnoh.i18n.getMessage('Choose a File...'),
};
const chunkSize = 1024 * 1024 * 10; // 10MB
const maxAllowedSize = 1024 * 1024 * 5; // 5MB
gnoh.addStyle([
`.${nameKey}.dialog-custom .dialog-content { flex-flow: wrap; gap: 18px; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper { overflow: hidden; margin: -2px; padding: 2px; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container { overflow: auto; margin: -2px; padding: 2px; flex: 0 1 auto; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image { background-color: var(--colorBgLighter); width: 120px; height: 120px; display: flex; justify-content: center; align-items: center; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image:hover { box-shadow: 0 0 0 2px var(--colorHighlightBg); }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image.preview img { object-fit: cover; width: 120px; height: 120px; flex: 0 0 auto; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image.icon .file-icon { width: 54px; height: 69px; padding: 15px 0 0; position: relative; font-family: sans-serif; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image.icon .file-icon:before { position: absolute; content: ''; left: 0; top: 0; height: 15px; left: 0; background-color: var(--colorFileIconBg, #007bff); right: 15px; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image.icon .file-icon:after { position: absolute; content: ''; width: 0; height: 0; border-style: solid; border-width: 15.5px 0 0 15.5px; border-color: transparent transparent transparent var(--colorFileIconBgLighter, #66b0ff); top: 0; right: 0; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-image.icon .file-icon .file-icon-content { background-color: var(--colorFileIconBg, #007bff); top: 15px; color: var(--colorFileIconFg, #fff); position: absolute; left: 0; bottom: 0; right: 0; padding: 24.75px 0.3em 0; font-size: 19.5px; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-title { width: 120px; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-title .filename-container { display: flex; flex-direction: row; overflow: hidden; width: 120px; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-title .filename-container .filename-text { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }`,
`.${nameKey}.dialog-custom .dialog-content .selectbox-wrapper .selectbox-container .selectbox-title .filename-container .filename-extension { white-space: nowrap; }`,
], nameKey);
function inject(nameKey) {
if (window.easyFiles) {
return;
} else {
window.easyFiles = true;
}
const fileData = [];
let fileInput = null;
function handleClick(event) {
if (event.target.matches('input[type=file]:not([webkitdirectory])')) {
event.preventDefault();
event.stopPropagation();
fileInput = event.target;
const attributes = {};
for (const attr of fileInput.attributes) {
attributes[attr.name] = attr.value;
}
fileData.length = 0;
chrome.runtime.sendMessage({
type: nameKey,
action: 'click',
attributes,
});
}
}
window.addEventListener('click', handleClick);
function changeFile(dataTransfer) {
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
chrome.runtime.onMessage.addListener((info, sender, sendResponse) => {
if (info.type === nameKey) {
switch (info.action) {
case 'file':
fileData[info.file.fileDataIndex] = info.file.fileData;
if (Object.entries(fileData).length === info.file.fileDataLength) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(
[new Uint8Array(fileData.flat())],
info.file.fileName,
{ type: info.file.mimeType },
));
changeFile(dataTransfer);
}
break;
case 'picker':
fileInput.showPicker();
break;
default:
return false;
}
}
});
}
async function simulatePaste() {
return new Promise((resolve, reject) => {
document.addEventListener('paste', (e) => {
e.preventDefault();
const items = [];
let isRealFile = true;
for (const item of e.clipboardData.items) {
const file = item.getAsFile();
const entry = item.webkitGetAsEntry();
if (file) {
if (!entry || entry.isFile) {
items.push({
file,
isFile: true,
isRealFile: !!entry,
});
} else if (entry.isDirectory) {
items.push({
file,
isDirectory: true,
});
}
}
}
resolve({
items,
isRealFile,
});
}, { once: true });
document.execCommand('paste');
});
}
async function readClipboard(accept) {
const clipboardFiles = [];
try {
const supportedTypes = [
{
extension: 'png',
mimeType: 'image/png',
},
{
extension: 'jpeg',
mimeType: 'image/jpeg',
},
{
extension: 'jpg',
mimeType: 'image/jpeg',
},
];
const supportedType = supportedTypes.find(s => gnoh.file.verifyAccept({ fileName: 'image.' + s.extension, mimeType: s.mimeType }, accept));
const pasteData = await simulatePaste();
for (const item of pasteData.items) {
const file = item.file;
let checkType = false;
if (item.isFile) {
if (item.isRealFile) {
checkType = gnoh.file.verifyAccept({ fileName: file.name, mimeType: file.type }, accept);
} else {
checkType = supportedType && file.type === 'image/png';
}
}
if (checkType && file.size <= maxAllowedSize) {
let blob = new Blob([file], { type: file.type });
if (!item.isRealFile && supportedType.mimeType === 'image/jpeg') {
blob = await convertPngToJpeg(blob);
}
const uint8Array = new Uint8Array(await blob.arrayBuffer());
const fileData = gnoh.array.chunks(uint8Array, chunkSize).map(a => Array.from(a));
const clipboardFile = {
fileData: fileData,
fileDataLength: fileData.length,
mimeType: blob.type,
category: 'clipboard',
};
if (item.isRealFile) {
clipboardFile.fileName = file.name;
} else {
clipboardFile.extension = supportedType.extension;
}
switch (clipboardFile.mimeType) {
case 'image/jpeg':
case 'image/png':
case 'image/svg+xml':
case 'image/webp':
case 'image/gif':
case 'image/bmp':
clipboardFile.previewUrl = await vivaldi.utilities.storeImage({
data: uint8Array,
mimeType: blob.type,
});
break;
}
clipboardFiles.push(clipboardFile);
}
}
} catch (error) {
console.error(error);
}
return clipboardFiles;
}
async function convertPngToJpeg(blob) {
const image = gnoh.createElement('img', {
src: URL.createObjectURL(blob),
});
await image.decode();
const canvas = gnoh.createElement('canvas', {
width: image.width,
height: image.height,
});
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
return new Promise((resolve) => {
canvas.toBlob(blob => {
URL.revokeObjectURL(image.src);
if (blob) {
resolve(blob);
}
}, 'image/jpeg');
});
}
async function getDownloadedFiles(accept) {
const downloadedFiles = await chrome.downloads.search({ exists: true, state: 'complete', orderBy: ['-startTime'] });
const result = {};
for (let downloadedFile of downloadedFiles) {
if (
downloadedFile.mime
&& downloadedFile.mime !== 'application/x-msdownload'
&& gnoh.file.verifyAccept({ fileName: downloadedFile.filename, mimeType: downloadedFile.mime }, accept)
) {
downloadedFile = (await chrome.downloads.search({ id: downloadedFile.id }))[0];
if (
downloadedFile
&& downloadedFile.exists === true
&& downloadedFile.state === 'complete'
&& downloadedFile.fileSize <= maxAllowedSize
&& !result[downloadedFile.filename]
) {
const file = {
mimeType: downloadedFile.mime,
path: downloadedFile.filename,
fileName: downloadedFile.filename.replace(/^.*[\\/]/, ''),
category: 'downloaded-file',
};
switch (file.mimeType) {
case 'image/jpeg':
case 'image/png':
case 'image/svg+xml':
case 'image/webp':
case 'image/gif':
case 'image/bmp':
file.previewUrl = await vivaldi.utilities.storeImage({
url: file.path,
});
break;
}
result[downloadedFile.filename] = file;
}
}
}
return Object.values(result);
}
function createFileIcon(extension) {
const colorBg = gnoh.string.toColor(extension);
const isLightBg = gnoh.color.isLight(colorBg.r, colorBg.g, colorBg.b);
const colorBgLighter = gnoh.color.shadeColor(colorBg.r, colorBg.g, colorBg.b, isLightBg ? 80 : -80);
const fileIcon = gnoh.createElement('div', {
class: 'file-icon',
style: {
'--colorFileIconBg': gnoh.color.rgbToHex(colorBg.r, colorBg.g, colorBg.b),
'--colorFileIconBgLighter': gnoh.color.rgbToHex(colorBgLighter.r, colorBgLighter.g, colorBgLighter.b),
'--colorFileIconFg': isLightBg ? '#f6f6f6' : '#111111',
}
});
const fileIconContent = gnoh.createElement('div', {
class: 'file-icon-content',
text: extension,
}, fileIcon);
return fileIcon;
}
async function createSelectbox(sender, file, dialog) {
const selectbox = gnoh.createElement('button', {
title: file.fileName || '',
class: 'selectbox',
events: {
click: async (event) => {
event.preventDefault();
dialog.close();
switch (file.category) {
case 'downloaded-file':
if (!file.fileData) {
const fileData = await vivaldi.utilities.readImage(file.path);
file.fileData = gnoh.array.chunks(fileData.data, chunkSize);
file.fileDataLength = file.fileData.length;
}
break;
case 'clipboard':
if (!file.fileName) {
const d = new Date();
const year = d.getFullYear();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const date = d.getDate().toString().padStart(2, '0');
const hour = d.getHours().toString().padStart(2, '0');
const minute = d.getMinutes().toString().padStart(2, '0');
const second = d.getSeconds().toString().padStart(2, '0');
const millisecond = d.getMilliseconds().toString().padStart(3, '0');
file.fileName = `image_${year}-${month}-${date}_${hour}${minute}${second}${millisecond}.${file.extension}`;
}
break;
}
chooseFile(sender, file);
},
},
});
const selectboxImage = gnoh.createElement('div', {
class: 'selectbox-image',
}, selectbox);
if (file.previewUrl) {
selectboxImage.classList.add('preview');
} else {
selectboxImage.classList.add('icon');
}
if (file.previewUrl) {
const image = gnoh.createElement('img', {
src: file.previewUrl,
}, selectboxImage);
} else {
const extension = file.extension || file.fileName.split('.').pop();
selectboxImage.append(createFileIcon(extension));
}
const selectboxTitle = gnoh.createElement('div', {
class: 'selectbox-title',
}, selectbox);
const filenameText = gnoh.createElement('div', {
class: 'filename-container',
}, selectboxTitle);
if (file.fileName) {
const extension = file.extension || file.fileName.split('.').pop();
const name = file.fileName.substring(0, file.fileName.length - extension.length - 1);
const filenameContainer = gnoh.createElement('div', {
class: 'filename-text',
text: name,
}, filenameText);
const filenameExtension = gnoh.createElement('div', {
class: 'filename-extension',
text: '.' + extension,
}, filenameText);
}
return selectbox;
}
async function showDialogChooseFile(data) {
const buttonShowAllFilesElement = Object.assign({}, gnoh.constant.dialogButtons.submit, {
label: langs.showAllFiles,
click: () => showAllFiles(data.sender),
});
const buttonCancelElement = Object.assign({}, gnoh.constant.dialogButtons.cancel);
const dialog = gnoh.dialog(
langs.chooseAFile,
null,
[buttonShowAllFilesElement, buttonCancelElement],
{
class: nameKey,
}
);
dialog.dialog.style.maxWidth = 570 + 'px';
if (data.clipboardFiles.length) {
const selectboxWrapperClipboard = gnoh.createElement('div', {
class: 'selectbox-wrapper',
});
const h3Clipboard = gnoh.createElement('h3', {
text: 'Clipboard',
}, selectboxWrapperClipboard);
const selectboxContainerClipboard = gnoh.createElement('div', {
class: 'selectbox-container',
}, selectboxWrapperClipboard);
for (const clipboardFile of data.clipboardFiles) {
selectboxContainerClipboard.append(await createSelectbox(data.sender, clipboardFile, dialog));
}
dialog.dialogContent.append(selectboxWrapperClipboard);
}
if (data.downloadedFiles.length) {
const selectboxWrapperDownloaded = gnoh.createElement('div', {
class: 'selectbox-wrapper',
});
const h3Downloaded = gnoh.createElement('h3', {
text: 'Downloaded',
}, selectboxWrapperDownloaded);
const selectboxContainerDownloaded = gnoh.createElement('div', {
class: 'selectbox-container',
}, selectboxWrapperDownloaded);
for (const downloadedFile of data.downloadedFiles) {
selectboxContainerDownloaded.append(await createSelectbox(data.sender, downloadedFile, dialog));
}
dialog.dialogContent.append(selectboxWrapperDownloaded);
}
}
function showAllFiles(sender) {
chrome.tabs.sendMessage(sender.tab.id, {
type: nameKey,
action: 'picker',
tabId: sender.tab.id,
frameId: sender.frameId,
}, {
frameId: sender.frameId,
});
}
async function chooseFile(sender, file) {
if (!file.fileData.length) {
file.fileData.push([]);
}
for (const [index, chunk] of file.fileData.entries()) {
await chrome.tabs.sendMessage(sender.tab.id, {
type: nameKey,
action: 'file',
tabId: sender.tab.id,
frameId: sender.frameId,
file: {
fileData: chunk,
fileDataIndex: index,
fileDataLength: file.fileData.length,
fileName: file.fileName,
mimeType: file.mimeType,
},
}, {
frameId: sender.frameId,
});
}
}
chrome.runtime.onMessage.addListener(async (info, sender, sendResponse) => {
if (sender.tab.windowId === vivaldiWindowId && info.type === nameKey) {
switch (info.action) {
case 'click':
const clipboardFiles = await readClipboard(info.attributes.accept);
const downloadedFiles = await getDownloadedFiles(info.attributes.accept);
if (clipboardFiles.length || downloadedFiles.length) {
showDialogChooseFile({
info,
sender,
clipboardFiles,
downloadedFiles,
})
} else {
showAllFiles(sender);
}
break;
}
}
});
gnoh.timeOut(() => {
chrome.tabs.query({ windowId: window.vivaldiWindowId, windowType: 'normal' }, (tabs) => {
tabs.forEach((tab) => {
chrome.webNavigation.getAllFrames({ tabId: tab.id }, (details) => {
details.forEach((detail) => {
chrome.scripting.executeScript({
target: {
tabId: tab.id,
frameIds: [detail.frameId]
},
func: inject,
args: [nameKey],
});
});
});
});
});
chrome.webNavigation.onCommitted.addListener((details) => {
chrome.scripting.executeScript({
target: {
tabId: details.tabId,
frameIds: [details.frameId]
},
func: inject,
args: [nameKey],
});
});
}, () => {
return window.vivaldiWindowId != null;
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment