diff --git a/compose/index.html b/compose/index.html index 23ba6668..34b7cca1 100644 --- a/compose/index.html +++ b/compose/index.html @@ -8,7 +8,7 @@ -
+
diff --git a/src/app.css b/src/app.css index de389690..4831ad3a 100644 --- a/src/app.css +++ b/src/app.css @@ -4,7 +4,7 @@ body { padding: 0; background-color: var(--bg-color); color: var(--text-color); - overflow: hidden; + /* overflow: hidden; */ } #app { diff --git a/src/app.jsx b/src/app.jsx index 93f5e27a..37b69227 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -277,8 +277,8 @@ export function App() { ? snapStates.showCompose.replyToStatus : null } - editStatus={snapStates.showCompose?.editStatus || null} - draftStatus={snapStates.showCompose?.draftStatus || null} + editStatus={states.showCompose?.editStatus || null} + draftStatus={states.showCompose?.draftStatus || null} onClose={(results) => { const { newStatus } = results || {}; states.showCompose = false; diff --git a/src/components/compose.css b/src/components/compose.css index 27fa7edc..5d315957 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -6,6 +6,10 @@ max-height: 100vh; overflow: auto; } +#compose-container.standalone { + max-height: none; + margin: auto; +} #compose-container .compose-top { text-align: right; @@ -110,8 +114,8 @@ min-width: 0; } #compose-container .toolbar-button { - cursor: pointer; display: inline-block; + color: var(--text-color); background-color: var(--bg-faded-color); padding: 0 8px; border-radius: 8px; @@ -123,6 +127,7 @@ position: relative; white-space: nowrap; border: 2px solid transparent; + vertical-align: middle; } #compose-container .toolbar-button > * { vertical-align: middle; @@ -131,9 +136,11 @@ } #compose-container .toolbar-button:has([disabled]) { pointer-events: none; + background-color: var(--bg-faded-color); + opacity: 0.5; } #compose-container .toolbar-button:has([disabled]) > * { - filter: opacity(0.5); + /* filter: opacity(0.5); */ } #compose-container .toolbar-button:not(.show-field) @@ -157,10 +164,12 @@ right: 0; left: auto !important; } -#compose-container .toolbar-button:hover { +#compose-container .toolbar-button:not(:disabled):hover { + cursor: pointer; + filter: none; border-color: var(--divider-color); } -#compose-container .toolbar-button:active { +#compose-container .toolbar-button:not(:disabled):active { filter: brightness(0.8); } @@ -272,3 +281,80 @@ color: var(--green-color); margin-bottom: 4px; } + +#compose-container .poll { + background-color: var(--bg-faded-color); + border-radius: 8px; + margin: 8px 0 0; +} + +#compose-container .poll-choices { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +} +#compose-container .poll-choice { + display: flex; + gap: 8px; + align-items: center; + justify-content: stretch; + flex-direction: row-reverse; +} +#compose-container .poll-choice input { + flex-grow: 1; + min-width: 0; +} + +#compose-container .poll-button { + border: 2px solid var(--outline-color); + width: 28px; + height: 28px; + padding: 0; + flex-shrink: 0; + line-height: 0; + overflow: hidden; + transition: border-radius 1s ease-out; + font-size: 14px; +} +#compose-container .multiple .poll-button { + border-radius: 4px; +} + +#compose-container .poll-toolbar { + display: flex; + gap: 8px; + align-items: stretch; + justify-content: space-between; + font-size: 90%; + border-top: 1px solid var(--outline-color); + padding: 8px; +} +#compose-container .poll-toolbar select { + padding: 4px; +} + +#compose-container .multiple-choices { + flex-grow: 1; + display: flex; + gap: 4px; + align-items: center; + border-left: 1px solid var(--outline-color); + padding-left: 8px; +} + +#compose-container .expires-in { + flex-grow: 1; + border-left: 1px solid var(--outline-color); + padding-left: 8px; + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +} + +#compose-container .remove-poll-button { + width: 100%; + color: var(--red-color); +} diff --git a/src/components/compose.jsx b/src/components/compose.jsx index dfce4c67..1b1ba916 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -18,12 +18,31 @@ import Status from './status'; - Max character limit includes BOTH status text and Content Warning text */ +const expiryOptions = { + '5 minutes': 5 * 60, + '30 minutes': 30 * 60, + '1 hour': 60 * 60, + '6 hours': 6 * 60 * 60, + '1 day': 24 * 60 * 60, + '3 days': 3 * 24 * 60 * 60, + '7 days': 7 * 24 * 60 * 60, +}; +const expirySeconds = Object.values(expiryOptions); +const oneDay = 24 * 60 * 60; + +const expiresInFromExpiresAt = (expiresAt) => { + if (!expiresAt) return oneDay; + const delta = (new Date(expiresAt).getTime() - Date.now()) / 1000; + return expirySeconds.find((s) => s >= delta) || oneDay; +}; + function Compose({ onClose, replyToStatus, editStatus, draftStatus, standalone, + hasOpener, }) { const [uiState, setUIState] = useState('default'); @@ -57,10 +76,11 @@ function Compose({ } = configuration; const textareaRef = useRef(); - + const spoilerTextRef = useRef(); const [visibility, setVisibility] = useState('public'); const [sensitive, setSensitive] = useState(false); - const spoilerTextRef = useRef(); + const [mediaAttachments, setMediaAttachments] = useState([]); + const [poll, setPoll] = useState(null); useEffect(() => { if (replyToStatus) { @@ -78,15 +98,32 @@ function Compose({ setSensitive(sensitive); } if (draftStatus) { - const { status, spoilerText, visibility, sensitive, mediaAttachments } = - draftStatus; + const { + status, + spoilerText, + visibility, + sensitive, + poll, + mediaAttachments, + } = draftStatus; + const composablePoll = !!poll?.options && { + ...poll, + options: poll.options.map((o) => o?.title || o), + expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), + }; textareaRef.current.value = status; spoilerTextRef.current.value = spoilerText; setVisibility(visibility); setSensitive(sensitive); + setPoll(composablePoll); setMediaAttachments(mediaAttachments); } else if (editStatus) { - const { visibility, sensitive, mediaAttachments } = editStatus; + const { visibility, sensitive, poll, mediaAttachments } = editStatus; + const composablePoll = !!poll?.options && { + ...poll, + options: poll.options.map((o) => o?.title || o), + expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), + }; setUIState('loading'); (async () => { try { @@ -98,6 +135,7 @@ function Compose({ spoilerTextRef.current.value = spoilerText; setVisibility(visibility); setSensitive(sensitive); + setPoll(composablePoll); setMediaAttachments(mediaAttachments); setUIState('default'); } catch (e) { @@ -199,8 +237,6 @@ function Compose({ } }, []); - const [mediaAttachments, setMediaAttachments] = useState([]); - const formRef = useRef(); const beforeUnloadCopy = @@ -216,7 +252,9 @@ function Compose({ } // check if all media attachments have IDs - const hasIDMediaAttachments = mediaAttachments.every((media) => media.id); + const hasIDMediaAttachments = + mediaAttachments.length > 0 && + mediaAttachments.every((media) => media.id); if (hasIDMediaAttachments) { console.log('canClose', { hasIDMediaAttachments }); return true; @@ -242,6 +280,7 @@ function Compose({ value, hasMediaAttachments, hasIDMediaAttachments, + poll, isSelf, hasOnlyAcct, sameWithSource, @@ -316,6 +355,7 @@ function Compose({ spoilerText: spoilerTextRef.current.value, visibility, sensitive, + poll, mediaAttachments: mediaAttachmentsWithIDs, }, }); @@ -343,51 +383,54 @@ function Compose({ ) : ( - + onClose({ + fn: () => { + window.opener.__STATES__.showCompose = { + editStatus, + replyToStatus, + draftStatus: { + status: textareaRef.current.value, + spoilerText: spoilerTextRef.current.value, + visibility, + sensitive, + poll, + mediaAttachments: mediaAttachmentsWithIDs, + }, + }; + }, + }); + }} + > + + + ) )} {!!replyToStatus && ( @@ -436,6 +479,16 @@ function Compose({ ); return; } + if (poll) { + if (poll.options.length < 2) { + alert('Poll must have at least 2 options'); + return; + } + if (poll.options.some((option) => option === '')) { + alert('Some poll choices are empty'); + return; + } + } // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters // Post-cleanup @@ -449,8 +502,7 @@ function Compose({ if (mediaAttachments.length > 0) { // Upload media attachments first const mediaPromises = mediaAttachments.map((attachment) => { - const { file, description, sourceDescription, id } = - attachment; + const { file, description, id } = attachment; console.log('UPLOADING', attachment); if (id) { // If already uploaded @@ -493,6 +545,7 @@ function Compose({ status, spoilerText, sensitive, + poll, mediaIds: mediaAttachments.map((attachment) => attachment.id), }; if (!editStatus) { @@ -639,59 +692,87 @@ function Compose({ })} )} + {!!poll && ( + { + if (poll) { + const newPoll = { ...poll }; + setPoll(newPoll); + } else { + setPoll(null); + } + }} + /> + )}
-
-
-
- {uiState === 'loading' && }{' '} - -
+ }} + /> + + {' '} + {' '} +
+ {uiState === 'loading' && }{' '} +
@@ -760,4 +841,111 @@ function MediaAttachment({ ); } +function Poll({ + poll, + disabled, + onInput = () => {}, + maxOptions, + maxExpiration, + minExpiration, + maxCharactersPerOption, +}) { + const { options, expiresIn, multiple } = poll; + + return ( +
+
+ {options.map((option, i) => ( +
+ { + const { value } = e.target; + options[i] = value; + onInput(poll); + }} + /> + +
+ ))} +
+
+ {' '} + + +
+
+ +
+
+ ); +} + export default Compose; diff --git a/src/components/icon.jsx b/src/components/icon.jsx index a55314c7..2fec0714 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -40,9 +40,12 @@ const ICONS = { external: 'mingcute:external-link-line', popout: 'mingcute:external-link-line', popin: ['mingcute:external-link-line', '180deg'], + plus: 'mingcute:add-circle-line', }; export default ({ icon, size = 'm', alt, title, class: className = '' }) => { + if (!icon) return null; + const iconSize = SIZES[size]; let iconName = ICONS[icon]; let rotate; diff --git a/src/components/status.jsx b/src/components/status.jsx index ef304de7..986df14d 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -264,10 +264,14 @@ function Card({ card }) { } } -function Poll({ poll }) { +function Poll({ poll, readOnly }) { const [pollSnapshot, setPollSnapshot] = useState(poll); const [uiState, setUIState] = useState('default'); + useEffect(() => { + setPollSnapshot(poll); + }, [poll]); + const { expired, expiresAt, @@ -280,7 +284,7 @@ function Poll({ poll }) { votesCount, } = pollSnapshot; - const expiresAtDate = new Date(expiresAt); + const expiresAtDate = !!expiresAt && new Date(expiresAt); return (
@@ -296,6 +300,7 @@ function Poll({ poll }) { optionVotesCount === Math.max(...options.map((o) => o.votesCount)); return (
@@ -357,37 +362,44 @@ function Poll({ poll }) { name="poll" value={i} disabled={uiState === 'loading'} + readOnly={readOnly} /> {title}
); })} - + {!readOnly && ( + + )} )} -

- {shortenNumber(votersCount)}{' '} - {votersCount === 1 ? 'voter' : 'voters'} - {votersCount !== votesCount && ( - <> - {' '} - • - {shortenNumber(votesCount)} - {' '} - vote - {votesCount === 1 ? '' : 's'} - - )}{' '} - • {expired ? 'Ended' : 'Ending'}{' '} - -

+ {!readOnly && ( +

+ {shortenNumber(votersCount)}{' '} + {votersCount === 1 ? 'voter' : 'voters'} + {votersCount !== votesCount && ( + <> + {' '} + • + {shortenNumber(votesCount)} + {' '} + vote + {votesCount === 1 ? '' : 's'} + + )}{' '} + • {expired ? 'Ended' : 'Ending'}{' '} + {!!expiresAtDate && ( + + )} +

+ )}
); } @@ -449,7 +461,7 @@ function EditedAtModal({ statusID, onClose = () => {} }) { }).format(createdAtDate)} - + ); })} @@ -470,7 +482,7 @@ function Status({ withinContext, size = 'm', skeleton, - editStatus, + readOnly, }) { if (skeleton) { return ( @@ -645,7 +657,7 @@ function Status({ )} {' '} - {size !== 'l' && !editStatus && ( + {size !== 'l' && uri ? ( + ) : ( + + {' '} + + {createdAtDate.toLocaleString()} + + )}
- {!!poll && } + {!!poll && } {!spoilerText && sensitive && !!mediaAttachments.length && (