diff options
| author | taichan <40626578+tai-cha@users.noreply.github.com> | 2025-06-25 17:09:23 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-25 17:09:23 +0900 |
| commit | b752dc72e531f6c63f09876a1c68a87a77c03b49 (patch) | |
| tree | d9bd25825a9b1b06c8db07a1888594ffc9db45c8 /packages/frontend/src/components/MkPostForm.vue | |
| parent | fix(frontend): ファイルがドライブの既定アップロード先に... (diff) | |
| download | misskey-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/MkPostForm.vue')
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 217 |
1 files changed, 173 insertions, 44 deletions
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; |