diff --git a/components/publish/PublishWidget.vue b/components/publish/PublishWidget.vue index 00bfde5090..9518f05c5a 100644 --- a/components/publish/PublishWidget.vue +++ b/components/publish/PublishWidget.vue @@ -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 { @@ -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() || '') @@ -256,11 +290,11 @@ onDeactivated(() => {
- + + + + +
@@ -469,14 +529,14 @@ onDeactivated(() => { - + @@ -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" > @@ -496,6 +556,7 @@ onDeactivated(() => {
{{ $t('action.save_changes') }} + {{ !isSending ? $t('action.schedule') : $t('state.scheduling') }} {{ $t('action.reply') }} {{ !isSending ? $t('action.publish') : $t('state.publishing') }} @@ -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; } @@ -530,4 +593,8 @@ onDeactivated(() => { align-items: center; border-radius: 50%; } + + input[name="schedule-datetime"]:invalid { + color: var(--c-danger); + } diff --git a/composables/masto/publish.ts b/composables/masto/publish.ts index ae1f6020e0..06f6001f80 100644 --- a/composables/masto/publish.ts +++ b/composables/masto/publish.ts @@ -59,7 +59,7 @@ export function usePublish(options: { failedMessages.value.length = 0 }, { deep: true }) - async function publishDraft() { + async function publishDraft(): Promise { if (isPublishDisabled.value) return @@ -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 @@ -83,6 +82,10 @@ 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, @@ -90,8 +93,9 @@ export function usePublish(options: { 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 @@ -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, @@ -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) { diff --git a/composables/masto/statusDrafts.ts b/composables/masto/statusDrafts.ts index 36e10b1e51..e5b1d19601 100644 --- a/composables/masto/statusDrafts.ts +++ b/composables/masto/statusDrafts.ts @@ -25,7 +25,7 @@ function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) { : preferredVisibility } -export function getDefaultDraft(options: Partial & Omit> = {}): Draft { +export function getDefaultDraft(options: Partial & Omit> = {}): Draft { const { attachments = [], initialText = '', @@ -37,6 +37,7 @@ export function getDefaultDraft(options: Partial['t'] export interface Draft { editingStatus?: mastodon.v1.Status initialText?: string - params: MarkNonNullable>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable } + params: MarkNonNullable>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable } attachments: mastodon.v1.MediaAttachment[] lastUpdated: number mentions?: string[]