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&hellip;
-                      </button>
+                        <button
+                          type="button"
+                          class="small light"
+                          disabled={uiState === 'loading'}
+                        >
+                          Delete&hellip;
+                        </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&hellip;
-              </button>
-            </p>
+                      // }
+                    })();
+                  }}
+                >
+                  <button
+                    type="button"
+                    class="light danger"
+                    disabled={uiState === 'loading'}
+                  >
+                    Delete all&hellip;
+                  </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();
+};