New feature: pop-out compose window

- More consistent design for both reply-to status and source status preview
- Fixed bugs too
- Make sure index.css is always above
This commit is contained in:
Lim Chee Aun 2022-12-13 20:42:09 +08:00
parent 3e80ee03f3
commit 9d78e67381
10 changed files with 329 additions and 50 deletions

View file

@ -3,7 +3,13 @@
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"importOrder": [".css$", "<THIRD_PARTY_MODULES>", "^../", "^[./]"], "importOrder": [
"index.css$",
".css$",
"<THIRD_PARTY_MODULES>",
"^../",
"^[./]"
],
"importOrderSeparation": true, "importOrderSeparation": true,
"importOrderSortSpecifiers": true, "importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true, "importOrderGroupNamespaceSpecifiers": true,

14
compose/index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compose / Phanpy</title>
<meta name="color-scheme" content="dark light" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/compose.jsx"></script>
</body>
</html>

View file

@ -23,7 +23,7 @@ import store from './utils/store';
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env; const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
window._STATES = states; window.__STATES__ = states;
async function startStream() { async function startStream() {
const stream = await masto.stream.streamUser(); const stream = await masto.stream.streamUser();
@ -267,6 +267,7 @@ export function App() {
: null : null
} }
editStatus={snapStates.showCompose?.editStatus || null} editStatus={snapStates.showCompose?.editStatus || null}
draftStatus={snapStates.showCompose?.draftStatus || null}
onClose={(result) => { onClose={(result) => {
states.showCompose = false; states.showCompose = false;
if (result) { if (result) {

View file

@ -19,11 +19,6 @@
z-index: 100; z-index: 100;
} }
#compose-container .close-button {
padding: 6px;
color: var(--text-insignificant-color);
}
#compose-container textarea { #compose-container textarea {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@ -42,18 +37,21 @@
transform: translateY(0); transform: translateY(0);
} }
} }
#compose-container .reply-to { #compose-container .status-preview {
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
max-height: 160px; max-height: 160px;
pointer-events: none; background-color: var(--bg-color);
filter: saturate(0.25) opacity(0.75);
background-color: var(--bg-blur-color);
margin: 0 12px; margin: 0 12px;
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
border-bottom: 0; border-bottom: 0;
/* box-shadow: 0 0 12px var(--divider-color); */
/* mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 90%, transparent); */
animation: appear-up 1s ease-in-out; animation: appear-up 1s ease-in-out;
overflow: auto;
}
#compose-container.standalone .status-preview * {
/*
For standalone mode (new window), prevent interacting with the status preview for now
*/
pointer-events: none;
} }
@keyframes appear-down { @keyframes appear-down {
0% { 0% {
@ -63,6 +61,38 @@
transform: translateY(0); transform: translateY(0);
} }
} }
#compose-container .status-preview-legend {
pointer-events: none;
position: sticky;
bottom: 0;
padding: 8px;
font-size: 80%;
font-weight: bold;
text-align: center;
color: var(--text-insignificant-color);
background-color: var(--bg-blur-color);
/* background-image: linear-gradient(
to bottom,
transparent,
var(--bg-faded-color)
); */
border-top: 1px solid var(--outline-color);
backdrop-filter: blur(8px);
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color);
}
#_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color);
background-color: var(--reply-to-faded-color);
/* background-image: linear-gradient(
to bottom,
transparent,
var(--reply-to-faded-color)
); */
}
#compose-container form { #compose-container form {
border-radius: 8px; border-radius: 8px;
padding: 4px 12px; padding: 4px 12px;
@ -70,7 +100,7 @@
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
#compose-container .reply-to ~ form { #compose-container .status-preview ~ form {
animation: appear-down 1s ease-in-out; animation: appear-down 1s ease-in-out;
box-shadow: 0 -12px 12px -12px var(--divider-color); box-shadow: 0 -12px 12px -12px var(--divider-color);
} }

View file

