MVP Scheduled Posts implementation

Somehow the CSS got formatted differently
This commit is contained in:
Lim Chee Aun 2025-02-25 21:55:43 +08:00
parent 66ab13e63f
commit 7b1d6741dd
16 changed files with 1156 additions and 367 deletions

View file

@ -43,6 +43,7 @@ import Login from './pages/login';
import Mentions from './pages/mentions'; import Mentions from './pages/mentions';
import Notifications from './pages/notifications'; import Notifications from './pages/notifications';
import Public from './pages/public'; import Public from './pages/public';
import ScheduledPosts from './pages/scheduled-posts';
import Search from './pages/search'; import Search from './pages/search';
import StatusRoute from './pages/status-route'; import StatusRoute from './pages/status-route';
import Trending from './pages/trending'; import Trending from './pages/trending';
@ -548,6 +549,7 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route path=":id" element={<List />} /> <Route path=":id" element={<List />} />
</Route> </Route>
<Route path="/fh" element={<FollowedHashtags />} /> <Route path="/fh" element={<FollowedHashtags />} />
<Route path="/sp" element={<ScheduledPosts />} />
<Route path="/ft" element={<Filters />} /> <Route path="/ft" element={<Filters />} />
<Route path="/catchup" element={<Catchup />} /> <Route path="/catchup" element={<Catchup />} />
<Route path="/annual_report/:year" element={<AnnualReport />} /> <Route path="/annual_report/:year" element={<AnnualReport />} />

View file

@ -175,4 +175,7 @@ export const ICONS = {
'user-x': () => import('@iconify-icons/mingcute/user-x-line'), 'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'), minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
celebrate: () => import('@iconify-icons/mingcute/celebrate-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'),
}; };

View file

@ -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 (
<input
type="datetime-local"
name="scheduledAt"
defaultValue={defaultValue}
min={minStr}
max={maxStr}
required
onChange={(e) => {
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;
}

View file

@ -94,8 +94,11 @@
); */ ); */
border-top: var(--hairline-width) solid var(--outline-color); border-top: var(--hairline-width) solid var(--outline-color);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
text-shadow: 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),
0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color); 0 1px 10px var(--bg-color);
z-index: 2; z-index: 2;
@ -134,7 +137,9 @@
} }
} }
#compose-container .status-preview ~ form { #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 { #compose-container textarea {
@ -412,16 +417,17 @@
width: 80px; width: 80px;
height: 80px; height: 80px;
/* checkerboard background */ /* checkerboard background */
background-image: linear-gradient( background-image:
45deg, linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%),
var(--img-bg-color) 25%,
transparent 25%
),
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%),
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-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 > * { #compose-container .media-preview > * {
width: 80px; width: 80px;
@ -562,6 +568,25 @@
color: var(--red-color); 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 { .compose-menu-add-media {
position: relative; position: relative;
@ -660,16 +685,17 @@
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 16px var(--img-bg-color); box-shadow: 0 2px 16px var(--img-bg-color);
/* checkerboard background */ /* checkerboard background */
background-image: linear-gradient( background-image:
45deg, linear-gradient(45deg, var(--img-bg-color) 25%, transparent 25%),
var(--img-bg-color) 25%,
transparent 25%
),
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%),
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-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; flex: 0.8;
} }
#media-sheet .media-preview > * { #media-sheet .media-preview > * {

View file

