From ff41cd3563dd2425981ff4154caa03d6bb23a662 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun <cheeaun@gmail.com> Date: Mon, 17 Jul 2023 21:01:00 +0800 Subject: [PATCH] Replace (most) alert/confirms with alternative UI Everything might break lol --- src/app.css | 75 ++++++++++++++-- src/components/account-info.jsx | 99 +++++++++++++-------- src/components/drafts.jsx | 68 ++++++++------ src/components/icon.jsx | 1 + src/components/list-add-edit.jsx | 22 +++-- src/components/menu-confirm.jsx | 43 +++++++++ src/components/status.jsx | 146 +++++++++++++++++++++++++------ src/pages/accounts.jsx | 17 +++- src/pages/hashtag.jsx | 18 ++-- src/pages/list.jsx | 24 +++-- src/utils/toast-alert.js | 34 +++++++ 11 files changed, 423 insertions(+), 124 deletions(-) create mode 100644 src/components/menu-confirm.jsx create mode 100644 src/utils/toast-alert.js diff --git a/src/app.css b/src/app.css index 9f70cbe3..1e744d15 100644 --- a/src/app.css +++ b/src/app.css @@ -1401,7 +1401,7 @@ body > .szh-menu-container { animation: appear-smooth 0.15s ease-in-out; width: 16em; max-width: 90vw; - overflow: hidden; + /* overflow: hidden; */ } .szh-menu[aria-label='Submenu'] { background-color: var(--bg-blur-color); @@ -1418,6 +1418,7 @@ body > .szh-menu-container { text-shadow: 0 1px 0 var(--bg-color); line-height: 1.2; /* border-bottom: 1px solid var(--outline-color); */ + border-radius: 8px 8px 0 0; } .szh-menu__header.plain { margin-bottom: 0; @@ -1426,6 +1427,28 @@ body > .szh-menu-container { .szh-menu__header * { vertical-align: middle; } +.szh-menu.menu-emphasized { + border-color: var(--outline-hover-color); + box-shadow: 0 3px 16px -3px var(--drop-shadow-color), + 0 3px 32px var(--drop-shadow-color), 0 3px 48px var(--drop-shadow-color); + background-color: var(--bg-color); + animation-duration: 0.3s; + animation-timing-function: ease-in-out; + width: auto; +} +.szh-menu .footer { + margin: 8px 0 -8px; + padding: 8px 16px; + color: var(--text-insignificant-color); + font-size: 90%; + background-color: var(--bg-faded-color); + text-shadow: 0 1px 0 var(--bg-color); + line-height: 1.2; + display: flex; + gap: 8px; + align-items: center; + border-radius: 0 0 8px 8px; +} .szh-menu .szh-menu__item { display: flex; gap: 8px; @@ -1498,21 +1521,26 @@ body > .szh-menu-container { font-size: inherit; } .szh-menu .menu-horizontal { - display: flex; + display: grid; + /* two columns only */ + grid-template-columns: repeat(2, 1fr); } -.szh-menu .menu-horizontal .szh-menu__item { - flex: 1; -} -.szh-menu .menu-horizontal .szh-menu__item:not(:only-child):first-child { +.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):first-child, +.szh-menu .menu-horizontal > *:not(:only-child):first-child .szh-menu__item { padding-right: 4px !important; } .szh-menu .menu-horizontal - .szh-menu__item:not(:only-child):not(:first-child):not(:last-child) { + > .szh-menu__item:not(:only-child):not(:first-child):not(:last-child), +.szh-menu + .menu-horizontal + > *:not(:only-child):not(:first-child):not(:last-child) + .szh-menu__item { padding-left: 8px !important; padding-right: 4px !important; } -.szh-menu .menu-horizontal .szh-menu__item:not(:only-child):last-child { +.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):last-child, +.szh-menu .menu-horizontal > *:not(:only-child):last-child .szh-menu__item { padding-left: 8px !important; } .szh-menu .szh-menu__item .menu-shortcut { @@ -1533,6 +1561,19 @@ body > .szh-menu-container { color: var(--red-color); opacity: 1; } +.szh-menu + .szh-menu__item:not(.szh-menu__item--disabled):not( + .szh-menu__item--hover + ).danger { + color: var(--red-color); +} +.szh-menu + .szh-menu__item:not(.szh-menu__item--disabled):not( + .szh-menu__item--hover + ).danger + .icon { + opacity: 1; +} .szh-menu .menu-wrap { display: flex; @@ -1658,6 +1699,24 @@ meter.donut[hidden] { margin-bottom: env(safe-area-inset-bottom); } +/* TOAST - ALERT */ + +:root .toastify.alert { + z-index: 1001; + box-shadow: 0 8px 32px var(--text-insignificant-color); + background-color: var(--bg-color); + color: var(--text-color); + cursor: pointer; + pointer-events: auto; + padding: 16px 32px; + font-size: max(calc(16px * 1.1), var(--text-size)); + text-align: center; + line-height: 1.25; +} +:root .toastify.alert:is(:hover, :active) { + background-color: var(--bg-faded-color); +} + /* AVATARS STACK */ .avatars-stack { diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index d21a567d..9df1f253 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -21,6 +21,7 @@ import Icon from './icon'; import Link from './link'; import ListAddEdit from './list-add-edit'; import Loader from './loader'; +import MenuConfirm from './menu-confirm'; import Modal from './modal'; import TranslationBlock from './translation-block'; @@ -734,11 +735,20 @@ function RelatedActions({ info, instance, authenticated }) { </div> </SubMenu> )} - <MenuItem + <MenuConfirm + subMenu + confirm={!blocking} + confirmLabel={ + <> + <Icon icon="block" /> + <span>Block @{username}?</span> + </> + } + menuItemClassName="danger" onClick={() => { - if (!blocking && !confirm(`Block @${username}?`)) { - return; - } + // if (!blocking && !confirm(`Block @${username}?`)) { + // return; + // } setRelationshipUIState('loading'); (async () => { try { @@ -784,7 +794,7 @@ function RelatedActions({ info, instance, authenticated }) { <span>Block @{username}…</span> </> )} - </MenuItem> + </MenuConfirm> {/* <MenuItem> <Icon icon="flag" /> <span>Report @{username}…</span> @@ -796,10 +806,17 @@ function RelatedActions({ info, instance, authenticated }) { <Loader abrupt /> )} {!!relationship && ( - <button - type="button" - class={`${following || requested ? 'light swap' : ''}`} - data-swap-state={following || requested ? 'danger' : ''} + <MenuConfirm + confirm={following || requested} + confirmLabel={ + <span> + {requested + ? 'Withdraw follow request?' + : `Unfollow @${info.acct || info.username}?`} + </span> + } + menuItemClassName="danger" + align="end" disabled={loading} onClick={() => { setRelationshipUIState('loading'); @@ -808,18 +825,17 @@ function RelatedActions({ info, instance, authenticated }) { let newRelationship; if (following || requested) { - const yes = confirm( - requested - ? 'Withdraw follow request?' - : `Unfollow @${info.acct || info.username}?`, - ); + // const yes = confirm( + // requested + // ? 'Withdraw follow request?' + // : `Unfollow @${info.acct || info.username}?`, + // ); - if (yes) { - newRelationship = - await currentMasto.v1.accounts.unfollow( - accountID.current, - ); - } + // if (yes) { + newRelationship = await currentMasto.v1.accounts.unfollow( + accountID.current, + ); + // } } else { newRelationship = await currentMasto.v1.accounts.follow( accountID.current, @@ -835,24 +851,31 @@ function RelatedActions({ info, instance, authenticated }) { })(); }} > - {following ? ( - <> - <span>Following</span> - <span>Unfollow…</span> - </> - ) : requested ? ( - <> - <span>Requested</span> - <span>Withdraw…</span> - </> - ) : locked ? ( - <> - <Icon icon="lock" /> <span>Follow</span> - </> - ) : ( - 'Follow' - )} - </button> + <button + type="button" + class={`${following || requested ? 'light swap' : ''}`} + data-swap-state={following || requested ? 'danger' : ''} + disabled={loading} + > + {following ? ( + <> + <span>Following</span> + <span>Unfollow…</span> + </> + ) : requested ? ( + <> + <span>Requested</span> + <span>Withdraw…</span> + </> + ) : locked ? ( + <> + <Icon icon="lock" /> <span>Follow</span> + </> + ) : ( + 'Follow' + )} + </button> + </MenuConfirm> )} </span> </p> diff --git a/src/components/drafts.jsx b/src/components/drafts.jsx index f47217d1..be783f15 100644 --- a/src/components/drafts.jsx +++ b/src/components/drafts.jsx @@ -10,6 +10,7 @@ import { getCurrentAccountNS } from '../utils/store-utils'; import Icon from './icon'; import Loader from './loader'; +import MenuConfirm from './menu-confirm'; function Drafts({ onClose }) { const { masto } = api(); @@ -89,26 +90,33 @@ function Drafts({ onClose }) { {niceDateTime(updatedAtDate)} </time> </b> - <button - type="button" - class="small light" + <MenuConfirm + confirmLabel={<span>Delete this draft?</span>} + menuItemClassName="danger" + align="end" disabled={uiState === 'loading'} onClick={() => { (async () => { try { - const yes = confirm('Delete this draft?'); - if (yes) { - await db.drafts.del(key); - reload(); - } + // const yes = confirm('Delete this draft?'); + // if (yes) { + await db.drafts.del(key); + reload(); + // } } catch (e) { alert('Error deleting draft! Please try again.'); } })(); }} > - Delete… - </button> + <button + type="button" + class="small light" + disabled={uiState === 'loading'} + > + Delete… + </button> + </MenuConfirm> </div> <button type="button" @@ -145,15 +153,16 @@ function Drafts({ onClose }) { ); })} </ul> - <p> - <button - type="button" - class="light danger" - disabled={uiState === 'loading'} - onClick={() => { - (async () => { - const yes = confirm('Delete all drafts?'); - if (yes) { + {drafts.length > 1 && ( + <p> + <MenuConfirm + confirmLabel={<span>Delete all drafts?</span>} + menuItemClassName="danger" + disabled={uiState === 'loading'} + onClick={() => { + (async () => { + // const yes = confirm('Delete all drafts?'); + // if (yes) { setUIState('loading'); try { await db.drafts.delMany( @@ -166,13 +175,20 @@ function Drafts({ onClose }) { alert('Error deleting drafts! Please try again.'); setUIState('error'); } - } - })(); - }} - > - Delete all drafts… - </button> - </p> + // } + })(); + }} + > + <button + type="button" + class="light danger" + disabled={uiState === 'loading'} + > + Delete all… + </button> + </MenuConfirm> + </p> + )} </> ) : ( <p>No drafts found.</p> diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 5c42a022..a1305620 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -87,6 +87,7 @@ const ICONS = { layout4: () => import('@iconify-icons/mingcute/layout-4-line'), layout5: () => import('@iconify-icons/mingcute/layout-5-line'), announce: () => import('@iconify-icons/mingcute/announcement-line'), + alert: () => import('@iconify-icons/mingcute/alert-line'), }; function Icon({ diff --git a/src/components/list-add-edit.jsx b/src/components/list-add-edit.jsx index 606062b0..4b19be0e 100644 --- a/src/components/list-add-edit.jsx +++ b/src/components/list-add-edit.jsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import { api } from '../utils/api'; import Icon from './icon'; +import MenuConfirm from './menu-confirm'; function ListAddEdit({ list, onClose }) { const { masto } = api(); @@ -103,13 +104,14 @@ function ListAddEdit({ list, onClose }) { {editMode ? 'Save' : 'Create'} </button> {editMode && ( - <button - type="button" - class="light danger" + <MenuConfirm disabled={uiState === 'loading'} + align="end" + menuItemClassName="danger" + confirmLabel="Delete this list?" onClick={() => { - const yes = confirm('Delete this list?'); - if (!yes) return; + // const yes = confirm('Delete this list?'); + // if (!yes) return; setUiState('loading'); (async () => { @@ -127,8 +129,14 @@ function ListAddEdit({ list, onClose }) { })(); }} > - Delete… - </button> + <button + type="button" + class="light danger" + disabled={uiState === 'loading'} + > + Delete… + </button> + </MenuConfirm> )} </div> </form> diff --git a/src/components/menu-confirm.jsx b/src/components/menu-confirm.jsx new file mode 100644 index 00000000..14e67746 --- /dev/null +++ b/src/components/menu-confirm.jsx @@ -0,0 +1,43 @@ +import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu'; +import { cloneElement } from 'preact'; + +function MenuConfirm({ + subMenu = false, + confirm = true, + confirmLabel, + menuItemClassName, + menuFooter, + ...props +}) { + const { children, onClick, ...restProps } = props; + if (!confirm) { + if (subMenu) return <MenuItem {...props} />; + if (onClick) { + return cloneElement(children, { + onClick, + }); + } + return children; + } + const Parent = subMenu ? SubMenu : Menu; + return ( + <Parent + openTrigger="clickOnly" + direction="bottom" + overflow="auto" + gap={-8} + shift={8} + menuClassName="menu-emphasized" + {...restProps} + menuButton={subMenu ? undefined : children} + label={subMenu ? children : undefined} + > + <MenuItem className={menuItemClassName} onClick={onClick}> + {confirmLabel} + </MenuItem> + {menuFooter} + </Parent> + ); +} + +export default MenuConfirm; diff --git a/src/components/status.jsx b/src/components/status.jsx index f77bf690..e926fb38 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -28,6 +28,7 @@ import { snapshot } from 'valtio/vanilla'; import AccountBlock from '../components/account-block'; import EmojiText from '../components/emoji-text'; import Loader from '../components/loader'; +import MenuConfirm from '../components/menu-confirm'; import Modal from '../components/modal'; import NameText from '../components/name-text'; import Poll from '../components/poll'; @@ -325,6 +326,12 @@ function Status({ }; }; + // Check if media has no descriptions + const mediaNoDesc = useMemo(() => { + return mediaAttachments.some( + (attachment) => !attachment.description?.trim?.(), + ); + }, [mediaAttachments]); const boostStatus = async () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); @@ -332,12 +339,8 @@ function Status({ } try { if (!reblogged) { - // Check if media has no descriptions - const hasNoDescriptions = mediaAttachments.some( - (attachment) => !attachment.description?.trim?.(), - ); let confirmText = 'Boost this post?'; - if (hasNoDescriptions) { + if (mediaNoDesc) { confirmText += '\n\n⚠️ Some media have no descriptions.'; } const yes = confirm(confirmText); @@ -367,6 +370,34 @@ function Status({ return false; } }; + const confirmBoostStatus = async () => { + if (!sameInstance || !authenticated) { + alert(unauthInteractionErrorMessage); + return false; + } + try { + // Optimistic + states.statuses[sKey] = { + ...status, + reblogged: !reblogged, + reblogsCount: reblogsCount + (reblogged ? -1 : 1), + }; + if (reblogged) { + const newStatus = await masto.v1.statuses.unreblog(id); + saveStatus(newStatus, instance); + return true; + } else { + const newStatus = await masto.v1.statuses.reblog(id); + saveStatus(newStatus, instance); + return true; + } + } catch (e) { + console.error(e); + // Revert optimistism + states.statuses[sKey] = status; + return false; + } + }; const favouriteStatus = async () => { if (!sameInstance || !authenticated) { @@ -490,11 +521,27 @@ function Status({ {!isSizeLarge && sameInstance && ( <> <div class="menu-horizontal"> - <MenuItem + <MenuConfirm + subMenu + confirmLabel={ + <> + <Icon icon="rocket" /> + <span>Unboost?</span> + </> + } + menuFooter={ + mediaNoDesc && + !reblogged && ( + <div class="footer"> + <Icon icon="alert" /> + Some media have no descriptions. + </div> + ) + } disabled={!canBoost} onClick={async () => { try { - const done = await boostStatus(); + const done = await confirmBoostStatus(); if (!isSizeLarge && done) { showToast(reblogged ? 'Unboosted' : 'Boosted'); } @@ -508,7 +555,7 @@ function Status({ }} /> <span>{reblogged ? 'Unboost' : 'Boost…'}</span> - </MenuItem> + </MenuConfirm> <MenuItem onClick={() => { try { @@ -660,27 +707,35 @@ function Status({ <span>Edit</span> </MenuItem> {isSizeLarge && ( - <MenuItem + <MenuConfirm + subMenu + confirmLabel={ + <> + <Icon icon="trash" /> + <span>Delete this post?</span> + </> + } + menuItemClassName="danger" onClick={() => { - const yes = confirm('Delete this post?'); - if (yes) { - (async () => { - try { - await masto.v1.statuses.remove(id); - const cachedStatus = getStatus(id, instance); - cachedStatus._deleted = true; - showToast('Deleted'); - } catch (e) { - console.error(e); - showToast('Unable to delete'); - } - })(); - } + // const yes = confirm('Delete this post?'); + // if (yes) { + (async () => { + try { + await masto.v1.statuses.remove(id); + const cachedStatus = getStatus(id, instance); + cachedStatus._deleted = true; + showToast('Deleted'); + } catch (e) { + console.error(e); + showToast('Unable to delete'); + } + })(); + // } }} > <Icon icon="trash" /> <span>Delete…</span> - </MenuItem> + </MenuConfirm> )} </div> )} @@ -1157,7 +1212,7 @@ function Status({ onClick={replyStatus} /> </div> - <div class="action has-count"> + {/* <div class="action has-count"> <StatusButton checked={reblogged} title={['Boost', 'Unboost']} @@ -1168,7 +1223,45 @@ function Status({ onClick={boostStatus} disabled={!canBoost} /> - </div> + </div> */} + <Menu + portal={{ + target: + document.querySelector('.status-deck') || document.body, + }} + align="start" + gap={4} + overflow="auto" + viewScroll="close" + boundingBoxPadding="8 8 8 8" + shift={-8} + menuClassName="menu-emphasized" + menuButton={({ open }) => ( + <div class="action has-count"> + <StatusButton + checked={reblogged} + title={['Boost', 'Unboost']} + alt={['Boost', 'Boosted']} + class="reblog-button" + icon="rocket" + count={reblogsCount} + // onClick={boostStatus} + disabled={open || !canBoost} + /> + </div> + )} + > + <MenuItem onClick={confirmBoostStatus}> + <Icon icon="rocket" /> + <span>Boost to everyone?</span> + </MenuItem> + {mediaNoDesc && ( + <div class="footer"> + <Icon icon="alert" /> + Some media have no descriptions. + </div> + )} + </Menu> <div class="action has-count"> <StatusButton checked={favourited} @@ -1682,6 +1775,7 @@ function StatusButton({ title={buttonTitle} class={`plain ${className} ${checked ? 'checked' : ''}`} onClick={(e) => { + if (!onClick) return; e.preventDefault(); e.stopPropagation(); onClick(e); diff --git a/src/pages/accounts.jsx b/src/pages/accounts.jsx index b5762077..c7910b5a 100644 --- a/src/pages/accounts.jsx +++ b/src/pages/accounts.jsx @@ -6,6 +6,7 @@ import { useReducer, useState } from 'preact/hooks'; import Avatar from '../components/avatar'; import Icon from '../components/icon'; import Link from '../components/link'; +import MenuConfirm from '../components/menu-confirm'; import NameText from '../components/name-text'; import { api } from '../utils/api'; import states from '../utils/states'; @@ -126,11 +127,19 @@ function Accounts({ onClose }) { <span>Set as default</span> </MenuItem> )} - <MenuItem + <MenuConfirm + subMenu + confirmLabel={ + <> + <Icon icon="exit" /> + <span>Log out @{account.info.acct}?</span> + </> + } disabled={!isCurrent} + menuItemClassName="danger" onClick={() => { - const yes = confirm('Log out?'); - if (!yes) return; + // const yes = confirm('Log out?'); + // if (!yes) return; accounts.splice(i, 1); store.local.setJSON('accounts', accounts); // location.reload(); @@ -139,7 +148,7 @@ function Accounts({ onClose }) { > <Icon icon="exit" /> <span>Log out…</span> - </MenuItem> + </MenuConfirm> </Menu> </div> </li> diff --git a/src/pages/hashtag.jsx b/src/pages/hashtag.jsx index b81a6744..d9a91564 100644 --- a/src/pages/hashtag.jsx +++ b/src/pages/hashtag.jsx @@ -10,6 +10,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import Icon from '../components/icon'; import Menu2 from '../components/menu2'; +import MenuConfirm from '../components/menu-confirm'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import showToast from '../utils/show-toast'; @@ -149,16 +150,19 @@ function Hashtags({ columnMode, ...props }) { > {!!info && hashtags.length === 1 && ( <> - <MenuItem + <MenuConfirm + subMenu + confirm={info.following} + confirmLabel={`Unfollow #${hashtag}?`} disabled={followUIState === 'loading' || !authenticated} onClick={() => { setFollowUIState('loading'); if (info.following) { - const yes = confirm(`Unfollow #${hashtag}?`); - if (!yes) { - setFollowUIState('default'); - return; - } + // const yes = confirm(`Unfollow #${hashtag}?`); + // if (!yes) { + // setFollowUIState('default'); + // return; + // } masto.v1.tags .unfollow(hashtag) .then(() => { @@ -198,7 +202,7 @@ function Hashtags({ columnMode, ...props }) { <Icon icon="plus" /> <span>Follow</span> </> )} - </MenuItem> + </MenuConfirm> <MenuDivider /> </> )} diff --git a/src/pages/list.jsx b/src/pages/list.jsx index 3c360288..dcfa71e4 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -11,6 +11,7 @@ import Icon from '../components/icon'; import Link from '../components/link'; import ListAddEdit from '../components/list-add-edit'; import Menu2 from '../components/menu2'; +import MenuConfirm from '../components/menu-confirm'; import Modal from '../components/modal'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; @@ -263,10 +264,11 @@ function RemoveAddButton({ account, listID }) { const [removed, setRemoved] = useState(false); return ( - <button - type="button" - class={`light ${removed ? '' : 'danger'}`} - disabled={uiState === 'loading'} + <MenuConfirm + confirm={!removed} + confirmLabel={<span>Remove @{account.username} from list?</span>} + align="end" + menuItemClassName="danger" onClick={() => { if (removed) { setUIState('loading'); @@ -282,8 +284,8 @@ function RemoveAddButton({ account, listID }) { } })(); } else { - const yes = confirm(`Remove ${account.username} from this list?`); - if (!yes) return; + // const yes = confirm(`Remove ${account.username} from this list?`); + // if (!yes) return; setUIState('loading'); (async () => { @@ -300,8 +302,14 @@ function RemoveAddButton({ account, listID }) { } }} > - {removed ? 'Add' : 'Remove…'} - </button> + <button + type="button" + class={`light ${removed ? '' : 'danger'}`} + disabled={uiState === 'loading'} + > + {removed ? 'Add' : 'Remove…'} + </button> + </MenuConfirm> ); } diff --git a/src/utils/toast-alert.js b/src/utils/toast-alert.js new file mode 100644 index 00000000..d4014775 --- /dev/null +++ b/src/utils/toast-alert.js @@ -0,0 +1,34 @@ +// Replace alert() with toastify-js +import Toastify from 'toastify-js'; + +const nativeAlert = window.alert; +if (!window.__nativeAlert) window.__nativeAlert = nativeAlert; + +window.alert = function (message) { + console.debug( + 'ALERT: This is a custom alert() function. Native alert() is still available as window.__nativeAlert()', + ); + // If Error object, show the message + if (message instanceof Error && message?.message) { + message = message.message; + } + // If not string, stringify it + if (typeof message !== 'string') { + message = JSON.stringify(message); + } + + const toast = Toastify({ + text: message, + className: 'alert', + gravity: 'top', + position: 'center', + duration: 10_000, + offset: { + y: 48, + }, + onClick: () => { + toast.hideToast(); + }, + }); + toast.showToast(); +};