diff --git a/src/app.jsx b/src/app.jsx index d8acfdb9..0ca54ab1 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -43,6 +43,7 @@ import Login from './pages/login'; import Mentions from './pages/mentions'; import Notifications from './pages/notifications'; import Public from './pages/public'; +import ScheduledPosts from './pages/scheduled-posts'; import Search from './pages/search'; import StatusRoute from './pages/status-route'; import Trending from './pages/trending'; @@ -548,6 +549,7 @@ function SecondaryRoutes({ isLoggedIn }) { } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ICONS.jsx b/src/components/ICONS.jsx index 60fbb7c9..5373402c 100644 --- a/src/components/ICONS.jsx +++ b/src/components/ICONS.jsx @@ -175,4 +175,7 @@ export const ICONS = { 'user-x': () => import('@iconify-icons/mingcute/user-x-line'), minimize: () => import('@iconify-icons/mingcute/arrows-down-line'), celebrate: () => import('@iconify-icons/mingcute/celebrate-line'), + schedule: () => import('@iconify-icons/mingcute/calendar-time-add-line'), + month: () => import('@iconify-icons/mingcute/calendar-month-line'), + day: () => import('@iconify-icons/mingcute/calendar-day-line'), }; diff --git a/src/components/ScheduledAtField.jsx b/src/components/ScheduledAtField.jsx new file mode 100644 index 00000000..b2f763b7 --- /dev/null +++ b/src/components/ScheduledAtField.jsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'preact/hooks'; + +export const MIN_SCHEDULED_AT = 6 * 60 * 1000; // 6 mins +const MAX_SCHEDULED_AT = 90 * 24 * 60 * 60 * 1000; // 90 days + +export default function ScheduledAtField({ scheduledAt, setScheduledAt }) { + if (!scheduledAt || !(scheduledAt instanceof Date)) { + console.warn('scheduledAt is not a Date:', scheduledAt); + return; + } + const [minStr, setMinStr] = useState(); + const [maxStr, setMaxStr] = useState(); + const timezoneOffset = scheduledAt.getTimezoneOffset(); + + useEffect(() => { + function updateMinStr() { + const min = new Date(Date.now() + MIN_SCHEDULED_AT); + const str = new Date(min.getTime() - timezoneOffset * 60000) + .toISOString() + .slice(0, 16); + setMinStr(str); + console.log('MIN', min); + } + updateMinStr(); + + function updateMaxStr() { + const max = new Date(Date.now() + MAX_SCHEDULED_AT); + const str = new Date(max.getTime() - timezoneOffset * 60000) + .toISOString() + .slice(0, 16); + setMaxStr(str); + console.log('MAX', max); + } + updateMaxStr(); + + // Update every 10s + const intervalId = setInterval(() => { + updateMinStr(); + updateMaxStr(); + }, 1000 * 10); + return () => clearInterval(intervalId); + }, []); + + const defaultValue = scheduledAt + ? new Date(scheduledAt.getTime() - scheduledAt.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16) + : null; + + return ( + { + setScheduledAt(new Date(e.target.value)); + }} + /> + ); +} + +export function getLocalTimezoneName() { + const date = new Date(); + const formatter = new Intl.DateTimeFormat(undefined, { + timeZoneName: 'long', + }); + const parts = formatter.formatToParts(date); + const timezoneName = parts.find( + (part) => part.type === 'timeZoneName', + )?.value; + return timezoneName; +} diff --git a/src/components/compose.css b/src/components/compose.css index 554bf0b8..93de66f2 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -94,8 +94,11 @@ ); */ border-top: var(--hairline-width) 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), + 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); z-index: 2; @@ -134,7 +137,9 @@ } } #compose-container .status-preview ~ form { - box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color); + box-shadow: + var(--drop-shadow), + 0 -3px 6px -3px var(--drop-shadow-color); } #compose-container textarea { @@ -412,16 +417,17 @@ width: 80px; height: 80px; /* checkerboard background */ - background-image: linear-gradient( - 45deg, - var(--img-bg-color) 25%, - transparent 25% - ), + background-image: + linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%), linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%), linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%); background-size: 10px 10px; - background-position: 0 0, 0 5px, 5px -5px, -5px 0px; + background-position: + 0 0, + 0 5px, + 5px -5px, + -5px 0px; } #compose-container .media-preview > * { width: 80px; @@ -562,6 +568,25 @@ color: var(--red-color); } +#compose-container { + .scheduled-at { + background-color: var(--bg-faded-color); + border-radius: 8px; + margin: 8px 0 0; + justify-content: flex-end; + text-align: end; + + input[type='datetime-local'] { + max-width: 80vw; + padding: 4px; + + &:invalid { + border-color: var(--red-color); + } + } + } +} + .compose-menu-add-media { position: relative; @@ -660,16 +685,17 @@ overflow: hidden; box-shadow: 0 2px 16px var(--img-bg-color); /* checkerboard background */ - background-image: linear-gradient( - 45deg, - var(--img-bg-color) 25%, - transparent 25% - ), + background-image: + linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%), linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%), linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%); background-size: 20px 20px; - background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + background-position: + 0 0, + 0 10px, + 10px -10px, + -10px 0px; flex: 0.8; } #media-sheet .media-preview > * { diff --git a/src/components/compose.jsx b/src/components/compose.jsx index e11f16d3..913785a1 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -59,6 +59,10 @@ import AccountBlock from './account-block'; import Icon from './icon'; import Loader from './loader'; import Modal from './modal'; +import ScheduledAtField, { + getLocalTimezoneName, + MIN_SCHEDULED_AT, +} from './ScheduledAtField'; import Status from './status'; const { @@ -207,6 +211,7 @@ const ADD_LABELS = { customEmoji: msg`Add custom emoji`, gif: msg`Add GIF`, poll: msg`Add poll`, + scheduledPost: msg`Schedule post`, }; function Compose({ @@ -265,6 +270,7 @@ function Compose({ const prevLanguage = useRef(language); const [mediaAttachments, setMediaAttachments] = useState([]); const [poll, setPoll] = useState(null); + const [scheduledAt, setScheduledAt] = useState(null); const prefs = store.account.get('preferences') || {}; @@ -375,6 +381,7 @@ function Compose({ sensitive, poll, mediaAttachments, + scheduledAt, } = draftStatus; const composablePoll = !!poll?.options && { ...poll, @@ -394,6 +401,7 @@ function Compose({ if (sensitive !== null) setSensitive(sensitive); if (composablePoll) setPoll(composablePoll); if (mediaAttachments) setMediaAttachments(mediaAttachments); + if (scheduledAt) setScheduledAt(scheduledAt); } }, [draftStatus, editStatus, replyToStatus]); @@ -574,6 +582,7 @@ function Compose({ sensitive, poll, mediaAttachments, + scheduledAt, }, }; if ( @@ -773,6 +782,13 @@ function Compose({ }, }); + const showScheduledAt = !editStatus; + const scheduledAtButtonDisabled = uiState === 'loading' || !!scheduledAt; + const onScheduledAtClick = () => { + const date = new Date(Date.now() + MIN_SCHEDULED_AT); + setScheduledAt(date); + }; + return (
@@ -827,6 +843,7 @@ function Compose({ sensitive, poll, mediaAttachments, + scheduledAt, }, }); @@ -915,6 +932,7 @@ function Compose({ sensitive, poll, mediaAttachments, + scheduledAt, }, }; window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again @@ -991,11 +1009,17 @@ function Compose({ const formData = new FormData(e.target); const entries = Object.fromEntries(formData.entries()); console.log('ENTRIES', entries); - let { status, visibility, sensitive, spoilerText } = entries; + let { status, visibility, sensitive, spoilerText, scheduledAt } = + entries; // Pre-cleanup sensitive = sensitive === 'on'; // checkboxes return "on" if checked + // Convert datetime-local input value to RFC3339 Date string value + scheduledAt = scheduledAt + ? new Date(scheduledAt).toISOString() + : undefined; + // Validation /* Let the backend validate this if (stringLength(status) > maxCharacters) { @@ -1125,6 +1149,7 @@ function Compose({ params.visibility = visibility; // params.inReplyToId = replyToStatus?.id || undefined; params.in_reply_to_id = replyToStatus?.id || undefined; + params.scheduled_at = scheduledAt; } params = removeNullUndefined(params); console.log('POST', params); @@ -1161,6 +1186,7 @@ function Compose({ type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post', newStatus, instance, + scheduledAt, }); } catch (e) { states.composerState.publishing = false; @@ -1359,6 +1385,30 @@ function Compose({ }} /> )} + {scheduledAt && ( +
+ + +
+ )}