Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce scheduled post feature #2643

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 73 additions & 6 deletions components/publish/PublishWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { EditorContent } from '@tiptap/vue-3'
import stringLength from 'string-length'
import type { mastodon } from 'masto'
import { useNow } from '@vueuse/core'
import type { Draft } from '~/types'

const {
Expand Down Expand Up @@ -116,6 +117,39 @@ const expiresInOptions = computed(() => [

const expiresInDefaultOptionIndex = 2

const scheduledTime = ref('')
const now = useNow({ interval: 1000 })
const minimumScheduledTime = computed(() => getMinimumScheduledTime(now.value))

const isValidScheduledTime = computed(() => {
if (scheduledTime.value === '')
return true

const scheduledTimeDate = new Date(scheduledTime.value)
return minimumScheduledTime.value.getTime() <= scheduledTimeDate.getTime()
})

watchEffect(() => {
draft.value.params.scheduledAt = scheduledTime.value
})

// Calculate the minimum scheduled time.
// Mastodon API allows to set the scheduled time to 5 minutes in the future
// but if the specified scheduled time is less than 5 minutes, Mastodon will
// send the post immediately.
// To prevent this, we add a buffer and round up the minutes.
function getMinimumScheduledTime(now: Date): Date {
const bufferInSec = 5 + 5 * 60 // + 5 minutes and 5 seconds
const nowInSec = Math.floor(now.getTime() / 1000)
const bufferedTimeInSec
= Math.ceil((nowInSec + bufferInSec) / 60) * 60
return new Date(bufferedTimeInSec * 1000)
}

function getDatetimeInputFormat(time: Date) {
return time.toISOString().slice(0, 16)
}

const characterCount = computed(() => {
const text = htmlToText(editor.value?.getHTML() || '')

Expand Down Expand Up @@ -256,11 +290,11 @@ onDeactivated(() => {
<header id="publish-failed" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t('state.publish_failed') }}</p>
<p>{{ scheduledTime ? $t('state.schedule_failed') : $t('state.publish_failed') }}</p>
</div>
<CommonTooltip placement="bottom" :content="$t('action.clear_publish_failed')">
<CommonTooltip placement="bottom" :content="scheduledTime ? $t('action.clear_schedule_failed') : $t('action.clear_publish_failed')">
<button
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('action.clear_publish_failed')"
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="scheduledTime ? $t('action.clear_schedule_failed') : $t('action.clear_publish_failed')"
@click="failedMessages = []"
>
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
Expand All @@ -275,6 +309,15 @@ onDeactivated(() => {
</ol>
</CommonErrorMessage>

<CommonErrorMessage v-if="!isValidScheduledTime" described-by="scheduled-time-invalid" pt-2>
<header id="scheduled-time-invalid" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t('state.schedule_time_invalid', [minimumScheduledTime.toLocaleString()]) }}</p>
</div>
</header>
</CommonErrorMessage>

<div relative flex-1 flex flex-col>
<EditorContent
:editor="editor"
Expand Down Expand Up @@ -435,6 +478,23 @@ onDeactivated(() => {

<PublishEditorTools v-if="editor" :editor="editor" />

<CommonDropdown placement="bottom">
<CommonTooltip placement="top" :content="$t('tooltip.schedule_post')" no-auto-focus>
<button btn-action-icon :aria-label="$t('tooltip.schedule_post')">
<div i-ri:calendar-schedule-line :class="scheduledTime !== '' ? 'text-primary' : ''" />
</button>
</CommonTooltip>
<template #popper>
<input
v-model="scheduledTime"
p2
type="datetime-local"
name="schedule-datetime"
:min="getDatetimeInputFormat(minimumScheduledTime)"
>
</template>
</CommonDropdown>

<div flex-auto />

<PublishCharacterCounter :max="characterLimit" :length="characterCount" />
Expand Down Expand Up @@ -469,14 +529,14 @@ onDeactivated(() => {
</template>
</PublishVisibilityPicker>

<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')" no-auto-focus>
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="scheduledTime ? $t('state.schedule_failed') : $t('tooltip.publish_failed')" no-auto-focus>
<button
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
>
<span block>
<div block i-carbon:face-dizzy-filled />
</span>
<span>{{ $t('state.publish_failed') }}</span>
<span>{{ scheduledTime ? $t('state.schedule_failed') : $t('state.publish_failed') }}</span>
</button>
</CommonTooltip>

Expand All @@ -485,7 +545,7 @@ onDeactivated(() => {
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
md:w-fit
class="publish-button"
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit"
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit || !isValidScheduledTime"
aria-describedby="publish-tooltip"
@click="publish"
>
Expand All @@ -496,6 +556,7 @@ onDeactivated(() => {
<div block i-carbon:face-dizzy-filled />
</span>
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
<span v-else-if="scheduledTime">{{ !isSending ? $t('action.schedule') : $t('state.scheduling') }}</span>
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
</button>
Expand All @@ -512,10 +573,12 @@ onDeactivated(() => {
background-color: var(--c-bg-btn-disabled);
color: var(--c-text-btn-disabled);
}

.publish-button[aria-disabled=true]:hover {
background-color: var(--c-bg-btn-disabled);
color: var(--c-text-btn-disabled);
}

.option-input:focus + .delete-button {
display: none;
}
Expand All @@ -530,4 +593,8 @@ onDeactivated(() => {
align-items: center;
border-radius: 50%;
}

input[name="schedule-datetime"]:invalid {
color: var(--c-danger);
}
</style>
17 changes: 13 additions & 4 deletions composables/masto/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function usePublish(options: {
failedMessages.value.length = 0
}, { deep: true })

async function publishDraft() {
async function publishDraft(): Promise<mastodon.v1.Status | undefined> {
if (isPublishDisabled.value)
return

Expand All @@ -68,7 +68,6 @@ export function usePublish(options: {
content = `${draft.value.mentions.map(i => `@${i}`).join(' ')} ${content}`

let poll

if (draft.value.params.poll) {
let options = draft.value.params.poll.options

Expand All @@ -83,15 +82,20 @@ export function usePublish(options: {
poll = { ...draft.value.params.poll, options }
}

let scheduledAt
if (draft.value.params.scheduledAt)
scheduledAt = new Date(draft.value.params.scheduledAt).toISOString()

const payload = {
...draft.value.params,
spoilerText: publishSpoilerText.value,
status: content,
mediaIds: draft.value.attachments.map(a => a.id),
language: draft.value.params.language || preferredLanguage.value,
poll,
scheduledAt,
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
} as mastodon.rest.v1.CreateStatusParams
} as mastodon.rest.v1.CreateScheduledStatusParams

if (import.meta.dev) {
// eslint-disable-next-line no-console
Expand All @@ -112,7 +116,6 @@ export function usePublish(options: {
if (!draft.value.editingStatus) {
status = await client.value.v1.statuses.create(payload)
}

else {
status = await client.value.v1.statuses.$select(draft.value.editingStatus.id).update({
...payload,
Expand All @@ -127,6 +130,12 @@ export function usePublish(options: {

draft.value = options.initialDraft.value()

if ('scheduled_at' in status)
// When created a scheduled post, it returns `mastodon.v1.ScheduledStatus` instead
// We want to return only Status, which will be used to route to the posted status page
// ref. Mastodon documentation - https://docs.joinmastodon.org/methods/statuses/#create
return

return status
}
catch (err) {
Expand Down
4 changes: 3 additions & 1 deletion composables/masto/statusDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) {
: preferredVisibility
}

export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.CreateScheduledStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
const {
attachments = [],
initialText = '',
Expand All @@ -37,6 +37,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.Create
language,
mentions,
poll,
scheduledAt,
} = options

return {
Expand All @@ -45,6 +46,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.Create
params: {
status: status || '',
poll,
scheduledAt,
inReplyToId,
visibility: getDefaultVisibility(visibility || 'public'),
sensitive: sensitive ?? false,
Expand Down
7 changes: 7 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"boosted": "Boosted",
"clear_publish_failed": "Clear publish errors",
"clear_save_failed": "Clear save errors",
"clear_schedule_failed": "Clear schedule errors",
"clear_upload_failed": "Clear file upload errors",
"close": "Close",
"compose": "Compose",
Expand All @@ -79,6 +80,7 @@
"reset": "Reset",
"save": "Save",
"save_changes": "Save changes",
"schedule": "Schedule",
"sign_in": "Sign in",
"sign_in_to": "Sign in to {0}",
"switch_account": "Switch account",
Expand Down Expand Up @@ -598,6 +600,9 @@
"publish_failed": "Publish failed",
"publishing": "Publishing",
"save_failed": "Save failed",
"schedule_failed": "Schedule failed",
"schedule_time_invalid": "The scheduled time must be at least 5 minutes later in the future. Set to {0} or later.",
"scheduling": "Scheduling",
"upload_failed": "Upload failed",
"uploading": "Uploading..."
},
Expand Down Expand Up @@ -719,6 +724,8 @@
"open_editor_tools": "Editor tools",
"pick_an_icon": "Pick an icon",
"publish_failed": "Close failed messages at the top of editor to republish posts",
"schedule_failed": "Close failed messages at the top of editor to reschedule posts",
"schedule_post": "Schedule post",
"toggle_bold": "Toggle bold",
"toggle_code_block": "Toggle code block",
"toggle_italic": "Toggle italic"
Expand Down
2 changes: 1 addition & 1 deletion types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type TranslateFn = ReturnType<typeof useI18n>['t']
export interface Draft {
editingStatus?: mastodon.v1.Status
initialText?: string
params: MarkNonNullable<Mutable<Omit<mastodon.rest.v1.CreateStatusParams, 'poll'>>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable<mastodon.rest.v1.CreateStatusParams['poll']> }
params: MarkNonNullable<Mutable<Omit<mastodon.rest.v1.CreateScheduledStatusParams, 'poll'>>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable<mastodon.rest.v1.CreateScheduledStatusParams['poll']> }
attachments: mastodon.v1.MediaAttachment[]
lastUpdated: number
mentions?: string[]
Expand Down