New feature: poll

- More fixes
This commit is contained in:
Lim Chee Aun 2022-12-14 21:48:17 +08:00
parent 121e9176f3
commit 72751709df
9 changed files with 456 additions and 147 deletions

View file

@ -8,7 +8,7 @@
<meta name="color-scheme" content="dark light" />
</head>
<body>
<div id="app"></div>
<div id="app-standalone"></div>
<script type="module" src="/src/compose.jsx"></script>
</body>
</html>

View file

@ -4,7 +4,7 @@ body {
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
overflow: hidden;
/* overflow: hidden; */
}
#app {

View file

@ -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;

View file

@ -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);
}

View file

@ -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,6 +383,7 @@ function Compose({
</button>
</span>
) : (
hasOpener && (
<button
type="button"
class="light"
@ -379,6 +420,7 @@ function Compose({
spoilerText: spoilerTextRef.current.value,
visibility,
sensitive,
poll,
mediaAttachments: mediaAttachmentsWithIDs,
},
};
@ -388,6 +430,7 @@ function Compose({
>
<Icon icon="popin" alt="Pop in" />
</button>
)
)}
</div>
{!!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,8 +692,25 @@ function Compose({
})}
</div>
)}
{!!poll && (
<Poll
maxOptions={maxOptions}
maxExpiration={maxExpiration}
minExpiration={minExpiration}
maxCharactersPerOption={maxCharactersPerOption}
poll={poll}
disabled={uiState === 'loading'}
onInput={(poll) => {
if (poll) {
const newPoll = { ...poll };
setPoll(newPoll);
} else {
setPoll(null);
}
}}
/>
)}
<div class="toolbar">
<div>
<label class="toolbar-button">
<input
type="file"
@ -648,7 +718,8 @@ function Compose({
multiple={mediaAttachments.length < maxMediaAttachments - 1}
disabled={
uiState === 'loading' ||
mediaAttachments.length >= maxMediaAttachments
mediaAttachments.length >= maxMediaAttachments ||
!!poll
}
onChange={(e) => {
const files = e.target.files;
@ -680,19 +751,29 @@ function Compose({
}}
/>
<Icon icon="attachment" />
</label>
</div>
<div>
{uiState === 'loading' && <Loader abrupt />}{' '}
</label>{' '}
<button
type="submit"
class="large"
disabled={uiState === 'loading'}
type="button"
class="toolbar-button"
disabled={
uiState === 'loading' || !!poll || !!mediaAttachments.length
}
onClick={() => {
setPoll({
options: ['', ''],
expiresIn: 24 * 60 * 60, // 1 day
multiple: false,
});
}}
>
<Icon icon="poll" alt="Add poll" />
</button>{' '}
<div class="spacer" />
{uiState === 'loading' && <Loader abrupt />}{' '}
<button type="submit" class="large" disabled={uiState === 'loading'}>
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
</button>
</div>
</div>
</form>
</div>
);
@ -760,4 +841,111 @@ function MediaAttachment({
);
}
function Poll({
poll,
disabled,
onInput = () => {},
maxOptions,
maxExpiration,
minExpiration,
maxCharactersPerOption,
}) {
const { options, expiresIn, multiple } = poll;
return (
<div class={`poll ${multiple ? 'multiple' : ''}`}>
<div class="poll-choices">
{options.map((option, i) => (
<div class="poll-choice" key={i}>
<input
required
type="text"
value={option}
disabled={disabled}
maxlength={maxCharactersPerOption}
placeholder={`Choice ${i + 1}`}
onInput={(e) => {
const { value } = e.target;
options[i] = value;
onInput(poll);
}}
/>
<button
type="button"
class="plain2 poll-button"
disabled={disabled || options.length <= 1}
onClick={() => {
options.splice(i, 1);
onInput(poll);
}}
>
<Icon icon="x" size="s" />
</button>
</div>
))}
</div>
<div class="poll-toolbar">
<button
type="button"
class="plain2 poll-button"
disabled={disabled || options.length >= maxOptions}
onClick={() => {
options.push('');
onInput(poll);
}}
>
+
</button>{' '}
<label class="multiple-choices">
<input
type="checkbox"
checked={multiple}
disabled={disabled}
onChange={(e) => {
const { checked } = e.target;
poll.multiple = checked;
onInput(poll);
}}
/>{' '}
Multiple choices
</label>
<label class="expires-in">
Duration{' '}
<select
value={expiresIn}
disabled={disabled}
onChange={(e) => {
const { value } = e.target;
poll.expiresIn = value;
onInput(poll);
}}
>
{Object.entries(expiryOptions)
.filter(([label, value]) => {
return value >= minExpiration && value <= maxExpiration;
})
.map(([label, value]) => (
<option value={value} key={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div class="poll-toolbar">
<button
type="button"
class="plain remove-poll-button"
disabled={disabled}
onClick={() => {
onInput(null);
}}
>
Remove poll
</button>
</div>
</div>
);
}
export default Compose;

View file

@ -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;

View file

@ -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 (
<div class="poll">
@ -296,6 +300,7 @@ function Poll({ poll }) {
optionVotesCount === Math.max(...options.map((o) => o.votesCount));
return (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option ${isLeading ? 'poll-option-leading' : ''}`}
style={{
'--percentage': `${percentage}%`,
@ -343,7 +348,7 @@ function Poll({ poll }) {
setUIState('default');
}}
style={{
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
pointerEvents: uiState === 'loading' || readOnly ? 'none' : 'auto',
opacity: uiState === 'loading' ? 0.5 : 1,
}}
>
@ -357,12 +362,14 @@ function Poll({ poll }) {
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
@ -370,8 +377,10 @@ function Poll({ poll }) {
>
Vote
</button>
)}
</form>
)}
{!readOnly && (
<p class="poll-meta">
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'}
@ -386,8 +395,11 @@ function Poll({ poll }) {
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && (
<relative-time datetime={expiresAtDate.toISOString()} />
)}
</p>
)}
</div>
);
}
@ -449,7 +461,7 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
}).format(createdAtDate)}
</time>
</h3>
<Status status={status} size="s" withinContext editStatus />
<Status status={status} size="s" withinContext readOnly />
</li>
);
})}
@ -470,7 +482,7 @@ function Status({
withinContext,
size = 'm',
skeleton,
editStatus,
readOnly,
}) {
if (skeleton) {
return (
@ -645,7 +657,7 @@ function Status({
</>
)}
</span>{' '}
{size !== 'l' && !editStatus && (
{size !== 'l' && uri ? (
<a href={uri} target="_blank" class="time">
<Icon
icon={visibilityIconsMap[visibility]}
@ -661,6 +673,22 @@ function Status({
{createdAtDate.toLocaleString()}
</relative-time>
</a>
) : (
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<relative-time
datetime={createdAtDate.toISOString()}
format="micro"
threshold="P1D"
prefix=""
>
{createdAtDate.toLocaleString()}
</relative-time>
</span>
)}
</div>
<div
@ -731,7 +759,7 @@ function Status({
}),
}}
/>
{!!poll && <Poll poll={poll} />}
{!!poll && <Poll poll={poll} readOnly={readOnly} />}
{!spoilerText && sensitive && !!mediaAttachments.length && (
<button
class="plain spoiler"

View file

@ -25,7 +25,7 @@ function App() {
if (uiState === 'closed') {
return (
<div>
<div class="box">
<p>You may close this page now.</p>
<p>
<button
@ -46,6 +46,7 @@ function App() {
replyToStatus={replyToStatus}
draftStatus={draftStatus}
standalone
hasOpener={window.opener}
onClose={(results) => {
const { newStatus, fn = () => {} } = results || {};
try {
@ -60,4 +61,4 @@ function App() {
);
}
render(<App />, document.getElementById('app'));
render(<App />, document.getElementById('app-standalone'));

View file

@ -114,7 +114,6 @@ button,
border: 0;
background-color: var(--button-bg-color);
color: var(--button-text-color);
cursor: pointer;
line-height: 1;
vertical-align: middle;
text-decoration: none;
@ -122,14 +121,14 @@ button,
button > * {
vertical-align: middle;
}
:is(button, .button):not([disabled]):hover {
:is(button, .button):not(:disabled, .disabled):hover {
cursor: pointer;
filter: brightness(1.2);
}
:is(button, .button):active {
:is(button, .button):not(:disabled, .disabled):active {
filter: brightness(0.8);
}
:is(button, .button)[disabled] {
cursor: auto;
:is(button:disabled, .button.disabled) {
opacity: 0.5;
}
@ -215,6 +214,10 @@ code {
display: inline-block;
}
.spacer {
flex-grow: 1;
}
/* KEYFRAMES */
@keyframes fade-in {