diff options
| author | NoriDev <m1nthing2322@gmail.com> | 2024-10-31 13:52:01 +0900 |
|---|---|---|
| committer | Marie <github@yuugi.dev> | 2024-12-09 05:31:03 +0100 |
| commit | 2528508cff9d8c90abd33e46b15220a49a00e2e2 (patch) | |
| tree | 1a7aa5717656fc29e67eed0f86feb5fec33d8f1e /packages/frontend/src/components | |
| parent | merge: Implement new SkRateLimiterServer with Leaky Bucket rate limits (resol... (diff) | |
| download | sharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.tar.gz sharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.tar.bz2 sharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.zip | |
feat: 노트 게시를 예약할 수 있음 (yojo-art/cherrypick#483, [Type4ny-Project/Type4ny@271c872c](https://github.com/Type4ny-Project/Type4ny/commit/271c872c97f215ef5d8e0be62251dd422a52e5b1))
Diffstat (limited to 'packages/frontend/src/components')
5 files changed, 243 insertions, 5 deletions
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index cd6fdf576c..2c69048ec5 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -50,7 +50,10 @@ import { popupMenu } from '@/os.js'; import { defaultStore } from '@/store.js'; const props = defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note & { + isSchedule?: boolean + }; + scheduled?: boolean; }>(); const menuVersionsButton = shallowRef<HTMLElement>(); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 542e3e79ea..7d2bbb31d3 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div v-show="!isDeleted" :class="$style.root" :tabindex="!isDeleted ? '-1' : undefined"> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> @@ -15,6 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <div v-show="note.cw == null || showContent"> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> + <div v-if="note.isSchedule" style="margin-top: 10px;"> + <MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.deleteAndEdit }}</MkButton> + <MkButton :class="$style.button" inline danger @click.stop.prevent="deleteScheduleNote()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> </div> </div> </div> @@ -24,18 +28,60 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { defaultStore } from '@/store.js'; const props = defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note & { + isSchedule? : boolean, + scheduledNoteId?: string + }; expandAllCws?: boolean; hideFiles?: boolean; }>(); let showContent = ref(defaultStore.state.uncollapseCW); +const isDeleted = ref(false); + +const emit = defineEmits<{ + (ev: 'editScheduleNote'): void; +}>(); + +async function deleteScheduleNote() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + okText: i18n.ts.delete, + cancelText: i18n.ts.cancel, + }); + if (canceled) return; + await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id }) + .then(() => { + isDeleted.value = true; + }); +} + +async function editScheduleNote() { + try { + await misskeyApi('notes/schedule/delete', { noteId: props.note.id }) + .then(() => { + isDeleted.value = true; + }); + } catch (err) { + console.error(err); + } + + await os.post({ + initialNote: props.note, + renote: props.note.renote, + reply: props.note.reply, + channel: props.note.channel, + }); + emit('editScheduleNote'); +} watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; @@ -50,6 +96,11 @@ watch(() => props.expandAllCws, (expandAllCws) => { font-size: 0.95em; } +.button{ + margin-right: var(--margin); + margin-bottom: var(--margin); +} + .avatar { flex-shrink: 0; display: block; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 4a29b27ac4..443e9e7ee9 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -77,6 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> + <MkScheduleEditor v-if="scheduleNote" v-model="scheduleNote" @destroyed="scheduleNote = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> </div> @@ -90,6 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> + <button v-tooltip="i18n.ts.otherSettings" :class="['_button', $style.footerButton]" @click="showOtherMenu"><i class="ti ti-dots"></i></button> </div> <div :class="$style.footerRight"> <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> @@ -110,6 +112,7 @@ import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import { host, url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; @@ -133,6 +136,7 @@ import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; +import MkScheduleEditor from '@/components/MkScheduleEditor.vue'; const $i = signinRequired(); @@ -150,7 +154,9 @@ const props = withDefaults(defineProps<{ initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; initialVisibleUsers?: Misskey.entities.UserDetailed[]; - initialNote?: Misskey.entities.Note; + initialNote?: Misskey.entities.Note & { + isSchedule?: boolean, + }; instant?: boolean; fixed?: boolean; autofocus?: boolean; @@ -206,6 +212,9 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]' const imeText = ref(''); const showingOptions = ref(false); const textAreaReadOnly = ref(false); +const scheduleNote = ref<{ + scheduledAt: number | null; +} | null>(null); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -378,6 +387,7 @@ function watchForDraft() { watch(localOnly, () => saveDraft()); watch(quoteId, () => saveDraft()); watch(reactionAcceptance, () => saveDraft()); + watch(scheduleNote, () => saveDraft()); } function MFMWindow() { @@ -586,6 +596,7 @@ function clear() { files.value = []; poll.value = null; quoteId.value = null; + scheduleNote.value = null; } function onKeydown(ev: KeyboardEvent) { @@ -736,6 +747,7 @@ function saveDraft() { visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, quoteId: quoteId.value, reactionAcceptance: reactionAcceptance.value, + scheduleNote: scheduleNote.value, }, }; @@ -843,6 +855,7 @@ async function post(ev?: MouseEvent) { visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, editId: props.editId ? props.editId : undefined, + scheduleNote: scheduleNote.value ?? undefined, }; if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { @@ -879,7 +892,7 @@ async function post(ev?: MouseEvent) { } posting.value = true; - misskeyApi(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { + misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => { if (props.freezeAfterPosted) { posted.value = true; } else { @@ -901,6 +914,8 @@ async function post(ev?: MouseEvent) { claimAchievement('notes1'); } + poll.value = null; + const text = postData.text ?? ''; const lowerCase = text.toLowerCase(); if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('sharkey')) { @@ -1030,6 +1045,41 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } +function toggleScheduleNote() { + if (scheduleNote.value) scheduleNote.value = null; + else { + scheduleNote.value = { + scheduledAt: null, + }; + } +} + +function showOtherMenu(ev: MouseEvent) { + const menuItems: MenuItem[] = []; + + if ($i.policies.scheduleNoteMax > 0) { + menuItems.push({ + type: 'button', + text: i18n.ts.schedulePost, + icon: 'ti ti-calendar-time', + action: toggleScheduleNote, + }, { + type: 'button', + text: i18n.ts.schedulePostList, + icon: 'ti ti-calendar-event', + action: () => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, { + closed: () => { + dispose(); + }, + }); + }, + }); + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1099,6 +1149,11 @@ onMounted(() => { } quoteId.value = init.renote ? init.renote.id : null; reactionAcceptance.value = init.reactionAcceptance; + if (init.isSchedule) { + scheduleNote.value = { + scheduledAt: new Date(init.createdAt).getTime(), + }; + } } nextTick(() => watchForDraft()); diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue new file mode 100644 index 0000000000..8f18f620ae --- /dev/null +++ b/packages/frontend/src/components/MkScheduleEditor.vue @@ -0,0 +1,69 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div style="padding: 8px 16px;"> + <section> + <MkInput v-model="atDate" small type="date" class="input"> + <template #label>{{ i18n.ts._poll.deadlineDate }}</template> + </MkInput> + <MkInput v-model="atTime" small type="time" class="input"> + <template #label>{{ i18n.ts._poll.deadlineTime }}</template> + </MkInput> + </section> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch } from 'vue'; +import MkInput from '@/components/MkInput.vue'; +import { formatDateTimeString } from '@/scripts/format-time-string.js'; +import { addTime } from '@/scripts/time.js'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + modelValue: { + scheduledAt: number | null; + }; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: { + scheduledAt: number | null; + }): void; +}>(); + +const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); +const atTime = ref('00:00'); + +if (props.modelValue.scheduledAt) { + const date = new Date(props.modelValue.scheduledAt); + atDate.value = formatDateTimeString(date, 'yyyy-MM-dd'); + atTime.value = formatDateTimeString(date, 'HH:mm'); +} + +function get() { + const calcAt = () => { + return new Date(`${ atDate.value } ${ atTime.value }`).getTime(); + }; + + return { + ...( + { scheduledAt: calcAt() } + ), + }; +} + +watch([ + atDate, + atTime, +], () => emit('update:modelValue', get()), { + deep: true, +}); + +onMounted(() => { + emit('update:modelValue', get()); +}); +</script> diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue new file mode 100644 index 0000000000..cf793c7110 --- /dev/null +++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialogEl" + :withOkButton="false" + @click="cancel()" + @close="cancel()" +> + <template #header>{{ i18n.ts.schedulePostList }}</template> + <MkSpacer :marginMin="14" :marginMax="16"> + <MkPagination ref="paginationEl" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps"> + <MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" @editScheduleNote="listUpdate"/> + </div> + </template> + </MkPagination> + </MkSpacer> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import { i18n } from '@/i18n.js'; +import { infoImageUrl } from '@/instance.js'; + +const emit = defineEmits<{ + (ev: 'cancel'): void; +}>(); + +const dialogEl = ref(); +const cancel = () => { + emit('cancel'); + dialogEl.value.close(); +}; +const paginationEl = ref(); +const pagination: Paging = { + endpoint: 'notes/schedule/list', + limit: 10, +}; + +function listUpdate() { + paginationEl.value.reload(); +} +</script> |