diff --git a/src/mami.css/controls/msgbox.css b/src/mami.css/controls/msgbox.css new file mode 100644 index 0000000..93e2a7b --- /dev/null +++ b/src/mami.css/controls/msgbox.css @@ -0,0 +1,76 @@ +.msgbox-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #1118; + color: #fff; + width: 100%; + height: 100%; + backdrop-filter: blur(2px); + display: grid; + justify-content: center; + align-items: center; + grid-template-columns: 340px; +} + +.msgbox-dialog { + grid-row: 1; + grid-column: 1; + font-family: Verdana, Geneva, Arial, Helvetica, sans-serif; + font-size: .8em; + line-height: 1.4em; + display: flex; + flex-direction: column; + background: #444d; + border: 1px solid #5554; + backdrop-filter: blur(2px); + filter: drop-shadow(0 6px 1em #000); +} + +.msgbox-dialog-body { + margin: 6px; +} +.msgbox-dialog-line { + margin: 6px; +} + +.msgbox-dialog-buttons { + display: flex; + gap: 2px; + margin: 2px; + justify-content: space-evenly; +} +.msgbox-dialog-buttons-many { + flex-direction: column; +} + +.msgbox-dialog-button { + font-family: inherit; + font-size: inherit; + line-height: inherit; + width: 100%; + display: block; + border: 0; + background: #555d; + border: 1px solid #6664; + color: inherit; + padding: 7px 4px 6px; + transition: background .1s, border-color .1s; +} +.msgbox-dialog-button:focus { + outline: 1px solid #ccc; +} +.msgbox-dialog-button:focus, +.msgbox-dialog-button:hover { + background: #666d; + border-color: #7774; +} +.msgbox-dialog-button:active { + background: #5a5a5add; + border-color: #6a6a6a44; +} +.msgbox-dialog-button-primary { + font-weight: 700; +} diff --git a/src/mami.css/views.css b/src/mami.css/controls/views.css similarity index 100% rename from src/mami.css/views.css rename to src/mami.css/controls/views.css diff --git a/src/mami.css/main.css b/src/mami.css/main.css index 6207707..72bdd46 100644 --- a/src/mami.css/main.css +++ b/src/mami.css/main.css @@ -1,7 +1,8 @@ @include __animations.css; @include _main.css; -@include views.css; +@include controls/msgbox.css; +@include controls/views.css; @include baka.css; @include ping.css; diff --git a/src/mami.js/context.js b/src/mami.js/context.js index f90b534..5faf505 100644 --- a/src/mami.js/context.js +++ b/src/mami.js/context.js @@ -15,6 +15,7 @@ const MamiContext = function(globalEventTarget, eventTarget) { let isUnloading = false; let settings; + let msgbox; let views; let sound; let textTriggers; @@ -40,6 +41,15 @@ const MamiContext = function(globalEventTarget, eventTarget) { settings = value; }, + get msgbox() { return msgbox; }, + set msgbox(value) { + if(msgbox !== undefined) + throw 'msgbox is already defined'; + if(typeof value !== 'object' || value === null) + throw 'msgbox must be a non-null object'; + msgbox = value; + }, + get views() { return views; }, set views(value) { if(views !== undefined) diff --git a/src/mami.js/controls/msgbox.jsx b/src/mami.js/controls/msgbox.jsx new file mode 100644 index 0000000..5514218 --- /dev/null +++ b/src/mami.js/controls/msgbox.jsx @@ -0,0 +1,262 @@ +#include animate.js +#include args.js +#include utility.js + +const MamiMessageBoxContainer = function() { + const container =
; + + return { + getElement: () => container, + show: async dialog => { + if(typeof dialog !== 'object' || dialog === null) + throw 'dialog must be a non-null object'; + + try { + return await dialog.show(container); + } finally { + dialog.dismiss(); + } + }, + }; +}; + +const MamiMessageBoxDialog = function(info) { + const defaultButtons = [ + { name: 'ok', text: 'OK', primary: true, reject: false }, + { name: 'yes', text: 'Yes', primary: true, reject: false }, + { name: 'no', text: 'No', reject: true }, + { name: 'cancel', text: 'Cancel', reject: true }, + ]; + + let showResolve, showReject; + + const doResolve = (...args) => { + if(typeof showResolve !== 'function') + return; + + showReject = undefined; + showResolve(...args); + }; + const doReject = (...args) => { + if(typeof showReject !== 'function') + return; + + showResolve = undefined; + showReject(...args); + }; + + const dialog = ; + + const body = ; + dialog.appendChild(body); + + if(info.body !== undefined) { + if(Array.isArray(info.body)) + for(const line of info.body) + body.appendChild({line}
); + else + body.appendChild({info.body}
); + } + + const buttons = ; + const buttonActions = {}; + dialog.appendChild(buttons); + + let primaryButton; + const createButton = button => { + if(button.name === undefined) { + console.error('No name specified for dialog button. Skipping...'); + return; + } + + const buttonName = button.name.toString(); + if(buttons.querySelector(`[name="${buttonName}"]`) !== null) { + console.error('A duplicate button name was attempted to be registered. Skipping...'); + return; + } + + const elem = ; + buttons.appendChild(elem); + + if(button.primary) { + primaryButton = elem; + elem.classList.add('msgbox-dialog-button-primary'); + } + + if(typeof button.action === 'function') + buttonActions[buttonName] = button.action; + }; + + for(const item of defaultButtons) + if(item.name in info) { + const button = typeof info[item.name] === 'object' && info[item.name] !== null ? info[item.name] : {}; + + button.name = item.name; + button.reject = item.reject; + if(button.primary === undefined) + button.primary = item.primary; + if(button.text === undefined) + button.text = item.text; + + createButton(button); + } + + if(Array.isArray(info.buttons)) + for(const button of info.buttons) { + if(button.text === undefined) + button.text = button.name; + + createButton(button); + } + + if(buttons.childElementCount < 1) + createButton({ name: 'dismiss', text: 'Dismiss', primary: true }); + + if(buttons.childElementCount > 2) + buttons.classList.add('msgbox-dialog-buttons-many'); + + return { + getElement: () => dialog, + show: container => { + return new Promise((resolve, reject) => { + showResolve = resolve; + showReject = reject; + + dialog.addEventListener('submit', ev => { + ev.preventDefault(); + + if(ev.submitter instanceof HTMLButtonElement) { + const action = ev.submitter.dataset.action === 'resolve' ? doResolve : doReject; + + let result; + if(ev.submitter.name in buttonActions) + result = buttonActions[ev.submitter.name](); + + if(result instanceof Promise) + result.then(result => { action(ev.submitter.name, result, true); }) + .catch(result => { action(ev.submitter.name, result, false); }); + else + action(ev.submitter.name, result); + } else doResolve(); + }); + + dialog.style.transform = 'scale(0)'; + container.appendChild(dialog); + + if(primaryButton instanceof HTMLButtonElement) + primaryButton.focus(); + + MamiAnimate({ + async: true, + duration: 500, + easing: 'outElasticHalf', + update: t => { + dialog.style.transform = `scale(${t})`; + }, + end: () => { + dialog.style.transform = null; + }, + }); + }); + }, + dismiss: async () => { + await MamiAnimate({ + async: true, + duration: 600, + easing: 'outExpo', + update: t => { dialog.style.opacity = 1 - t; }, + end: () => { dialog.style.opacity = '0'; }, + }); + + $r(dialog); + }, + cancel: () => { + doReject(); + }, + }; +}; + +const MamiMessageBoxControl = function(options) { + options = MamiArguments.verify(options, [ + MamiArguments.type('views', 'object', undefined, true), + ]); + + const views = options.views; + const container = new MamiMessageBoxContainer; + const queue = []; + let currentDialog; + + let processingQueue = false; + const processQueue = async () => { + if(processingQueue) + return; + + try { + processingQueue = true; + + let item; + while((item = queue.shift()) !== undefined) { + if(!processingQueue) + break; + + try { + currentDialog = new MamiMessageBoxDialog(item.info); + item.resolve(await container.show(currentDialog)); + } catch(ex) { + item.reject(ex); + } finally { + currentDialog = undefined; + } + } + } finally { + processingQueue = false; + } + }; + + const raise = async () => { + if(!views.isCurrent(container)) + views.raise(container, ctx => MamiAnimate({ + async: true, + duration: 300, + easing: 'outExpo', + update: t => { ctx.toElem.style.opacity = t; }, + end: () => { ctx.toElem.style.opacity = null; }, + })); + + if(!processingQueue) + await processQueue(); + }; + + const dismiss = async () => { + processingQueue = false; + currentDialog?.cancel(); + + if(views.isCurrent(container)) + await views.pop(ctx => MamiAnimate({ + async: true, + duration: 300, + easing: 'outExpo', + update: t => { ctx.fromElem.style.opacity = 1 - t; }, + end: t => { ctx.fromElem.style.opacity = '0'; }, + })); + }; + + return { + raise: raise, + dismiss: dismiss, + show: (info, priority) => { + return new Promise((resolve, reject) => { + const item = { info: info, resolve: resolve, reject: reject }; + + if(priority) queue.unshift(item); + else queue.push(item); + + let queueWasRunning = processingQueue; + raise().finally(() => { + if(!queueWasRunning) + dismiss(); + }); + }); + }, + }; +}; diff --git a/src/mami.js/controls/views.js b/src/mami.js/controls/views.js index be79277..58a7e7b 100644 --- a/src/mami.js/controls/views.js +++ b/src/mami.js/controls/views.js @@ -53,7 +53,6 @@ const MamiViewsControl = function(options) { await elementInfo.onViewPush(); const element = extractElement(elementInfo); - element.classList.toggle('hidden', false); element.classList.toggle('views-item', true); element.classList.toggle('views-background', false); @@ -80,8 +79,6 @@ const MamiViewsControl = function(options) { fromInfo: prevElemInfo, fromElem: prevElem, }); - - prevElem.classList.toggle('hidden', true); } }; @@ -98,7 +95,6 @@ const MamiViewsControl = function(options) { const nextElemInfo = views[views.length - 1]; const nextElem = extractElement(nextElemInfo); - nextElem.classList.toggle('hidden', false); nextElem.classList.toggle('views-background', false); if(typeof nextElemInfo.onViewForeground === 'function') @@ -115,8 +111,6 @@ const MamiViewsControl = function(options) { if(typeof elementInfo.onViewBackground === 'function') await elementInfo.onViewBackground(); - element.classList.toggle('hidden', true); - if(targetBody.contains(element)) targetBody.removeChild(element); @@ -135,9 +129,24 @@ const MamiViewsControl = function(options) { return views[views.length - 1]; }; + const raise = async (elementInfo, transition) => { + if(typeof elementInfo !== 'object') + throw 'elementInfo must be an object'; + + if(current() === elementInfo) + return; + + const index = views.indexOf(elementInfo); + if(index >= 0) + views.splice(index, 1); + + return await push(elementInfo, transition); + }; + return { push: push, pop: pop, + raise: raise, count: () => views.length, current: current, currentElement: () => { @@ -147,6 +156,8 @@ const MamiViewsControl = function(options) { return extractElement(currentInfo); }, + includes: elementInfo => views.includes(elementInfo), + isCurrent: elementInfo => current() === elementInfo, unshift: async elementInfo => { if(views.length < 1) return await push(elementInfo, null); @@ -159,7 +170,6 @@ const MamiViewsControl = function(options) { if(!views.includes(elementInfo)) views.unshift(elementInfo); - element.classList.toggle('hidden', true); element.classList.toggle('views-item', true); element.classList.toggle('views-background', true); @@ -175,8 +185,6 @@ const MamiViewsControl = function(options) { const elementInfo = views.shift(); const element = extractElement(elementInfo); - element.classList.toggle('hidden', false); - if(targetBody.contains(element)) targetBody.removeChild(element); diff --git a/src/mami.js/main.js b/src/mami.js/main.js index a9d9419..f0e0bb4 100644 --- a/src/mami.js/main.js +++ b/src/mami.js/main.js @@ -14,6 +14,7 @@ window.Umi = { UI: {} }; #include weeb.js #include worker.js #include audio/autoplay.js +#include controls/msgbox.jsx #include controls/views.js #include eeprom/eeprom.js #include settings/backup.js @@ -40,11 +41,11 @@ window.Umi = { UI: {} }; const ctx = new MamiContext(eventTarget); Object.defineProperty(window, 'mami', { enumerable: true, value: ctx }); - const views = new MamiViewsControl({ body: document.body }); - ctx.views = views; + ctx.views = new MamiViewsControl({ body: document.body }); + ctx.msgbox = new MamiMessageBoxControl({ views: ctx.views }); const loadingOverlay = new Umi.UI.LoadingOverlay('spinner', 'Loading...'); - await views.push(loadingOverlay); + await ctx.views.push(loadingOverlay); loadingOverlay.setMessage('Loading environment...'); try { @@ -233,7 +234,7 @@ window.Umi = { UI: {} }; // should be dynamic when possible const layout = new Umi.UI.ChatLayout; - await views.unshift(layout); + await ctx.views.unshift(layout); Umi.UI.View.AccentReload(); Umi.UI.Hooks.AddHooks(); @@ -375,7 +376,7 @@ window.Umi = { UI: {} }; Umi.UI.Toggles.Add('clear', { 'click': function() { - if(confirm('ARE YOU SURE ABOUT THAT???')) { + ctx.msgbox.show({ body: 'ARE YOU SURE ABOUT THAT???', yes: true, no: true }).then(() => { const limit = settings.get('explosionRadius'); const explode = $e({ tag: 'img', @@ -408,13 +409,13 @@ window.Umi = { UI: {} }; for(const blMsg of backLog) Umi.Messages.Add(blMsg); - } + }).catch(() => {}); } }, 'Clear Logs'); const pingIndicator = new MamiPingIndicator; const pingToggle = Umi.UI.Toggles.Add('ping', { - click: () => alert(pingToggle.title), + click: () => { ctx.msgbox.show({ body: `Your current ping is ${pingToggle.title}` }); }, }, 'Ready~'); pingToggle.appendChild(pingIndicator.getElement()); @@ -451,7 +452,7 @@ window.Umi = { UI: {} }; .then(() => uploadEntry.remove()) .catch(ex => { console.error(ex); - alert(ex); + ctx.msgbox.show({ body: ['An error occurred while trying to delete an uploaded file:', ex] }); }); }); @@ -474,7 +475,7 @@ window.Umi = { UI: {} }; } catch(ex) { if(!ex.aborted) { console.error(ex); - alert(ex); + ctx.msgbox.show({ body: ['An error occurred while trying to upload a file:', ex] }); } uploadEntry.remove(); @@ -515,13 +516,23 @@ window.Umi = { UI: {} }; ev.preventDefault(); for(const file of ev.dataTransfer.files) { - if(file.name.slice(-5) === '.mami' - && confirm('This file appears to be a settings export. Do you want to import it? This will overwrite your existing settings!')) { - (new MamiSettingsBackup(settings)).importFile(file); - return; - } - - if(doUpload !== undefined) + if(file.name.slice(-5) === '.mami') { + ctx.msgbox.show({ + body: [ + 'This file appears to be a settings export.', + 'Do you want to import it? This will overwrite your existing settings!', + ], + yes: true, + no: true, + }) + .then(() => { + (new MamiSettingsBackup(settings)).importFile(file); + }) + .catch(() => { + if(doUpload !== undefined) + doUpload(file); + }); + } else if(doUpload !== undefined) doUpload(file); } }); @@ -539,7 +550,7 @@ window.Umi = { UI: {} }; loadingOverlay.setMessage('Connecting...'); const setLoadingOverlay = async (icon, header, message, optional) => { - const currentView = views.current(); + const currentView = ctx.views.current(); if('setIcon' in currentView) { currentView.setIcon(icon); @@ -550,7 +561,7 @@ window.Umi = { UI: {} }; if(!optional) { const loading = new Umi.UI.LoadingOverlay(icon, header, message); - await views.push(loading); + await ctx.views.push(loading); } }; diff --git a/src/mami.js/ui/hooks.js b/src/mami.js/ui/hooks.js index 2b7b740..a7f3a34 100644 --- a/src/mami.js/ui/hooks.js +++ b/src/mami.js/ui/hooks.js @@ -61,7 +61,7 @@ Umi.UI.Hooks = (function() { if((ev.ctrlKey && ev.key !== 'v') || ev.altKey) return; - if(!ev.target.matches('input, textarea, select')) + if(!ev.target.matches('input, textarea, select, button')) $i('umi-msg-text').focus(); }); diff --git a/src/mami.js/ui/settings.jsx b/src/mami.js/ui/settings.jsx index ee33f95..ba02886 100644 --- a/src/mami.js/ui/settings.jsx +++ b/src/mami.js/ui/settings.jsx @@ -231,7 +231,7 @@ Umi.UI.Settings = (function() { name: 'onlyConnectWhenVisible', title: 'Only connect when the tab is in the foreground', type: 'checkbox', - confirm: 'Please only disable this setting if you are using a desktop or laptop computer, this should always remain on on a phone, tablet or other device of that sort. Ignoring this warning may carry consequences.', + confirm: ['Please only disable this setting if you are using a desktop or laptop computer, this should always remain on on a phone, tablet or other device of that sort.', 'Are you sure you want to change this setting? Ignoring this warning may carry consequences.'], }, { name: 'playJokeSounds', @@ -346,10 +346,8 @@ Umi.UI.Settings = (function() { { title: 'Import settings', type: 'button', + confirm: ['Your current settings will be replaced with the ones in the export.', 'Are you sure you want to continue?'], invoke: () => { - if(!confirm('Your current settings will be replaced with the ones in the export. Are you sure you want to continue?')) - return; - (new MamiSettingsBackup(mami.settings)).importUpload(document.body); }, }, @@ -368,10 +366,8 @@ Umi.UI.Settings = (function() { { title: 'Reset settings', type: 'button', + confirm: ['This will reset all your settings to their defaults values.', 'Are you sure you want to do this?'], invoke: () => { - if(!confirm('This will reset all your settings to their defaults values. Are you sure you want to do this?')) - return; - mami.settings.clear(); }, }, @@ -463,7 +459,16 @@ Umi.UI.Settings = (function() { ); } else if(display.type === 'button') { input.value = display.title; - input.addEventListener('click', () => display.invoke(input)); + input.addEventListener('click', () => { + if(display.confirm !== undefined) + mami.msgbox.show({ + body: display.confirm, + yes: { primary: false }, + no: { primary: true }, + }).then(() => { display.invoke(input); }).catch(() => {}); + else + display.invoke(input); + }); } if(setting !== undefined) { @@ -473,12 +478,19 @@ Umi.UI.Settings = (function() { if(display.type === 'checkbox') { mami.settings.watch(setting.name, ev => input.checked = ev.detail.value); input.addEventListener('change', () => { - if(display.confirm !== undefined && input.checked !== setting.fallback && !confirm(display.confirm)) { - input.checked = setting.fallback; - return; + if(display.confirm !== undefined && input.checked !== setting.fallback) { + mami.msgbox.show({ + body: display.confirm, + yes: { primary: false }, + no: { primary: true }, + }).then(() => { + mami.settings.toggle(setting.name); + }).catch(() => { + input.checked = setting.fallback; + }); + } else { + mami.settings.toggle(setting.name); } - - mami.settings.toggle(setting.name); }); } else { mami.settings.watch(setting.name, ev => input.value = ev.detail.value); diff --git a/src/mami.js/ui/users.js b/src/mami.js/ui/users.js index b39df3c..22c56ef 100644 --- a/src/mami.js/ui/users.js +++ b/src/mami.js/ui/users.js @@ -112,11 +112,15 @@ Umi.UI.Users = (function() { })); uOptions.appendChild(createAction('Kick Fucking Everyone', { 'click': function() { - if(confirm('You are about to detonate the fucking bomb. Are you sure?')) { - const targets = Umi.Users.All(); + mami.msgbox.show({ + body: ['You are about to detonate the fucking bomb.', 'Are you sure?'], + yes: { text: 'FUCK IT, WE BALL' }, + no: { text: 'nah' }, + }).then(() => { + const targets = Umi.Users.All().reverse(); for(const target of targets) // this shouldn't call it like this but will have to leave it for now Umi.Server.sendMessage('/kick ' + target.name); - } + }).catch(() => {}); } })); }