@ -59,6 +59,10 @@ import AccountBlock from './account-block';
import Icon from './icon'; import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
import Modal from './modal'; import Modal from './modal';
import ScheduledAtField, {
getLocalTimezoneName,
MIN_SCHEDULED_AT,
} from './ScheduledAtField';
import Status from './status'; import Status from './status';
const { const {
@ -207,6 +211,7 @@ const ADD_LABELS = {
customEmoji: msg`Add custom emoji`, customEmoji: msg`Add custom emoji`,
gif: msg`Add GIF`, gif: msg`Add GIF`,
poll: msg`Add poll`, poll: msg`Add poll`,
scheduledPost: msg`Schedule post`,
}; };
function Compose({ function Compose({
@ -265,6 +270,7 @@ function Compose({
const prevLanguage = useRef(language); const prevLanguage = useRef(language);
const [mediaAttachments, setMediaAttachments] = useState([]); const [mediaAttachments, setMediaAttachments] = useState([]);
const [poll, setPoll] = useState(null); const [poll, setPoll] = useState(null);
const [scheduledAt, setScheduledAt] = useState(null);
const prefs = store.account.get('preferences') || {}; const prefs = store.account.get('preferences') || {};
@ -375,6 +381,7 @@ function Compose({
sensitive, sensitive,
poll, poll,
mediaAttachments, mediaAttachments,
scheduledAt,
} = draftStatus; } = draftStatus;
const composablePoll = !!poll?.options && { const composablePoll = !!poll?.options && {
...poll, ...poll,
@ -394,6 +401,7 @@ function Compose({
if (sensitive !== null) setSensitive(sensitive); if (sensitive !== null) setSensitive(sensitive);
if (composablePoll) setPoll(composablePoll); if (composablePoll) setPoll(composablePoll);
if (mediaAttachments) setMediaAttachments(mediaAttachments); if (mediaAttachments) setMediaAttachments(mediaAttachments);
if (scheduledAt) setScheduledAt(scheduledAt);
} }
}, [draftStatus, editStatus, replyToStatus]); }, [draftStatus, editStatus, replyToStatus]);
@ -574,6 +582,7 @@ function Compose({
sensitive, sensitive,
poll, poll,
mediaAttachments, mediaAttachments,
scheduledAt,
}, },
}; };
if ( 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 ( return (
<div id="compose-container-outer"> <div id="compose-container-outer">
<div id="compose-container" class={standalone ? 'standalone' : ''}> <div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -827,6 +843,7 @@ function Compose({
sensitive, sensitive,
poll, poll,
mediaAttachments, mediaAttachments,
scheduledAt,
}, },
}); });
@ -915,6 +932,7 @@ function Compose({
sensitive, sensitive,
poll, poll,
mediaAttachments, mediaAttachments,
scheduledAt,
}, },
}; };
window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again 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 formData = new FormData(e.target);
const entries = Object.fromEntries(formData.entries()); const entries = Object.fromEntries(formData.entries());
console.log('ENTRIES', entries); console.log('ENTRIES', entries);
let { status, visibility, sensitive, spoilerText } = entries; let { status, visibility, sensitive, spoilerText, scheduledAt } =
entries;
// Pre-cleanup // Pre-cleanup
sensitive = sensitive === 'on'; // checkboxes return "on" if checked 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 // Validation
/* Let the backend validate this /* Let the backend validate this
if (stringLength(status) > maxCharacters) { if (stringLength(status) > maxCharacters) {
@ -1125,6 +1149,7 @@ function Compose({
params.visibility = visibility; params.visibility = visibility;
// params.inReplyToId = replyToStatus?.id || undefined; // params.inReplyToId = replyToStatus?.id || undefined;
params.in_reply_to_id = replyToStatus?.id || undefined; params.in_reply_to_id = replyToStatus?.id || undefined;
params.scheduled_at = scheduledAt;
} }
params = removeNullUndefined(params); params = removeNullUndefined(params);
console.log('POST', params); console.log('POST', params);
@ -1161,6 +1186,7 @@ function Compose({
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post', type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
newStatus, newStatus,
instance, instance,
scheduledAt,
}); });
} catch (e) { } catch (e) {
states.composerState.publishing = false; states.composerState.publishing = false;
@ -1359,6 +1385,30 @@ function Compose({
}} }}
/> />
)} )}
{scheduledAt && (
<div class="toolbar scheduled-at">
<button
type="button"
class="plain4 small"
onClick={() => {
setScheduledAt(null);
}}
>
<Icon icon="x" />
</button>
<label>
<Trans>
Posting on{' '}
<ScheduledAtField
scheduledAt={scheduledAt}
setScheduledAt={setScheduledAt}
/>
</Trans>
<br />
<small>{getLocalTimezoneName()}</small>
</label>
</div>
)}
<div class="toolbar compose-footer"> <div class="toolbar compose-footer">
<span class="add-toolbar-button-group spacer"> <span class="add-toolbar-button-group spacer">
{showAddButton && ( {showAddButton && (
@ -1418,12 +1468,23 @@ function Compose({
<span>{_(ADD_LABELS.gif)}</span> <span>{_(ADD_LABELS.gif)}</span>
</MenuItem> </MenuItem>
)} )}
{showPollButton && (
<MenuItem <MenuItem
disabled={pollButtonDisabled} disabled={pollButtonDisabled}
onClick={onPollButtonClick} onClick={onPollButtonClick}
> >
<Icon icon="poll" /> <span>{_(ADD_LABELS.poll)}</span> <Icon icon="poll" /> <span>{_(ADD_LABELS.poll)}</span>
</MenuItem> </MenuItem>
)}
{showScheduledAt && (
<MenuItem
disabled={scheduledAtButtonDisabled}
onClick={onScheduledAtClick}
>
<Icon icon="schedule" />{' '}
<span>{_(ADD_LABELS.scheduledPost)}</span>
</MenuItem>
)}
</Menu2> </Menu2>
)} )}
<span class="add-sub-toolbar-button-group" ref={addSubToolbarRef}> <span class="add-sub-toolbar-button-group" ref={addSubToolbarRef}>
@ -1476,7 +1537,6 @@ function Compose({
/> />
</button> </button>
)} )}
{}
{showPollButton && ( {showPollButton && (
<> <>
<button <button
@ -1489,6 +1549,16 @@ function Compose({
</button> </button>
</> </>
)} )}
{showScheduledAt && (
<button
type="button"
class={`toolbar-button ${scheduledAt ? 'highlight' : ''}`}
disabled={scheduledAtButtonDisabled}
onClick={onScheduledAtClick}
>
<Icon icon="schedule" alt={_(ADD_LABELS.scheduledPost)} />
</button>
)}
</span> </span>
</span> </span>
{/* <div class="spacer" /> */} {/* <div class="spacer" /> */}
@ -1551,7 +1621,9 @@ function Compose({
</select> </select>
</label>{' '} </label>{' '}
<button type="submit" disabled={uiState === 'loading'}> <button type="submit" disabled={uiState === 'loading'}>
{replyToStatus {scheduledAt
? t`Schedule`
: replyToStatus
? t`Reply` ? t`Reply`
: editStatus : editStatus
? t`Update` ? t`Update`

View file

@ -63,15 +63,20 @@ export default function Modals() {
null null
} }
onClose={(results) => { onClose={(results) => {
const { newStatus, instance, type } = results || {}; const { newStatus, instance, type, scheduledAt } = results || {};
states.showCompose = false; states.showCompose = false;
window.__COMPOSE__ = null; window.__COMPOSE__ = null;
if (newStatus) { if (newStatus) {
states.reloadStatusPage++; states.reloadStatusPage++;
if (scheduledAt) states.reloadScheduledPosts++;
showToast({ showToast({
text: { text: {
post: t`Post published. Check it out.`, post: scheduledAt
reply: t`Reply posted. Check it out.`, ? t`Post scheduled`
: t`Post published. Check it out.`,
reply: scheduledAt
? t`Reply scheduled`
: t`Reply posted. Check it out.`,
edit: t`Post updated. Check it out.`, edit: t`Post updated. Check it out.`,
}[type || 'post'], }[type || 'post'],
delay: 1000, delay: 1000,
@ -79,11 +84,15 @@ export default function Modals() {
onClick: (toast) => { onClick: (toast) => {
toast.hideToast(); toast.hideToast();
states.prevLocation = location; states.prevLocation = location;
if (scheduledAt) {
navigate('/sp');
} else {
navigate( navigate(
instance instance
? `/${instance}/s/${newStatus.id}` ? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`, : `/s/${newStatus.id}`,
); );
}
}, },
}); });
} }

View file

@ -36,6 +36,7 @@ function NameText({
onClick, onClick,
}) { }) {
const { i18n } = useLingui(); const { i18n } = useLingui();
if (!account) return null;
const { const {
acct, acct,
avatar, avatar,

View file

@ -254,6 +254,12 @@ function NavMenu(props) {
<Trans>Followed Hashtags</Trans> <Trans>Followed Hashtags</Trans>
</span> </span>
</MenuLink> </MenuLink>
<MenuLink to="/sp">
<Icon icon="schedule" size="l" />{' '}
<span>
<Trans>Scheduled Posts</Trans>
</span>
</MenuLink>
<MenuDivider /> <MenuDivider />
{supports('@mastodon/filters') && ( {supports('@mastodon/filters') && (
<MenuLink to="/ft"> <MenuLink to="/ft">

View file

@ -27,7 +27,7 @@ export default function Poll({
ownVotes, ownVotes,
voted, voted,
votersCount, votersCount,
votesCount, votesCount = 0,
emojis, emojis,
} = poll; } = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry

View file

@ -40,13 +40,15 @@ const rtfFromNow = (date) => {
const seconds = (date.getTime() - Date.now()) / 1000; const seconds = (date.getTime() - Date.now()) / 1000;
const absSeconds = Math.abs(seconds); const absSeconds = Math.abs(seconds);
if (absSeconds < minute) { if (absSeconds < minute) {
return rtf.format(seconds, 'second'); return rtf.format(Math.floor(seconds), 'second');
} else if (absSeconds < hour) { } else if (absSeconds < hour) {
return rtf.format(Math.floor(seconds / minute), 'minute'); return rtf.format(Math.floor(seconds / minute), 'minute');
} else if (absSeconds < day) { } else if (absSeconds < day) {
return rtf.format(Math.floor(seconds / hour), 'hour'); return rtf.format(Math.floor(seconds / hour), 'hour');
} else { } else if (absSeconds < 30 * day) {
return rtf.format(Math.floor(seconds / day), 'day'); return rtf.format(Math.floor(seconds / day), 'day');
} else {
return rtf.format(Math.floor(seconds / day / 30), 'month');
} }
}; };
@ -76,7 +78,8 @@ export default function RelativeTime({ datetime, format }) {
const [renderCount, rerender] = useReducer((x) => x + 1, 0); const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const date = useMemo(() => new Date(datetime), [datetime]); const date = useMemo(() => new Date(datetime), [datetime]);
const [dateStr, dt, title] = useMemo(() => { const [dateStr, dt, title] = useMemo(() => {
if (!isValidDate(date)) return ['' + datetime, '', '']; if (!isValidDate(date))
return ['' + (typeof datetime === 'string' ? datetime : ''), '', ''];
let str; let str;
if (format === 'micro') { if (format === 'micro') {
// If date <= 1 day ago or day is within this year // If date <= 1 day ago or day is within this year

View file

@ -201,7 +201,7 @@ const PostContent =
} }
} }
divRef.current.replaceChildren(dom.cloneNode(true)); divRef.current.replaceChildren(dom.cloneNode(true));
}, [content, emojis.length]); }, [content, emojis?.length]);
return ( return (
<div <div
@ -358,7 +358,7 @@ function Status({
emojis: accountEmojis, emojis: accountEmojis,
bot, bot,
group, group,
}, } = {},
id, id,
repliesCount, repliesCount,
reblogged, reblogged,
@ -428,7 +428,7 @@ function Status({
return null; return null;
} }
console.debug('RENDER Status', id, status?.account.displayName, quoted); console.debug('RENDER Status', id, status?.account?.displayName, quoted);
const debugHover = (e) => { const debugHover = (e) => {
if (e.shiftKey) { if (e.shiftKey) {
@ -646,7 +646,7 @@ function Status({
[spoilerText, content], [spoilerText, content],
); );
const createdDateText = niceDateTime(createdAtDate); const createdDateText = createdAt && niceDateTime(createdAtDate);
const editedDateText = editedAt && niceDateTime(editedAtDate); const editedDateText = editedAt && niceDateTime(editedAtDate);
// Can boost if: // Can boost if:
@ -1792,6 +1792,7 @@ function Status({
</a> </a>
)} )}
<div class="container"> <div class="container">
{!!(status.account || createdAt) && (
<div class="meta"> <div class="meta">
<span class="meta-name"> <span class="meta-name">
<NameText <NameText
@ -1932,6 +1933,7 @@ function Status({
</span> </span>
))} ))}
</div> </div>
)}
{visibility === 'direct' && ( {visibility === 'direct' && (
<> <>
<div class="status-direct-badge"> <div class="status-direct-badge">

View file

@ -11,8 +11,9 @@
--main-width: 40em; --main-width: 40em;
text-size-adjust: none; text-size-adjust: none;
--hairline-width: 1px; --hairline-width: 1px;
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', --monospace-font:
Menlo, Courier, monospace; ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
monospace;
--blue-color: royalblue; --blue-color: royalblue;
--purple-color: blueviolet; --purple-color: blueviolet;
@ -190,8 +191,16 @@ html {
} }
body { body {
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, font-family:
Ubuntu, Cantarell, Noto Sans, sans-serif; ui-rounded,
-apple-system,
BlinkMacSystemFont,
Segoe UI,
Roboto,
Ubuntu,
Cantarell,
Noto Sans,
sans-serif;
font-size: var(--text-size); font-size: var(--text-size);
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -367,6 +376,7 @@ button[hidden] {
input[type='text'], input[type='text'],
input[type='search'], input[type='search'],
input[type='datetime-local'],
textarea, textarea,
select { select {
color: var(--text-color); color: var(--text-color);
@ -377,6 +387,7 @@ select {
} }
input[type='text']:focus, input[type='text']:focus,
input[type='search']:focus, input[type='search']:focus,
input[type='datetime-local']:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--outline-color); border-color: var(--outline-color);

428
src/locales/en.po generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
#scheduled-posts-page {
.posts-list {
list-style: none;
padding: 0;
margin: 0;
li {
> button {
text-align: start;
color: inherit;
padding: 16px;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--outline-color);
gap: 8px;
> .status {
padding: 8px;
pointer-events: none;
background-color: var(--bg-blur-color);
border-radius: 8px;
border: 1px solid var(--outline-color);
font-size: 80%;
max-height: 160px;
overflow: hidden;
mask-image: linear-gradient(
to bottom,
black calc(160px - 16px),
transparent
);
.media-container .media {
width: 80px !important;
height: 80px !important;
}
}
}
.post-schedule-meta {
display: flex;
align-items: center;
gap: 4px;
&.post-schedule-time {
.icon,
b {
color: var(--red-text-color);
}
}
&.post-schedule-month b {
opacity: 0.8;
}
}
}
h2 {
font-weight: 500;
margin: 0;
padding: 0;
font-size: 1em;
}
}
}
#scheduled-post-sheet {
header h2 {
font-weight: normal;
small {
font-size: var(--text-size);
}
}
main > .status {
background-color: var(--bg-blur-color);
border-radius: 8px;
border: 1px solid var(--outline-color);
overflow: auto;
max-height: 50svh;
.media-container .media {
width: 80px !important;
height: 80px !important;
}
}
.status-reply {
border-radius: 16px 16px 0 0;
max-height: 160px;
background-color: var(--bg-color);
margin: 0 12px;
border: 1px solid var(--outline-color);
border-bottom: 0;
-webkit-animation: appear-up 1sease-in-out;
animation: appear-up 1sease-in-out;
overflow: auto;
> .status {
font-size: 90%;
}
}
footer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
.row {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
}
input[type='datetime-local'] {
max-width: calc(100vw - 32px);
min-width: 0; /* Adding a min-width to prevent overflow */
&:user-invalid {
border-color: var(--red-color);
}
@supports not selector(:user-invalid) {
&:invalid {
border-color: var(--red-color);
}
}
}
.grow {
flex-grow: 1;
}
}
}

View file

@ -0,0 +1,376 @@
import './scheduled-posts.css';
import { Trans, useLingui } from '@lingui/react/macro';
import { MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useReducer, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import MenuConfirm from '../components/menu-confirm';
import Menu2 from '../components/menu2';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import ScheduledAtField, {
getLocalTimezoneName,
} from '../components/ScheduledAtField';
import Status from '../components/status';
import { api } from '../utils/api';
import niceDateTime from '../utils/nice-date-time';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 40;
export default function ScheduledPosts() {
const { t } = useLingui();
const snapStates = useSnapshot(states);
useTitle(t`Scheduled Posts`, '/sp');
const { masto } = api();
const [scheduledPosts, setScheduledPosts] = useState([]);
const [uiState, setUIState] = useState('default');
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const [showScheduledPostModal, setShowScheduledPostModal] = useState(false);
useEffect(reload, [snapStates.reloadScheduledPosts]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const postsIterator = masto.v1.scheduledStatuses.list({ limit: LIMIT });
const allPosts = [];
let posts;
do {
const result = await postsIterator.next();
posts = result.value;
if (posts?.length) {
allPosts.push(...posts);
}
} while (posts?.length);
setScheduledPosts(allPosts);
} catch (e) {
console.error(e);
setUIState('error');
} finally {
setUIState('default');
}
})();
}, [reloadCount]);
return (
<div id="scheduled-posts-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
</div>
<h1>
<Trans>Scheduled Posts</Trans>
</h1>
<div class="header-side">
<Menu2
portal
setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
<MenuItem
onClick={() => {
reload();
}}
>
<Icon icon="refresh" size="l" />
<span>
<Trans>Refresh</Trans>
</span>
</MenuItem>
</Menu2>
</div>
</div>
</header>
<main>
{!scheduledPosts.length ? (
<p class="ui-state">
{uiState === 'loading' ? <Loader /> : t`No scheduled posts.`}
</p>
) : (
<ul class="posts-list">
{scheduledPosts.map((post) => {
const { id, params, scheduledAt, mediaAttachments } = post;
const {
inReplyToId,
language,
poll,
sensitive,
spoilerText,
text,
visibility,
} = params;
const status = {
// account: account.info,
id,
inReplyToId,
language,
mediaAttachments,
poll: poll
? {
...poll,
expiresAt: new Date(Date.now() + poll.expiresIn * 1000),
options: poll.options.map((option) => ({
title: option,
votesCount: 0,
})),
}
: undefined,
sensitive,
spoilerText,
text,
visibility,
content: `<p>${text}</p>`,
// createdAt: scheduledAt,
};
return (
<li key={id}>
<ScheduledPostPreview
status={status}
scheduledAt={scheduledAt}
onClick={() => {
setShowScheduledPostModal({
post: status,
scheduledAt: new Date(scheduledAt),
});
}}
/>
</li>
);
})}
</ul>
)}
{showScheduledPostModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowScheduledPostModal(false);
}
}}
>
<ScheduledPostEdit
post={showScheduledPostModal.post}
scheduledAt={showScheduledPostModal.scheduledAt}
onClose={() => setShowScheduledPostModal(false)}
/>
</Modal>
)}
</main>
</div>
</div>
);
}
function ScheduledPostPreview({ status, scheduledAt, onClick }) {
// Look at scheduledAt, if it's months away, ICON = 'month'. If it's days away, ICON = 'day', else ICON = 'time'
const icon = useMemo(() => {
const hours =
(new Date(scheduledAt).getTime() - Date.now()) / (1000 * 60 * 60);
if (hours < 24) {
return 'time';
} else if (hours < 720) {
// 30 days
return 'day';
} else {
return 'month';
}
}, [scheduledAt]);
return (
<button type="button" class="textual block" onClick={onClick}>
<div class={`post-schedule-meta post-schedule-${icon}`}>
<Icon icon={icon} class="insignificant" />{' '}
<span>
<Trans comment="Scheduled [in 1 day] ([Thu, Feb 27, 6:30:00 PM])">
Scheduled{' '}
<b>
<RelativeTime datetime={scheduledAt} />
</b>{' '}
<small>
(
{niceDateTime(scheduledAt, {
formatOpts: {
weekday: 'short',
second: 'numeric',
},
})}
)
</small>
</Trans>
</span>
</div>
<Status status={status} size="s" previewMode readOnly />
</button>
);
}
function ScheduledPostEdit({ post, scheduledAt, onClose }) {
const { masto } = api();
const { t } = useLingui();
const [uiState, setUIState] = useState('default');
const [newScheduledAt, setNewScheduledAt] = useState();
const differentScheduledAt =
newScheduledAt && newScheduledAt.getTime() !== scheduledAt.getTime();
const localTZ = getLocalTimezoneName();
const pastSchedule = scheduledAt && scheduledAt <= Date.now();
const { inReplyToId } = post;
const [replyToStatus, setReplyToStatus] = useState(null);
// TODO: Uncomment this once https://github.com/mastodon/mastodon/issues/34000 is fixed
// useEffect(() => {
// if (inReplyToId) {
// (async () => {
// try {
// const status = await masto.v1.statuses.$select(inReplyToId).fetch();
// setReplyToStatus(status);
// } catch (e) {
// console.error(e);
// }
// })();
// }
// }, [inReplyToId]);
return (
<div id="scheduled-post-sheet" class="sheet">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" size="l" alt={t`Close`} />
</button>
<header>
<h2>
<Trans comment="Scheduled [in 1 day]">
Scheduled{' '}
<b>
<RelativeTime datetime={scheduledAt} />
</b>
</Trans>
<br />
<small>
{niceDateTime(scheduledAt, {
formatOpts: {
weekday: 'short',
second: 'numeric',
},
})}
</small>
</h2>
</header>
<main tabIndex="-1">
{!!replyToStatus && (
<div class="status-reply">
<Status status={replyToStatus} size="s" previewMode readOnly />
</div>
)}
<Status
status={post}
size="s"
previewMode
readOnly
onMediaClick={(e, i, media, status) => {
e.preventDefault();
states.showMediaModal = {
mediaAttachments: post.mediaAttachments,
mediaIndex: i,
};
}}
/>
<form
onSubmit={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
try {
await masto.v1.scheduledStatuses.$select(post.id).update({
scheduledAt: newScheduledAt.toISOString(),
});
showToast(t`Scheduled post rescheduled`);
onClose();
setUIState('default');
states.reloadScheduledPosts++;
} catch (e) {
setUIState('error');
console.error(e);
showToast(t`Failed to reschedule post`);
}
})();
}}
>
<footer>
<div class="row">
<span>
<ScheduledAtField
scheduledAt={scheduledAt}
setScheduledAt={(date) => {
setNewScheduledAt(date);
}}
/>{' '}
<small class="ib">{localTZ}</small>
</span>
</div>
<div class="row">
<button
disabled={
!differentScheduledAt || uiState === 'loading' || pastSchedule
}
>
<Trans>Reschedule</Trans>
</button>
<span class="grow" />
<MenuConfirm
align="end"
menuItemClassName="danger"
confirmLabel={t`Delete scheduled post?`}
onClick={() => {
setUIState('loading');
(async () => {
try {
await api()
.masto.v1.scheduledStatuses.$select(post.id)
.remove();
showToast(t`Scheduled post deleted`);
onClose();
setUIState('default');
states.reloadScheduledPosts++;
} catch (e) {
setUIState('error');
console.error(e);
showToast(t`Failed to delete scheduled post`);
}
})();
}}
>
<button
type="button"
class="light danger"
disabled={uiState === 'loading' || pastSchedule}
>
<Trans>Delete</Trans>
</button>
</MenuConfirm>
</div>
</footer>
</form>
</main>
</div>
);
}

View file

@ -31,6 +31,7 @@ const states = proxy({
id: null, id: null,
counter: 0, counter: 0,
}, },
reloadScheduledPosts: 0,
spoilers: {}, spoilers: {},
spoilersMedia: {}, spoilersMedia: {},
scrollPositions: {}, scrollPositions: {},