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 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 }) {
<Route path=":id" element={<List />} />
</Route>
<Route path="/fh" element={<FollowedHashtags />} />
<Route path="/sp" element={<ScheduledPosts />} />
<Route path="/ft" element={<Filters />} />
<Route path="/catchup" element={<Catchup />} />
<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'),
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'),
};

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);
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 > * {

View file

@ -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 (
<div id="compose-container-outer">
<div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -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 && (
<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">
<span class="add-toolbar-button-group spacer">
{showAddButton && (
@ -1418,12 +1468,23 @@ function Compose({
<span>{_(ADD_LABELS.gif)}</span>
</MenuItem>
)}
<MenuItem
disabled={pollButtonDisabled}
onClick={onPollButtonClick}
>
<Icon icon="poll" /> <span>{_(ADD_LABELS.poll)}</span>
</MenuItem>
{showPollButton && (
<MenuItem
disabled={pollButtonDisabled}
onClick={onPollButtonClick}
>
<Icon icon="poll" /> <span>{_(ADD_LABELS.poll)}</span>
</MenuItem>
)}
{showScheduledAt && (
<MenuItem
disabled={scheduledAtButtonDisabled}
onClick={onScheduledAtClick}
>
<Icon icon="schedule" />{' '}
<span>{_(ADD_LABELS.scheduledPost)}</span>
</MenuItem>
)}
</Menu2>
)}
<span class="add-sub-toolbar-button-group" ref={addSubToolbarRef}>
@ -1476,7 +1537,6 @@ function Compose({
/>
</button>
)}
{}
{showPollButton && (
<>
<button
@ -1489,6 +1549,16 @@ function Compose({
</button>
</>
)}
{showScheduledAt && (
<button
type="button"
class={`toolbar-button ${scheduledAt ? 'highlight' : ''}`}
disabled={scheduledAtButtonDisabled}
onClick={onScheduledAtClick}
>
<Icon icon="schedule" alt={_(ADD_LABELS.scheduledPost)} />
</button>
)}
</span>
</span>
{/* <div class="spacer" /> */}
@ -1551,14 +1621,16 @@ function Compose({
</select>
</label>{' '}
<button type="submit" disabled={uiState === 'loading'}>
{replyToStatus
? t`Reply`
: editStatus
? t`Update`
: t({
message: 'Post',
context: 'Submit button in composer',
})}
{scheduledAt
? t`Schedule`
: replyToStatus
? t`Reply`
: editStatus
? t`Update`
: t({
message: 'Post',
context: 'Submit button in composer',
})}
</button>
</div>
</form>

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@ export default function Poll({
ownVotes,
voted,
votersCount,
votesCount,
votesCount = 0,
emojis,
} = poll;
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 absSeconds = Math.abs(seconds);
if (absSeconds < minute) {
return rtf.format(seconds, 'second');
return rtf.format(Math.floor(seconds), 'second');
} else if (absSeconds < hour) {
return rtf.format(Math.floor(seconds / minute), 'minute');
} else if (absSeconds < day) {
return rtf.format(Math.floor(seconds / hour), 'hour');
} else {
} else if (absSeconds < 30 * 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 date = useMemo(() => new Date(datetime), [datetime]);
const [dateStr, dt, title] = useMemo(() => {
if (!isValidDate(date)) return ['' + datetime, '', ''];
if (!isValidDate(date))
return ['' + (typeof datetime === 'string' ? datetime : ''), '', ''];
let str;
if (format === 'micro') {
// 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));
}, [content, emojis.length]);
}, [content, emojis?.length]);
return (
<div
@ -358,7 +358,7 @@ function Status({
emojis: accountEmojis,
bot,
group,
},
} = {},
id,
repliesCount,
reblogged,
@ -428,7 +428,7 @@ function Status({
return null;
}
console.debug('RENDER Status', id, status?.account.displayName, quoted);
console.debug('RENDER Status', id, status?.account?.displayName, quoted);
const debugHover = (e) => {
if (e.shiftKey) {
@ -646,7 +646,7 @@ function Status({
[spoilerText, content],
);
const createdDateText = niceDateTime(createdAtDate);
const createdDateText = createdAt && niceDateTime(createdAtDate);
const editedDateText = editedAt && niceDateTime(editedAtDate);
// Can boost if:
@ -1792,146 +1792,148 @@ function Status({
</a>
)}
<div class="container">
<div class="meta">
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} instance={instance} short />
</span>
</>
)} */}
{/* </span> */}{' '}
{size !== 'l' &&
(_deleted ? (
<span class="status-deleted-tag">
<Trans>Deleted</Trans>
</span>
) : url && !previewMode && !readOnly && !quoted ? (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.which === 2
) {
return;
}
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
setContextMenuProps({
anchorRef: {
current: e.currentTarget,
},
align: 'end',
direction: 'bottom',
gap: 4,
});
setIsContextMenuOpen(true);
}}
class={`time ${
isContextMenuOpen && contextMenuProps?.anchorRef
? 'is-open'
: ''
}`}
>
{showCommentHint && !showCommentCount ? (
<Icon
icon="comment2"
size="s"
// alt={`${repliesCount} ${
// repliesCount === 1 ? 'reply' : 'replies'
// }`}
alt={plural(repliesCount, {
one: '# reply',
other: '# replies',
})}
/>
) : (
visibility !== 'public' &&
visibility !== 'direct' && (
{!!(status.account || createdAt) && (
<div class="meta">
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} instance={instance} short />
</span>
</>
)} */}
{/* </span> */}{' '}
{size !== 'l' &&
(_deleted ? (
<span class="status-deleted-tag">
<Trans>Deleted</Trans>
</span>
) : url && !previewMode && !readOnly && !quoted ? (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.which === 2
) {
return;
}
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
setContextMenuProps({
anchorRef: {
current: e.currentTarget,
},
align: 'end',
direction: 'bottom',
gap: 4,
});
setIsContextMenuOpen(true);
}}
class={`time ${
isContextMenuOpen && contextMenuProps?.anchorRef
? 'is-open'
: ''
}`}
>
{showCommentHint && !showCommentCount ? (
<Icon
icon={visibilityIconsMap[visibility]}
alt={_(visibilityText[visibility])}
icon="comment2"
size="s"
// alt={`${repliesCount} ${
// repliesCount === 1 ? 'reply' : 'replies'
// }`}
alt={plural(repliesCount, {
one: '# reply',
other: '# replies',
})}
/>
)
)}{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
{!previewMode && !readOnly && (
<Icon icon="more2" class="more" alt={t`More`} />
)}
</Link>
) : (
// <Menu
// instanceRef={menuInstanceRef}
// portal={{
// target: document.body,
// }}
// containerProps={{
// style: {
// // Higher than the backdrop
// zIndex: 1001,
// },
// onClick: (e) => {
// if (e.target === e.currentTarget)
// menuInstanceRef.current?.closeMenu?.();
// },
// }}
// align="end"
// gap={4}
// overflow="auto"
// viewScroll="close"
// boundingBoxPadding="8 8 8 8"
// unmountOnClose
// menuButton={({ open }) => (
// <Link
// to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// onStatusLinkClick?.(e, status);
// }}
// class={`time ${open ? 'is-open' : ''}`}
// >
// <Icon
// icon={visibilityIconsMap[visibility]}
// alt={visibilityText[visibility]}
// size="s"
// />{' '}
// <RelativeTime datetime={createdAtDate} format="micro" />
// </Link>
// )}
// >
// {StatusMenuItems}
// </Menu>
<span class="time">
{visibility !== 'public' && visibility !== 'direct' && (
<>
<Icon
icon={visibilityIconsMap[visibility]}
alt={_(visibilityText[visibility])}
size="s"
/>{' '}
</>
)}
<RelativeTime datetime={createdAtDate} format="micro" />
</span>
))}
</div>
) : (
visibility !== 'public' &&
visibility !== 'direct' && (
<Icon
icon={visibilityIconsMap[visibility]}
alt={_(visibilityText[visibility])}
size="s"
/>
)
)}{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
{!previewMode && !readOnly && (
<Icon icon="more2" class="more" alt={t`More`} />
)}
</Link>
) : (
// <Menu
// instanceRef={menuInstanceRef}
// portal={{
// target: document.body,
// }}
// containerProps={{
// style: {
// // Higher than the backdrop
// zIndex: 1001,
// },
// onClick: (e) => {
// if (e.target === e.currentTarget)
// menuInstanceRef.current?.closeMenu?.();
// },
// }}
// align="end"
// gap={4}
// overflow="auto"
// viewScroll="close"
// boundingBoxPadding="8 8 8 8"
// unmountOnClose
// menuButton={({ open }) => (
// <Link
// to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// onStatusLinkClick?.(e, status);
// }}
// class={`time ${open ? 'is-open' : ''}`}
// >
// <Icon
// icon={visibilityIconsMap[visibility]}
// alt={visibilityText[visibility]}
// size="s"
// />{' '}
// <RelativeTime datetime={createdAtDate} format="micro" />
// </Link>
// )}
// >
// {StatusMenuItems}
// </Menu>
<span class="time">
{visibility !== 'public' && visibility !== 'direct' && (
<>
<Icon
icon={visibilityIconsMap[visibility]}
alt={_(visibilityText[visibility])}
size="s"
/>{' '}
</>
)}
<RelativeTime datetime={createdAtDate} format="micro" />
</span>
))}
</div>
)}
{visibility === 'direct' && (
<>
<div class="status-direct-badge">

View file

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