diff --git a/src/app.css b/src/app.css index 1a37f161..7848f645 100644 --- a/src/app.css +++ b/src/app.css @@ -1609,6 +1609,47 @@ body:has(.media-modal-container + .status-deck) .media-post-link { bottom: calc(16px + env(safe-area-inset-bottom) + 52px); } } +#compose-button { + &.min { + outline: 2px solid var(--button-text-color); + + &:after { + content: ''; + display: block; + position: absolute; + top: 0; + right: 0; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: var(--button-bg-color); + border: 2px solid var(--button-text-color); + box-shadow: 0 2px 8px var(--drop-shadow-color); + opacity: 0; + transition: opacity 0.2s ease-out 0.5s; + opacity: 1; + } + } + + &.loading { + outline-color: var(--button-bg-blur-color); + + &:before { + position: absolute; + inset: 0; + content: ''; + border-radius: 50%; + animation: spin 5s linear infinite; + border: 2px dashed var(--button-text-color); + } + } + + &.error { + &:after { + background-color: var(--red-color); + } + } +} /* SHEET */ diff --git a/src/components/ICONS.jsx b/src/components/ICONS.jsx index 4ab8b8d3..0fa7880f 100644 --- a/src/components/ICONS.jsx +++ b/src/components/ICONS.jsx @@ -108,4 +108,5 @@ export const ICONS = { settings: () => import('@iconify-icons/mingcute/settings-6-line'), 'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'), 'user-x': () => import('@iconify-icons/mingcute/user-x-line'), + minimize: () => import('@iconify-icons/mingcute/arrows-down-line'), }; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index c585c450..fbb1debb 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -19,6 +19,7 @@ import { getLists } from '../utils/lists'; import niceDateTime from '../utils/nice-date-time'; import pmem from '../utils/pmem'; import shortenNumber from '../utils/shorten-number'; +import showCompose from '../utils/show-compose'; import showToast from '../utils/show-toast'; import states, { hideAllModals } from '../utils/states'; import store from '../utils/store'; @@ -1081,11 +1082,11 @@ function RelatedActions({ <> <MenuItem onClick={() => { - states.showCompose = { + showCompose({ draftStatus: { status: `@${currentInfo?.acct || acct} `, }, - }; + }); }} > <Icon icon="at" /> diff --git a/src/components/compose-button.jsx b/src/components/compose-button.jsx index ef64adf7..66d84ab6 100644 --- a/src/components/compose-button.jsx +++ b/src/components/compose-button.jsx @@ -1,4 +1,5 @@ import { useHotkeys } from 'react-hotkeys-hook'; +import { useSnapshot } from 'valtio'; import openCompose from '../utils/open-compose'; import openOSK from '../utils/open-osk'; @@ -7,7 +8,15 @@ import states from '../utils/states'; import Icon from './icon'; export default function ComposeButton() { + const snapStates = useSnapshot(states); + function handleButton(e) { + if (snapStates.composerState.minimized) { + states.composerState.minimized = false; + openOSK(); + return; + } + if (e.shiftKey) { const newWin = openCompose(); @@ -28,7 +37,14 @@ export default function ComposeButton() { }); return ( - <button type="button" id="compose-button" onClick={handleButton}> + <button + type="button" + id="compose-button" + onClick={handleButton} + class={`${snapStates.composerState.minimized ? 'min' : ''} ${ + snapStates.composerState.publishing ? 'loading' : '' + } ${snapStates.composerState.publishingError ? 'error' : ''}`} + > <Icon icon="quill" size="xl" alt="Compose" /> </button> ); diff --git a/src/components/compose.jsx b/src/components/compose.jsx index 4e0710e5..c402eb66 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -514,6 +514,7 @@ function Compose({ // I don't think this warrant a draft mode for a status that's already posted // Maybe it could be a big edit change but it should be rare if (editStatus) return; + if (states.composerState.minimized) return; const key = draftKey(); const backgroundDraft = { key, @@ -670,6 +671,11 @@ function Compose({ [replyToStatus], ); + const onMinimize = () => { + saveUnsavedDraft(); + states.composerState.minimized = true; + }; + return ( <div id="compose-container-outer"> <div id="compose-container" class={standalone ? 'standalone' : ''}> @@ -689,7 +695,7 @@ function Compose({ /> )} {!standalone ? ( - <span> + <span class="button-group"> <button type="button" class="light pop-button" @@ -736,6 +742,13 @@ function Compose({ > <Icon icon="popout" alt="Pop out" /> </button>{' '} + <button + type="button" + class="light min-button" + onClick={onMinimize} + > + <Icon icon="minimize" alt="Minimize" /> + </button>{' '} <button type="button" class="light close-button" @@ -810,6 +823,10 @@ function Compose({ } else { window.opener.__STATES__.showCompose = true; } + if (window.opener.__STATES__.composerState.minimized) { + // Maximize it + window.opener.__STATES__.composerState.minimized = false; + } }, }); }} @@ -915,6 +932,8 @@ function Compose({ spoilerText = (sensitive && spoilerText) || undefined; status = status === '' ? undefined : status; + // states.composerState.minimized = true; + states.composerState.publishing = true; setUIState('loading'); (async () => { try { @@ -948,6 +967,8 @@ function Compose({ return result.status === 'rejected' || !result.value?.id; }) ) { + states.composerState.publishing = false; + states.composerState.publishingError = true; setUIState('error'); // Alert all the reasons results.forEach((result) => { @@ -1021,6 +1042,8 @@ function Compose({ newStatus = await masto.v1.statuses.create(params); } } + states.composerState.minimized = false; + states.composerState.publishing = false; setUIState('default'); // Close @@ -1031,6 +1054,8 @@ function Compose({ instance, }); } catch (e) { + states.composerState.publishing = false; + states.composerState.publishingError = true; console.error(e); alert(e?.reason || e); setUIState('error'); diff --git a/src/components/modal.css b/src/components/modal.css index 713ed308..62d6c8b6 100644 --- a/src/components/modal.css +++ b/src/components/modal.css @@ -10,17 +10,56 @@ align-items: center; background-color: var(--backdrop-color); animation: appear 0.5s var(--timing-function) both; + transition: all 0.5s var(--timing-function); &.solid { background-color: var(--backdrop-solid-color); } + --compose-button-dimension: 56px; + --compose-button-dimension-half: calc(var(--compose-button-dimension) / 2); + --compose-button-dimension-margin: 16px; + + &.min { + /* Minimized */ + pointer-events: none; + user-select: none; + overflow: hidden; + transform: scale(0); + --right: max( + var(--compose-button-dimension-margin), + env(safe-area-inset-right) + ); + --bottom: max( + var(--compose-button-dimension-margin), + env(safe-area-inset-bottom) + ); + --origin-right: calc( + 100% - var(--compose-button-dimension-half) - var(--right) + ); + --origin-bottom: calc( + 100% - var(--compose-button-dimension-half) - var(--bottom) + ); + transform-origin: var(--origin-right) var(--origin-bottom); + } + .sheet { transition: transform 0.3s var(--timing-function); - transform-origin: center bottom; + transform-origin: 80% 80%; } &:has(~ div) .sheet { transform: scale(0.975); } } + +@media (max-width: calc(40em - 1px)) { + #app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min { + border: 2px solid red; + + --bottom: calc( + var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) + + 52px + ); + } +} diff --git a/src/components/modal.jsx b/src/components/modal.jsx index 0587b3ce..daa5a98a 100644 --- a/src/components/modal.jsx +++ b/src/components/modal.jsx @@ -8,7 +8,7 @@ import useCloseWatcher from '../utils/useCloseWatcher'; const $modalContainer = document.getElementById('modal-container'); -function Modal({ children, onClose, onClick, class: className }) { +function Modal({ children, onClose, onClick, class: className, minimized }) { if (!children) return null; const modalRef = useRef(); @@ -43,21 +43,30 @@ function Modal({ children, onClose, onClick, class: className }) { useEffect(() => { const $deckContainers = document.querySelectorAll('.deck-container'); - if (children) { - $deckContainers.forEach(($deckContainer) => { - $deckContainer.setAttribute('inert', ''); - }); + if (minimized) { + // Similar to focusDeck in focus-deck.jsx + // Focus last deck + const page = $deckContainers[$deckContainers.length - 1]; // last one + if (page && page.tabIndex === -1) { + page.focus(); + } } else { - $deckContainers.forEach(($deckContainer) => { - $deckContainer.removeAttribute('inert'); - }); + if (children) { + $deckContainers.forEach(($deckContainer) => { + $deckContainer.setAttribute('inert', ''); + }); + } else { + $deckContainers.forEach(($deckContainer) => { + $deckContainer.removeAttribute('inert'); + }); + } } return () => { $deckContainers.forEach(($deckContainer) => { $deckContainer.removeAttribute('inert'); }); }; - }, [children]); + }, [children, minimized]); const Modal = ( <div @@ -72,7 +81,8 @@ function Modal({ children, onClose, onClick, class: className }) { onClose?.(e); } }} - tabIndex="-1" + tabIndex={minimized ? 0 : '-1'} + inert={minimized} onFocus={(e) => { try { if (e.target === e.currentTarget) { diff --git a/src/components/modals.jsx b/src/components/modals.jsx index a0ebd4c9..4dcda8ee 100644 --- a/src/components/modals.jsx +++ b/src/components/modals.jsx @@ -39,7 +39,10 @@ export default function Modals() { return ( <> {!!snapStates.showCompose && ( - <Modal class="solid"> + <Modal + class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`} + minimized={!!snapStates.composerState.minimized} + > <IntlSegmenterSuspense> <Compose replyToStatus={ diff --git a/src/components/status.jsx b/src/components/status.jsx index aa8c7fa9..c59bb263 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -51,6 +51,7 @@ import openCompose from '../utils/open-compose'; import pmem from '../utils/pmem'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import shortenNumber from '../utils/shorten-number'; +import showCompose from '../utils/show-compose'; import showToast from '../utils/show-toast'; import { speak, supportsTTS } from '../utils/speech'; import states, { getStatus, saveStatus, statusKey } from '../utils/states'; @@ -524,9 +525,9 @@ function Status({ }); if (newWin) return; } - states.showCompose = { + showCompose({ replyToStatus: status, - }; + }); }; // Check if media has no descriptions @@ -771,11 +772,11 @@ function Status({ menuExtras={ <MenuItem onClick={() => { - states.showCompose = { + showCompose({ draftStatus: { status: `\n${url}`, }, - }; + }); }} > <Icon icon="quote" /> @@ -1092,9 +1093,9 @@ function Status({ {supports('@mastodon/post-edit') && ( <MenuItem onClick={() => { - states.showCompose = { + showCompose({ editStatus: status, - }; + }); }} > <Icon icon="pencil" /> @@ -2125,11 +2126,11 @@ function Status({ menuExtras={ <MenuItem onClick={() => { - states.showCompose = { + showCompose({ draftStatus: { status: `\n${url}`, }, - }; + }); }} > <Icon icon="quote" /> diff --git a/src/index.css b/src/index.css index 10c4380c..a963ee03 100644 --- a/src/index.css +++ b/src/index.css @@ -388,6 +388,27 @@ select.plain { background-color: transparent; } +.button-group { + display: flex; + + button, + .button { + margin-inline: calc(-1 * var(--hairline-width)); + + &:first-child:not(:only-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + &:not(:first-child, :last-child, :only-child) { + border-radius: 0; + } + &:last-child:not(:only-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } +} + pre { tab-size: 2; } @@ -547,3 +568,9 @@ kbd { .shazam-container-horizontal[hidden] { grid-template-columns: 0fr; } + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/pages/status.css b/src/pages/status.css index b971c76e..35b26764 100644 --- a/src/pages/status.css +++ b/src/pages/status.css @@ -23,12 +23,6 @@ } } -@keyframes spin { - to { - transform: rotate(360deg); - } -} - .hero-heading { font-size: var(--text-size); display: inline-block; diff --git a/src/utils/show-compose.js b/src/utils/show-compose.js new file mode 100644 index 00000000..c29669f9 --- /dev/null +++ b/src/utils/show-compose.js @@ -0,0 +1,27 @@ +import openOSK from './open-osk'; +import showToast from './show-toast'; +import states from './states'; + +const TOAST_DURATION = 5_000; // 5 seconds + +export default function showCompose(opts) { + if (!opts) opts = true; + + if (states.showCompose) { + if (states.composerState.minimized) { + showToast({ + duration: TOAST_DURATION, + text: `A draft post is currently minimized. Post or discard it before creating a new one.`, + }); + } else { + showToast({ + duration: TOAST_DURATION, + text: `A post is currently open. Post or discard it before creating a new one.`, + }); + } + return; + } + + openOSK(); + states.showCompose = opts; +} diff --git a/src/utils/states.js b/src/utils/states.js index d346bb77..d7381a0f 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -40,6 +40,7 @@ const states = proxy({ statusReply: {}, accounts: {}, routeNotification: null, + composerState: {}, // Modals showCompose: false, showSettings: false,