diff options
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkNoteDraftsDialog.vue | 261 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotification.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 121 | ||||
| -rw-r--r-- | packages/frontend/src/os.ts | 8 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.editor.vue | 21 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.vue | 7 |
6 files changed, 323 insertions, 118 deletions
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue index 5b8211b715..3f0a5a5247 100644 --- a/packages/frontend/src/components/MkNoteDraftsDialog.vue +++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue @@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only @esc="cancel()" > <template #header> - {{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }}) + {{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }}) </template> - <div class="_spacer"> - <MkPagination :paginator="paginator" withControl> - <template #empty> - <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/> - </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div - v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])" - :key="draft.id" - v-panel - :class="[$style.draft]" - > - <div :class="$style.draftBody" class="_gaps_s"> - <div :class="$style.draftInfo"> - <div :class="$style.draftMeta"> - <div v-if="draft.reply" class="_nowrap"> - <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> - <template #user> - <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/> - <MkAcct v-else :user="draft.reply.user"/> - </template> - </I18n> - </div> - <div v-else-if="draft.replyId" class="_nowrap"> - <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> - <template #user> - {{ i18n.ts.deletedNote }} - </template> - </I18n> - </div> - <div v-if="draft.renote && draft.text != null" class="_nowrap"> - <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> - <template #user> - <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/> - <MkAcct v-else :user="draft.renote.user"/> - </template> - </I18n> - </div> - <div v-else-if="draft.renoteId" class="_nowrap"> - <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> - <template #user> - {{ i18n.ts.deletedNote }} - </template> - </I18n> + <MkStickyContainer> + <template #header> + <MkTabs + v-model:tab="tab" + centered + :class="$style.tabs" + :tabs="[ + { + key: 'drafts', + title: i18n.ts.drafts, + icon: 'ti ti-pencil-question', + }, + { + key: 'scheduled', + title: i18n.ts.scheduled, + icon: 'ti ti-calendar-clock', + }, + ]" + /> + </template> + + <div class="_spacer"> + <MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl> + <template #empty> + <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div + v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])" + :key="draft.id" + v-panel + :class="[$style.draft]" + > + <div :class="$style.draftBody" class="_gaps_s"> + <MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> + <I18n :src="i18n.ts.scheduledToPostOnX" tag="span"> + <template #x> + <MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/> + </template> + </I18n> + </MkInfo> + <div :class="$style.draftInfo"> + <div :class="$style.draftMeta"> + <div v-if="draft.reply" class="_nowrap"> + <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> + <template #user> + <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/> + <MkAcct v-else :user="draft.reply.user"/> + </template> + </I18n> + </div> + <div v-else-if="draft.replyId" class="_nowrap"> + <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span"> + <template #user> + {{ i18n.ts.deletedNote }} + </template> + </I18n> + </div> + <div v-if="draft.renote && draft.text != null" class="_nowrap"> + <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> + <template #user> + <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/> + <MkAcct v-else :user="draft.renote.user"/> + </template> + </I18n> + </div> + <div v-else-if="draft.renoteId" class="_nowrap"> + <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span"> + <template #user> + {{ i18n.ts.deletedNote }} + </template> + </I18n> + </div> + <div v-if="draft.channel" class="_nowrap"> + <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }} + </div> </div> - <div v-if="draft.channel" class="_nowrap"> - <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }} + </div> + <div :class="$style.draftContent"> + <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/> + </div> + <div :class="$style.draftFooter"> + <div :class="$style.draftVisibility"> + <span :title="i18n.ts._visibility[draft.visibility]"> + <i v-if="draft.visibility === 'public'" class="ti ti-world"></i> + <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i> + </span> + <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> + <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/> </div> </div> - <div :class="$style.draftContent"> - <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/> - </div> - <div :class="$style.draftFooter"> - <div :class="$style.draftVisibility"> - <span :title="i18n.ts._visibility[draft.visibility]"> - <i v-if="draft.visibility === 'public'" class="ti ti-world"></i> - <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i> - <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i> - <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i> - </span> - <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> - </div> - <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/> + + <div :class="$style.draftActions" class="_buttons"> + <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> + <MkButton + :class="$style.itemButton" + small + @click="cancelSchedule(draft)" + > + <i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }} + </MkButton> + <!-- TODO + <MkButton + :class="$style.itemButton" + small + @click="reSchedule(draft)" + > + <i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }} + </MkButton> + --> + </template> + <MkButton + v-else + :class="$style.itemButton" + small + @click="restoreDraft(draft)" + > + <i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }} + </MkButton> + <MkButton + v-tooltip="i18n.ts._drafts.delete" + danger + small + :iconOnly="true" + :class="$style.itemButton" + style="margin-left: auto;" + @click="deleteDraft(draft)" + > + <i class="ti ti-trash"></i> + </MkButton> </div> </div> - <div :class="$style.draftActions" class="_buttons"> - <MkButton - :class="$style.itemButton" - small - @click="restoreDraft(draft)" - > - <i class="ti ti-corner-up-left"></i> - {{ i18n.ts._drafts.restore }} - </MkButton> - <MkButton - v-tooltip="i18n.ts._drafts.delete" - danger - small - :iconOnly="true" - :class="$style.itemButton" - @click="deleteDraft(draft)" - > - <i class="ti ti-trash"></i> - </MkButton> - </div> </div> - </div> - </template> - </MkPagination> - </div> + </template> + </MkPagination> + </div> + </MkStickyContainer> </MkModalWindow> </template> @@ -125,6 +175,12 @@ import * as os from '@/os.js'; import { $i } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api'; import { Paginator } from '@/utility/paginator.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const props = defineProps<{ + scheduled?: boolean; +}>(); const emit = defineEmits<{ (ev: 'restore', draft: Misskey.entities.NoteDraft): void; @@ -132,8 +188,20 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const paginator = markRaw(new Paginator('notes/drafts/list', { +const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts'); + +const draftsPaginator = markRaw(new Paginator('notes/drafts/list', { + limit: 10, + params: { + scheduled: false, + }, +})); + +const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', { limit: 10, + params: { + scheduled: true, + }, })); const currentDraftsCount = ref(0); @@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) { if (canceled) return; os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => { - paginator.reload(); + draftsPaginator.reload(); + }); +} + +async function cancelSchedule(draft: Misskey.entities.NoteDraft) { + os.apiWithDialog('notes/drafts/update', { + draftId: draft.id, + isActuallyScheduled: false, + scheduledAt: null, + }).then(() => { + scheduledPaginator.reload(); }); } </script> @@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) { padding-top: 16px; border-top: solid 1px var(--MI_THEME-divider); } + +.tabs { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 21104b41df..45a74e3f02 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> @@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_mention]: notification.type === 'mention', [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', + [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', + [$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed', [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', @@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> + <i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i> + <i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> @@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tail"> <header :class="$style.header"> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> + <span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span> + <span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span> @@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> <i class="ti ti-quote" :class="$style.quote"></i> </MkA> + <MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ti ti-quote" :class="$style.quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> + <i class="ti ti-quote" :class="$style.quote"></i> + </MkA> <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> {{ notification.role.name }} </div> @@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_scheduledNotePosted { + background: var(--eventOther); + pointer-events: none; +} + +.t_scheduledNotePostFailed { + background: var(--eventOther); + pointer-events: none; +} + .t_achievementEarned { background: var(--eventAchievement); pointer-events: none; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 17f93a4ec8..c1b950a6c8 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.headerLeft"> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> <button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu"> - <MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/> + <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/> </button> - <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button> + <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button> </div> <div :class="$style.headerRight"> <template v-if="!(targetChannel != null && fixed)"> @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="posted"></template> <template v-else-if="posting"><MkEllipsis/></template> <template v-else>{{ submitText }}</template> - <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i> + <i style="margin-left: 6px;" :class="submitIcon"></i> </div> </button> </div> @@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> </div> + <MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt"> + <I18n :src="i18n.ts.scheduleToPostOnX" tag="span"> + <template #x> + <MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/> + </template> + </I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button> + </MkInfo> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <div v-show="useCw" :class="$style.cwOuter"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> @@ -199,6 +206,7 @@ if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } const reactionAcceptance = ref(store.s.reactionAcceptance); +const scheduledAt = ref<number | null>(null); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -262,11 +270,17 @@ const placeholder = computed((): string => { }); const submitText = computed((): string => { - return renoteTargetNote.value - ? i18n.ts.quote - : replyTargetNote.value - ? i18n.ts.reply - : i18n.ts.note; + return scheduledAt.value != null + ? i18n.ts.schedule + : renoteTargetNote.value + ? i18n.ts.quote + : replyTargetNote.value + ? i18n.ts.reply + : i18n.ts.note; +}); + +const submitIcon = computed((): string => { + return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send'; }); const textLength = computed((): number => { @@ -414,6 +428,7 @@ function watchForDraft() { watch(localOnly, () => saveDraft()); watch(quoteId, () => saveDraft()); watch(reactionAcceptance, () => saveDraft()); + watch(scheduledAt, () => saveDraft()); } function checkMissingMention() { @@ -605,7 +620,13 @@ function showOtherSettings() { action: () => { toggleReactionAcceptance(); }, - }, { type: 'divider' }, { + }, ...($i.policies.scheduledNoteLimit > 0 ? [{ + icon: 'ti ti-calendar-time', + text: i18n.ts.schedulePost + '...', + action: () => { + schedule(); + }, + }] : []), { type: 'divider' }, { type: 'switch', icon: 'ti ti-eye', text: i18n.ts.preview, @@ -654,6 +675,7 @@ function clear() { files.value = []; poll.value = null; quoteId.value = null; + scheduledAt.value = null; } function onKeydown(ev: KeyboardEvent) { @@ -809,6 +831,7 @@ function saveDraft() { ...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), quoteId: quoteId.value, reactionAcceptance: reactionAcceptance.value, + scheduledAt: scheduledAt.value, }, }; @@ -823,7 +846,9 @@ function deleteDraft() { miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } -async function saveServerDraft(clearLocal = false) { +async function saveServerDraft(options: { + isActuallyScheduled?: boolean; +} = {}) { return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', { ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }), text: text.value, @@ -831,19 +856,15 @@ async function saveServerDraft(clearLocal = false) { visibility: visibility.value, localOnly: localOnly.value, hashtag: hashtags.value, - ...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}), + fileIds: files.value.map(f => f.id), poll: poll.value, - ...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), - renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined, - replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined, - channelId: targetChannel.value ? targetChannel.value.id : undefined, + visibleUserIds: visibleUsers.value.map(x => x.id), + renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null, + replyId: replyTargetNote.value ? replyTargetNote.value.id : null, + channelId: targetChannel.value ? targetChannel.value.id : null, reactionAcceptance: reactionAcceptance.value, - }).then(() => { - if (clearLocal) { - clear(); - deleteDraft(); - } - }).catch((err) => { + scheduledAt: scheduledAt.value, + isActuallyScheduled: options.isActuallyScheduled ?? false, }); } @@ -878,6 +899,21 @@ async function post(ev?: MouseEvent) { } } + if (scheduledAt.value != null) { + if (uploader.items.value.some(x => x.uploaded == null)) { + await uploadFiles(); + + // アップロード失敗したものがあったら中止 + if (uploader.items.value.some(x => x.uploaded == null)) { + return; + } + } + + await postAsScheduled(); + clear(); + return; + } + if (props.mock) return; if (visibility.value === 'public' && ( @@ -1049,6 +1085,14 @@ async function post(ev?: MouseEvent) { }); } +async function postAsScheduled() { + if (props.mock) return; + + await saveServerDraft({ + isActuallyScheduled: true, + }); +} + function cancel() { emit('cancel'); } @@ -1143,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) } function showDraftMenu(ev: MouseEvent) { - function showDraftsDialog() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, { + function showDraftsDialog(scheduled: boolean) { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), { + scheduled, + }, { restore: async (draft: Misskey.entities.NoteDraft) => { text.value = draft.text ?? ''; useCw.value = draft.cw != null; @@ -1175,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) { renoteTargetNote.value = draft.renote; replyTargetNote.value = draft.reply; reactionAcceptance.value = draft.reactionAcceptance; + scheduledAt.value = draft.scheduledAt ?? null; if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel; visibleUsers.value = []; @@ -1215,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) { text: i18n.ts._drafts.listDrafts, icon: 'ti ti-cloud-download', action: () => { - showDraftsDialog(); + showDraftsDialog(false); + }, + }, { type: 'divider' }, { + type: 'button', + text: i18n.ts._drafts.listScheduledNotes, + icon: 'ti ti-clock-down', + action: () => { + showDraftsDialog(true); }, }], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } +async function schedule() { + const { canceled, result } = await os.inputDatetime({ + title: i18n.ts.schedulePost, + }); + if (canceled) return; + if (result.getTime() <= Date.now()) return; + + scheduledAt.value = result.getTime(); +} + +function cancelSchedule() { + scheduledAt.value = null; +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1255,6 +1323,7 @@ onMounted(() => { } quoteId.value = draft.data.quoteId; reactionAcceptance.value = draft.data.reactionAcceptance; + scheduledAt.value = draft.data.scheduledAt ?? null; } } @@ -1519,6 +1588,10 @@ html[data-color-scheme=light] .preview { margin: 0 20px 16px 20px; } +.scheduledAt { + margin: 0 20px 16px 20px; +} + .cw, .hashtags, .text { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6c5f04c6b5..aafa1c4b21 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -76,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints>( } else if (err.code === 'ROLE_PERMISSION_DENIED') { title = i18n.ts.permissionDeniedError; text = i18n.ts.permissionDeniedErrorDescription; - } else if (err.code.startsWith('TOO_MANY')) { + } else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する title = i18n.ts.youCannotCreateAnymore; text = `${i18n.ts.error}: ${err.id}`; } else if (err.message.startsWith('Unexpected token')) { @@ -460,7 +460,7 @@ export function inputNumber(props: { }); } -export function inputDate(props: { +export function inputDatetime(props: { title?: string; text?: string; placeholder?: string | null; @@ -475,13 +475,13 @@ export function inputDate(props: { title: props.title, text: props.text, input: { - type: 'date', + type: 'datetime-local', placeholder: props.placeholder, default: props.default ?? null, }, }, { done: result => { - resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); + resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, closed: () => dispose(), }); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index e10eb1163e..03df01a930 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -802,6 +802,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix> + <span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.scheduledNoteLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix> @@ -831,6 +850,7 @@ import { watch, ref, computed } from 'vue'; import { throttle } from 'throttle-debounce'; import * as Misskey from 'misskey-js'; import RolesEditorFormula from './RolesEditorFormula.vue'; +import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -842,7 +862,6 @@ import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; -import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; const emit = defineEmits<{ (ev: 'update:modelValue', v: any): void; diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 7a49860b2d..eca0284be3 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -304,6 +304,13 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix>{{ policies.scheduledNoteLimit }}</template> + <MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0"> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template> |