@ -17,7 +17,13 @@ import Status from './status';
- Max character limit includes BOTH status text and Content Warning text - Max character limit includes BOTH status text and Content Warning text
*/ */
export default ({ onClose, replyToStatus, editStatus }) => { export default ({
onClose,
replyToStatus,
editStatus,
draftStatus,
standalone,
}) => {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const accounts = store.local.getJSON('accounts'); const accounts = store.local.getJSON('accounts');
@ -51,27 +57,34 @@ export default ({ onClose, replyToStatus, editStatus }) => {
const textareaRef = useRef(); const textareaRef = useRef();
const [visibility, setVisibility] = useState( const [visibility, setVisibility] = useState('public');
replyToStatus?.visibility || 'public', const [sensitive, setSensitive] = useState(false);
);
const [sensitive, setSensitive] = useState(replyToStatus?.sensitive || false);
const spoilerTextRef = useRef(); const spoilerTextRef = useRef();
useEffect(() => { useEffect(() => {
let timer = setTimeout(() => { if (replyToStatus) {
const spoilerText = replyToStatus?.spoilerText; const { spoilerText, visibility, sensitive } = replyToStatus;
if (spoilerText && spoilerTextRef.current) { if (spoilerText && spoilerTextRef.current) {
spoilerTextRef.current.value = spoilerText; spoilerTextRef.current.value = spoilerText;
spoilerTextRef.current.focus(); spoilerTextRef.current.focus();
} else { } else {
textareaRef.current?.focus(); textareaRef.current.focus();
if (replyToStatus.account.id !== currentAccount) {
textareaRef.current.value = `@${replyToStatus.account.acct} `;
}
} }
}, 0); setVisibility(visibility);
return () => clearTimeout(timer); setSensitive(sensitive);
}, []); }
if (draftStatus) {
useEffect(() => { const { status, spoilerText, visibility, sensitive, mediaAttachments } =
if (editStatus) { draftStatus;
textareaRef.current.value = status;
spoilerTextRef.current.value = spoilerText;
setVisibility(visibility);
setSensitive(sensitive);
setMediaAttachments(mediaAttachments);
} else if (editStatus) {
const { visibility, sensitive, mediaAttachments } = editStatus; const { visibility, sensitive, mediaAttachments } = editStatus;
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
@ -93,7 +106,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
} }
})(); })();
} }
}, [editStatus]); }, [draftStatus, editStatus, replyToStatus]);
const textExpanderRef = useRef(); const textExpanderRef = useRef();
const textExpanderTextRef = useRef(''); const textExpanderTextRef = useRef('');
@ -192,13 +205,32 @@ export default ({ onClose, replyToStatus, editStatus }) => {
const beforeUnloadCopy = const beforeUnloadCopy =
'You have unsaved changes. Are you sure you want to discard this post?'; 'You have unsaved changes. Are you sure you want to discard this post?';
const canClose = () => { const canClose = () => {
// check for status or mediaAttachments
const { value, dataset } = textareaRef.current; const { value, dataset } = textareaRef.current;
const containNonIDMediaAttachments =
// check for non-ID media attachments
const hasNonIDMediaAttachments =
mediaAttachments.length > 0 && mediaAttachments.length > 0 &&
mediaAttachments.some((media) => !media.id); mediaAttachments.some((media) => !media.id);
if ((value && value !== dataset?.source) || containNonIDMediaAttachments) { // check if status contains only "@acct", if replying
const hasAcct =
replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
// check if status is different than source
const differentThanSource = dataset?.source && value !== dataset.source;
console.log({
value,
hasAcct,
differentThanSource,
hasNonIDMediaAttachments,
});
if (
(value && !hasAcct) ||
differentThanSource ||
hasNonIDMediaAttachments
) {
const yes = confirm(beforeUnloadCopy); const yes = confirm(beforeUnloadCopy);
return yes; return yes;
} }
@ -223,7 +255,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
}, []); }, []);
return ( return (
<div id="compose-container"> <div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top"> <div class="compose-top">
{currentAccountInfo?.avatarStatic && ( {currentAccountInfo?.avatarStatic && (
<Avatar <Avatar
@ -232,21 +264,137 @@ export default ({ onClose, replyToStatus, editStatus }) => {
alt={currentAccountInfo.username} alt={currentAccountInfo.username}
/> />
)} )}
<button {!standalone ? (
type="button" <span>
class="light close-button" <button
onClick={() => { type="button"
if (canClose()) { class="light"
onClose(); onClick={() => {
} // If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
}} const containNonIDMediaAttachments =
> mediaAttachments.length > 0 &&
<Icon icon="x" /> mediaAttachments.some((media) => !media.id);
</button> if (containNonIDMediaAttachments) {
const yes = confirm(
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
);
if (!yes) {
return;
}
}
const url = new URL('/compose/', window.location);
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;
const left = Math.max(0, (screenWidth - 600) / 2);
const top = Math.max(0, (screenHeight - 450) / 2);
const width = Math.min(screenWidth, 600);
const height = Math.min(screenHeight, 450);
const newWin = window.open(
url,
'compose' + Math.random(),
`width=${width},height=${height},left=${left},top=${top}`,
);
if (!newWin) {
alert('Looks like your browser is blocking popups.');
return;
}
const mediaAttachmentsWithIDs = mediaAttachments.filter(
(media) => media.id,
);
newWin.masto = masto;
newWin.__COMPOSE__ = {
editStatus,
replyToStatus,
draftStatus: {
status: textareaRef.current.value,
spoilerText: spoilerTextRef.current.value,
visibility,
sensitive,
mediaAttachments: mediaAttachmentsWithIDs,
},
};
onClose(() => {
window.opener.__STATES__.reloadStatusPage++;
});
}}
>
<Icon icon="popout" alt="Pop out" />
</button>{' '}
<button
type="button"
class="light close-button"
onClick={() => {
if (canClose()) {
onClose();
}
}}
>
<Icon icon="x" />
</button>
</span>
) : (
<button
type="button"
class="light"
onClick={() => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments =
mediaAttachments.length > 0 &&
mediaAttachments.some((media) => !media.id);
if (containNonIDMediaAttachments) {
const yes = confirm(
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
);
if (!yes) {
return;
}
}
if (!window.opener) {
alert('Looks like you closed the parent window.');
return;
}
const mediaAttachmentsWithIDs = mediaAttachments.filter(
(media) => media.id,
);
onClose(() => {
window.opener.__STATES__.showCompose = {
editStatus,
replyToStatus,
draftStatus: {
status: textareaRef.current.value,
spoilerText: spoilerTextRef.current.value,
visibility,
sensitive,
mediaAttachments: mediaAttachmentsWithIDs,
},
};
});
}}
>
<Icon icon="popin" alt="Pop in" />
</button>
)}
</div> </div>
{!!replyToStatus && ( {!!replyToStatus && (
<div class="reply-to"> <div class="status-preview">
<Status status={replyToStatus} size="s" /> <Status status={replyToStatus} size="s" />
<div class="status-preview-legend reply-to">
Replying to @
{replyToStatus.account.acct || replyToStatus.account.username}
</div>
</div>
)}
{!!editStatus && (
<div class="status-preview">
<Status status={editStatus} size="s" />
<div class="status-preview-legend">Editing source status</div>
</div> </div>
)} )}
<form <form
@ -385,6 +533,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
<input <input
name="sensitive" name="sensitive"
type="checkbox" type="checkbox"
checked={sensitive}
disabled={uiState === 'loading' || !!editStatus} disabled={uiState === 'loading' || !!editStatus}
onChange={(e) => { onChange={(e) => {
const sensitive = e.target.checked; const sensitive = e.target.checked;

View file

@ -1,3 +1,5 @@
import 'iconify-icon';
const SIZES = { const SIZES = {
s: 12, s: 12,
m: 16, m: 16,
@ -35,11 +37,18 @@ const ICONS = {
upload: 'mingcute:upload-3-line', upload: 'mingcute:upload-3-line',
gear: 'mingcute:settings-3-line', gear: 'mingcute:settings-3-line',
more: 'mingcute:more-1-line', more: 'mingcute:more-1-line',
external: 'mingcute:external-link-line',
popout: 'mingcute:external-link-line',
popin: ['mingcute:external-link-line', '180deg'],
}; };
export default ({ icon, size = 'm', alt, title, class: className = '' }) => { export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
const iconSize = SIZES[size]; const iconSize = SIZES[size];
const iconName = ICONS[icon]; let iconName = ICONS[icon];
let rotate;
if (Array.isArray(iconName)) {
[iconName, rotate] = iconName;
}
return ( return (
<div <div
class={`icon ${className}`} class={`icon ${className}`}
@ -52,7 +61,12 @@ export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
lineHeight: 0, lineHeight: 0,
}} }}
> >
<iconify-icon width={iconSize} height={iconSize} icon={iconName}> <iconify-icon
width={iconSize}
height={iconSize}
icon={iconName}
rotate={rotate}
>
{alt} {alt}
</iconify-icon> </iconify-icon>
</div> </div>

View file

@ -93,11 +93,13 @@
transform: translateX(5px); transform: translateX(5px);
} }
.status:not(.small) .container { .status .container {
padding-left: 16px;
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
} }
.status:not(.small) .container {
padding-left: 16px;
}
.status > .container > .meta { .status > .container > .meta {
display: flex; display: flex;

55
src/compose.jsx Normal file
View file

@ -0,0 +1,55 @@
import './index.css';
import './app.css';
import '@github/time-elements';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose';
function App() {
const [uiState, setUIState] = useState('default');
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
useEffect(() => {
if (uiState === 'closed') {
window.close();
}
}, [uiState]);
if (uiState === 'closed') {
return (
<div>
<p>You may close this page now.</p>
<p>
<button
onClick={() => {
window.close();
}}
>
Close window
</button>
</p>
</div>
);
}
return (
<Compose
editStatus={editStatus}
replyToStatus={replyToStatus}
draftStatus={draftStatus}
standalone
onClose={(fn = () => {}) => {
try {
fn();
setUIState('closed');
} catch (e) {}
}}
/>
);
}
render(<App />, document.getElementById('app'));

View file

@ -1,7 +1,6 @@
import './index.css'; import './index.css';
import '@github/time-elements'; import '@github/time-elements';
import 'iconify-icon';
import { render } from 'preact'; import { render } from 'preact';
import { App } from './app'; import { App } from './app';

View file

@ -1,7 +1,16 @@
import preact from '@preact/preset-vite'; import preact from '@preact/preset-vite';
import { resolve } from 'path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [preact()], plugins: [preact()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
compose: resolve(__dirname, 'compose/index.html'),
},
},
},
}); });