summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authortaichan <40626578+tai-cha@users.noreply.github.com>2025-06-25 17:09:23 +0900
committerGitHub <noreply@github.com>2025-06-25 17:09:23 +0900
commitb752dc72e531f6c63f09876a1c68a87a77c03b49 (patch)
treed9bd25825a9b1b06c8db07a1888594ffc9db45c8 /packages/frontend/src/components
parentfix(frontend): ファイルがドライブの既定アップロード先に... (diff)
downloadmisskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.gz
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.bz2
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.zip
feat: ノートの下書き(draft of note) (#15298)
* WIp (backend) * Remove unused * 下書きbackend 続き * fix(backedn): visibilityが下書きに反映されない * Update packages/backend/src/postgres.ts Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * Fix : import order * fix(backend) : createでcwが効かない * FIX FOREGIN KEY * wip: frontend(既存の下書きを挿入) まだ:チャンネル表示、下書きの作成、削除 * WIP: ノート選択ダイアログ 投稿時に下書きを削除 * Promiseに変更 * 連合なし、チャンネルも表示 * Hashtagの値抜け漏れ * hasthagを0文字でも作成可能に * 下書きの保存機構 * chore(misskey-js): build types * localOnly抜け漏れ * チャンネル情報の書き換え * enhance(frontend): ヘッダ部の表示改善 * fix(frontend): ファイル添付できない * fix: no file * fix(frontend): 投票が反映されない * ハッシュタグの展開(コメントアウト外し忘れ) * fix: visibleUserIdsが反映されない * enhance: APIの型を整備 * refactor: 型が整備できたのでasを削除 * Add userhost * fix * enhance: paginationを使う * fix * fix: 自分のアカウントでの投稿でしか下書きを利用できないように 完全に塞ぐことはできないが一応 * :art: * APIのエラーIDを追加 * enhance: スタイル調整 * remove unused code * :art: * fix: ロールポリシーの型 * ロールの編集画面 * ダイアログの挙動改善 * 下書き機能が利用できない場合は表示しないように * refactor * fix: ダブルクリックが効かない問題を修正 * add comments * fix * fix: 保存時のエラーの種別にかかわらずmodalを閉じないように * fix()backend: NoteDraftのreply, renoteの型が間違ってたので修正 (migtrationはあってた) * fix: 投稿フォームを空白にして通常リノートできるやつは下書きとしては弾くように * fix(backend): テキストが0文字でも下書きは保存できるように * Fix(backend): replyIdの型定義がミスっているのを修正 * chore(misskey-js): update types * Add CHANGELOG * lint * 常にサーバー下書きに保存し、上限を超えた場合のみ尋ねるように * NoteDraftServiceにcreate, updateの処理を移譲 * Fix typeerror * remove tooltip * Remove Mkbutton:short and use iconOnly * 不要なコメントの削除 * Remove Short Completely * wip * escキーまわりの挙動を改善 * 下書き選択時に下書き可能数と現在の量が分かるように * cleanUp * wip * wi * wip * Update MkPostForm.vue --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkNoteDraftsDialog.vue218
-rw-r--r--packages/frontend/src/components/MkPostForm.vue217
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue12
3 files changed, 401 insertions, 46 deletions
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue
new file mode 100644
index 0000000000..b4aff8d16f
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue
@@ -0,0 +1,218 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialogEl"
+ :width="600"
+ :height="650"
+ :withOkButton="false"
+ @click="cancel()"
+ @close="cancel()"
+ @closed="emit('closed')"
+ @esc="cancel()"
+>
+ <template #header>
+ {{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
+ </template>
+ <div :class="$style.drafts" class="_gaps">
+ <MkPagination ref="pagingEl" :pagination="paging">
+ <template #empty>
+ <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
+ </template>
+
+ <template #default="{ items }">
+ <div class="_spacer _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-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-if="draft.channel" class="_nowrap">
+ <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+ </div>
+ </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>
+ </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>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, useTemplateRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
+import MkButton from '@/components/MkButton.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import { $i } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api';
+
+const emit = defineEmits<{
+ (ev: 'restore', draft: Misskey.entities.NoteDraft): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const paging = {
+ endpoint: 'notes/drafts/list',
+ limit: 10,
+} satisfies PagingCtx;
+
+const pagingComponent = useTemplateRef('pagingEl');
+
+const currentDraftsCount = ref(0);
+misskeyApi('notes/drafts/count').then((count) => {
+ currentDraftsCount.value = count;
+});
+
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function cancel() {
+ emit('cancel');
+ dialogEl.value?.close();
+}
+
+function restoreDraft(draft: Misskey.entities.NoteDraft) {
+ emit('restore', draft);
+ dialogEl.value?.close();
+}
+
+async function deleteDraft(draft: Misskey.entities.NoteDraft) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts._drafts.deleteAreYouSure,
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
+ pagingComponent.value?.paginator.reload();
+ });
+}
+</script>
+
+<style lang="scss" module>
+.drafts {
+ overflow-x: hidden;
+ overflow-x: clip;
+ overflow-y: auto;
+}
+
+.draft {
+ padding: 16px;
+ gap: 16px;
+ border-radius: 10px;
+}
+
+.draftBody {
+ width: 100%;
+ min-width: 0;
+}
+
+.draftInfo {
+ display: flex;
+ width: 100%;
+ font-size: 0.85em;
+ opacity: 0.7;
+}
+
+.draftMeta {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.draftContent {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+ font-size: 0.9em;
+}
+
+.draftFooter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.draftVisibility {
+ flex-shrink: 0;
+}
+
+.draftCreatedAt {
+ font-size: 85%;
+ opacity: 0.7;
+}
+
+.draftActions {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--MI_THEME-divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index e319c9bacb..f8e163c581 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -17,10 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
</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>
</div>
<div :class="$style.headerRight">
- <template v-if="!(channel != null && fixed)">
- <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
+ <template v-if="!(targetChannel != null && fixed)">
+ <button v-if="targetChannel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
@@ -29,10 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<button v-else class="_button" :class="[$style.headerRightItem, $style.visibility]" disabled>
<span><i class="ti ti-device-tv"></i></span>
- <span :class="$style.headerRightButtonText">{{ channel.name }}</span>
+ <span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span>
</button>
</template>
- <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
+ <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly">
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
@@ -42,12 +43,12 @@ 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' : reply ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
+ <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
</div>
</button>
</div>
</header>
- <MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
+ <MkNoteSimple v-if="replyTargetNote" :class="$style.targetNote" :note="replyTargetNote"/>
<MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :note="renoteTargetNote"/>
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
@@ -66,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
- <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
+ <div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
@@ -207,6 +208,10 @@ const showingOptions = ref(false);
const textAreaReadOnly = ref(false);
const justEndedComposition = ref(false);
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
+const replyTargetNote: ShallowRef<PostFormProps['reply'] | null> = shallowRef(props.reply);
+const targetChannel = shallowRef(props.channel);
+
+const serverDraftId = ref<string | null>(null);
const postFormActions = getPluginHandlers('post_form_action');
const uploader = useUploader({
@@ -219,12 +224,12 @@ uploader.events.on('itemUploaded', ctx => {
});
const draftKey = computed((): string => {
- let key = props.channel ? `channel:${props.channel.id}` : '';
+ let key = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
if (renoteTargetNote.value) {
key += `renote:${renoteTargetNote.value.id}`;
- } else if (props.reply) {
- key += `reply:${props.reply.id}`;
+ } else if (replyTargetNote.value) {
+ key += `reply:${replyTargetNote.value.id}`;
} else {
key += `note:${$i.id}`;
}
@@ -235,9 +240,9 @@ const draftKey = computed((): string => {
const placeholder = computed((): string => {
if (renoteTargetNote.value) {
return i18n.ts._postForm.quotePlaceholder;
- } else if (props.reply) {
+ } else if (replyTargetNote.value) {
return i18n.ts._postForm.replyPlaceholder;
- } else if (props.channel) {
+ } else if (targetChannel.value) {
return i18n.ts._postForm.channelPlaceholder;
} else {
const xs = [
@@ -255,7 +260,7 @@ const placeholder = computed((): string => {
const submitText = computed((): string => {
return renoteTargetNote.value
? i18n.ts.quote
- : props.reply
+ : replyTargetNote.value
? i18n.ts.reply
: i18n.ts.note;
});
@@ -296,6 +301,11 @@ const canPost = computed((): boolean => {
(!poll.value || poll.value.choices.length >= 2);
});
+// cannot save pure renote as draft
+const canSaveAsServerDraft = computed((): boolean => {
+ return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null);
+});
+
const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
@@ -318,13 +328,13 @@ if (props.mention) {
text.value += ' ';
}
-if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
- text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
+if (replyTargetNote.value && (replyTargetNote.value.user.username !== $i.username || (replyTargetNote.value.user.host != null && replyTargetNote.value.user.host !== host))) {
+ text.value = `@${replyTargetNote.value.user.username}${replyTargetNote.value.user.host != null ? '@' + toASCII(replyTargetNote.value.user.host) : ''} `;
}
-if (props.reply && props.reply.text != null) {
- const ast = mfm.parse(props.reply.text);
- const otherHost = props.reply.user.host;
+if (replyTargetNote.value && replyTargetNote.value.text != null) {
+ const ast = mfm.parse(replyTargetNote.value.text);
+ const otherHost = replyTargetNote.value.user.host;
for (const x of extractMentions(ast)) {
const mention = x.host ?
@@ -347,32 +357,32 @@ if ($i.isSilenced && visibility.value === 'public') {
visibility.value = 'home';
}
-if (props.channel) {
+if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
}
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
- if (props.reply.visibility === 'home' && visibility.value === 'followers') {
+if (replyTargetNote.value && ['home', 'followers', 'specified'].includes(replyTargetNote.value.visibility)) {
+ if (replyTargetNote.value.visibility === 'home' && visibility.value === 'followers') {
visibility.value = 'followers';
- } else if (['home', 'followers'].includes(props.reply.visibility) && visibility.value === 'specified') {
+ } else if (['home', 'followers'].includes(replyTargetNote.value.visibility) && visibility.value === 'specified') {
visibility.value = 'specified';
} else {
- visibility.value = props.reply.visibility;
+ visibility.value = replyTargetNote.value.visibility;
}
if (visibility.value === 'specified') {
- if (props.reply.visibleUserIds) {
+ if (replyTargetNote.value.visibleUserIds) {
misskeyApi('users/show', {
- userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
+ userIds: replyTargetNote.value.visibleUserIds.filter(uid => uid !== $i.id && uid !== replyTargetNote.value?.userId),
}).then(users => {
users.forEach(u => pushVisibleUser(u));
});
}
- if (props.reply.userId !== $i.id) {
- misskeyApi('users/show', { userId: props.reply.userId }).then(user => {
+ if (replyTargetNote.value.userId !== $i.id) {
+ misskeyApi('users/show', { userId: replyTargetNote.value.userId }).then(user => {
pushVisibleUser(user);
});
}
@@ -385,9 +395,9 @@ if (props.specified) {
}
// keep cw when reply
-if (prefer.s.keepCw && props.reply && props.reply.cw) {
+if (prefer.s.keepCw && replyTargetNote.value && replyTargetNote.value.cw) {
useCw.value = true;
- cw.value = props.reply.cw;
+ cw.value = replyTargetNote.value.cw;
}
function watchForDraft() {
@@ -485,7 +495,7 @@ function updateFileName(file, name) {
}
function setVisibility() {
- if (props.channel) {
+ if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -496,7 +506,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
anchorElement: visibilityButton.value,
- ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
+ ...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;
@@ -509,7 +519,7 @@ function setVisibility() {
}
async function toggleLocalOnly() {
- if (props.channel) {
+ if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -798,7 +808,7 @@ function saveDraft() {
localOnly: localOnly.value,
files: files.value,
poll: poll.value,
- visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
+ ...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value,
},
@@ -815,6 +825,32 @@ function deleteDraft() {
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
}
+async function saveServerDraft(clearLocal = false) {
+ return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
+ ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
+ text: text.value,
+ useCw: useCw.value,
+ cw: cw.value,
+ visibility: visibility.value,
+ localOnly: localOnly.value,
+ hashtag: hashtags.value,
+ ...(files.value.length > 0 ? { 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 : undefined,
+ replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
+ quoteId: quoteId.value,
+ channelId: targetChannel.value ? targetChannel.value.id : undefined,
+ reactionAcceptance: reactionAcceptance.value,
+ }).then(() => {
+ if (clearLocal) {
+ clear();
+ deleteDraft();
+ }
+ }).catch((err) => {
+ });
+}
+
function isAnnoying(text: string): boolean {
return text.includes('$[x2') ||
text.includes('$[x3') ||
@@ -882,9 +918,9 @@ async function post(ev?: MouseEvent) {
let postData = {
text: text.value === '' ? null : text.value,
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
- replyId: props.reply ? props.reply.id : undefined,
+ replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
- channelId: props.channel ? props.channel.id : undefined,
+ channelId: targetChannel.value ? targetChannel.value.id : undefined,
poll: poll.value,
cw: useCw.value ? cw.value ?? '' : null,
localOnly: localOnly.value,
@@ -989,6 +1025,10 @@ async function post(ev?: MouseEvent) {
if (m === 0 && s === 0) {
claimAchievement('postedAt0min0sec');
}
+
+ if (serverDraftId.value != null) {
+ misskeyApi('notes/drafts/delete', { draftId: serverDraftId.value });
+ }
});
}).catch(err => {
posting.value = false;
@@ -1092,6 +1132,84 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
os.contextMenu(menu, ev);
}
+function showDraftMenu(ev: MouseEvent) {
+ function showDraftsDialog() {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, {
+ restore: async (draft: Misskey.entities.NoteDraft) => {
+ text.value = draft.text ?? '';
+ useCw.value = draft.cw != null;
+ cw.value = draft.cw ?? null;
+ visibility.value = draft.visibility;
+ localOnly.value = draft.localOnly ?? false;
+ files.value = draft.files ?? [];
+ hashtags.value = draft.hashtag ?? '';
+ if (draft.hashtag) withHashtags.value = true;
+ if (draft.poll) {
+ // 投票を一時的に空にしないと反映されないため
+ poll.value = null;
+ nextTick(() => {
+ poll.value = {
+ choices: draft.poll!.choices,
+ multiple: draft.poll!.multiple,
+ expiresAt: draft.poll!.expiresAt ? (new Date(draft.poll!.expiresAt)).getTime() : null,
+ expiredAfter: null,
+ };
+ });
+ }
+ if (draft.visibleUserIds) {
+ misskeyApi('users/show', { userIds: draft.visibleUserIds }).then(users => {
+ users.forEach(u => pushVisibleUser(u));
+ });
+ }
+ quoteId.value = draft.renoteId ?? null;
+ renoteTargetNote.value = draft.renote;
+ replyTargetNote.value = draft.reply;
+ reactionAcceptance.value = draft.reactionAcceptance;
+ if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
+
+ visibleUsers.value = [];
+ draft.visibleUserIds?.forEach(uid => {
+ if (!visibleUsers.value.some(u => u.id === uid)) {
+ misskeyApi('users/show', { userId: uid }).then(user => {
+ pushVisibleUser(user);
+ });
+ }
+ });
+
+ serverDraftId.value = draft.id;
+ },
+ cancel: () => {
+
+ },
+ closed: () => {
+ dispose();
+ },
+ });
+ }
+
+ os.popupMenu([{
+ type: 'button',
+ text: i18n.ts._drafts.saveToDraft,
+ icon: 'ti ti-cloud-upload',
+ action: async () => {
+ if (!canSaveAsServerDraft.value) {
+ return os.alert({
+ type: 'error',
+ text: i18n.ts._drafts.cannotCreateDraftOfRenote,
+ });
+ }
+ saveServerDraft();
+ },
+ }, {
+ type: 'button',
+ text: i18n.ts._drafts.listDrafts,
+ icon: 'ti ti-cloud-download',
+ action: () => {
+ showDraftsDialog();
+ },
+ }], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
+}
+
onMounted(() => {
if (props.autofocus) {
focus();
@@ -1204,21 +1322,18 @@ defineExpose({
.headerLeft {
display: flex;
- flex: 0 1 100px;
+ flex: 1;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 6px;
+ padding-left: 12px;
}
.cancel {
- padding: 0;
- font-size: 1em;
- height: 100%;
- flex: 0 1 50px;
+ padding: 8px;
}
.account {
- height: 100%;
- display: inline-flex;
- vertical-align: bottom;
- flex: 0 1 50px;
}
.avatar {
@@ -1227,6 +1342,20 @@ defineExpose({
margin: auto;
}
+.draftButton {
+ padding: 8px;
+ font-size: 90%;
+ border-radius: 6px;
+
+ &:hover {
+ background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
+ }
+
+ &:disabled {
+ background: none;
+ }
+}
+
.headerRight {
display: flex;
min-height: 48px;
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 0a655bab99..1f7796bd83 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal
ref="modal"
:preferType="'dialog'"
- @click="_close()"
+ @click="onBgClick()"
@closed="onModalClosed()"
- @esc="_close()"
+ @esc="onEsc"
>
<MkPostForm
ref="form"
@@ -57,6 +57,14 @@ async function _close() {
modal.value?.close();
}
+function onEsc(ev: KeyboardEvent) {
+ _close();
+}
+
+function onBgClick() {
+ _close();
+}
+
function onModalClosed() {
emit('closed');
}