diff options
Diffstat (limited to 'packages/frontend/src/components/MkPostForm.vue')
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 224 |
1 files changed, 153 insertions, 71 deletions
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index ca227d649a..921829c41e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.headerRight"> <template v-if="!(channel != null && fixed)"> - <button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @click="setVisibility"> + <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" :disabled="editId != null" @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> @@ -32,11 +32,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ channel.name }}</span> </button> </template> - <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @click="toggleLocalOnly"> + <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified' || editId != null" @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> - <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> + <button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button> + <button v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> <span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span> <span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span> <span v-else><i class="ph-smiley ph-bold ph-lg"></i></span> @@ -65,9 +66,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <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.cwFrame"> + <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"> - <div v-if="maxCwLength - cwLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: cwLength > maxCwLength }]">{{ maxCwLength - cwLength }}</div> + <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> @@ -106,41 +107,48 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, toRaw } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import type { ShallowRef } from 'vue'; import { appendContentWarning } from '@@/js/append-content-warning.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; -import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { erase, unique } from '@/scripts/array.js'; -import { extractMentions } from '@/scripts/extract-mentions.js'; -import { formatTimeString } from '@/scripts/format-time-string.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; +import XTextCounter from '@/components/MkPostForm.TextCounter.vue'; +import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import { erase, unique } from '@/utility/array.js'; +import { extractMentions } from '@/utility/extract-mentions.js'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFiles } from '@/scripts/select-file.js'; -import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFiles } from '@/utility/select-file.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { deepClone } from '@/scripts/clone.js'; +import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; +import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { uploadFile } from '@/utility/upload.js'; +import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -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'; +import { miLocalStorage } from '@/local-storage.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const modal = inject('modal'); @@ -157,7 +165,7 @@ const props = withDefaults(defineProps<PostFormProps & { initialLocalOnly: undefined, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'posted'): void; @@ -168,10 +176,11 @@ const emit = defineEmits<{ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void; }>(); -const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); -const cwInputEl = shallowRef<HTMLInputElement | null>(null); -const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); -const visibilityButton = shallowRef<HTMLElement>(); +const textareaEl = useTemplateRef('textareaEl'); +const cwInputEl = useTemplateRef('cwInputEl'); +const hashtagsInputEl = useTemplateRef('hashtagsInputEl'); +const visibilityButton = useTemplateRef('visibilityButton'); +const otherSettingsButton = useTemplateRef('otherSettingsButton'); const posting = ref(false); const posted = ref(false); @@ -179,19 +188,18 @@ const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); -const showPreview = ref(defaultStore.state.showPreview); -watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); -const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction); -watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value)); +const showPreview = ref(store.s.showPreview); +watch(showPreview, () => store.set('showPreview', showPreview.value)); +const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); +watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref<string | null>(props.initialCw ?? null); -const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly)); -const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility)); +const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly)); +const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility)); const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } -const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); -const autocomplete = ref(null); +const reactionAcceptance = ref(store.s.reactionAcceptance); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -204,6 +212,7 @@ const scheduleNote = ref<{ scheduledAt: number | null; } | null>(null); const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); +const postFormActions = getPluginHandlers('post_form_action'); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -255,8 +264,11 @@ const maxTextLength = computed((): number => { return instance ? instance.maxNoteTextLength : 1000; }); -const cwLength = computed(() => cw.value?.length ?? 0); -const maxCwLength = computed(() => instance.maxCwLength); +const cwTextLength = computed((): number => { + return cw.value?.length ?? 0; +}); + +const maxCwTextLength = computed(() => instance.maxCwLength); const canPost = computed((): boolean => { return !props.mock && !posting.value && !posted.value && @@ -268,13 +280,19 @@ const canPost = computed((): boolean => { quoteId.value != null ) && (textLength.value <= maxTextLength.value) && - (cwLength.value <= maxCwLength.value) && + ( + useCw.value ? + ( + cw.value != null && cw.value.trim() !== '' && + cwTextLength.value <= maxCwTextLength.value + ) : true + ) && (files.value.length <= 16) && (!poll.value || poll.value.choices.length >= 2); }); -const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags')); +const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); +const hashtags = computed(store.makeGetterSetter('postFormHashtags')); watch(text, () => { checkMissingMention(); @@ -362,7 +380,7 @@ if (props.specified) { } // keep cw when reply -if (defaultStore.state.keepCw && props.reply && props.reply.cw) { +if (prefer.s.keepCw && props.reply && props.reply.cw) { useCw.value = true; cw.value = props.reply.cw; } @@ -484,7 +502,7 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities function upload(file: File, name?: string): void { if (props.mock) return; - uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { + uploadFile(file, prefer.s.uploadFolder, name).then(res => { files.value.push(res); }); } @@ -505,8 +523,8 @@ function setVisibility() { }, { changeVisibility: v => { visibility.value = v; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('visibility', visibility.value); + if (prefer.s.rememberNoteVisibility) { + store.set('visibility', visibility.value); } }, closed: () => dispose(), @@ -553,8 +571,8 @@ async function toggleLocalOnly() { } localOnly.value = !localOnly.value; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('localOnly', localOnly.value); + if (prefer.s.rememberNoteVisibility) { + store.set('localOnly', localOnly.value); } } @@ -574,6 +592,47 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } +//#region その他の設定メニューpopup +function showOtherSettings() { + let reactionAcceptanceIcon = 'ti ti-icons'; + + if (reactionAcceptance.value === 'likeOnly') { + reactionAcceptanceIcon = 'ti ti-heart _love'; + } else if (reactionAcceptance.value === 'likeOnlyForRemote') { + reactionAcceptanceIcon = 'ti ti-heart-plus'; + } + + const menuItems = [{ + type: 'component', + component: XTextCounter, + props: { + textLength: textLength, + }, + }, { type: 'divider' }, { + icon: reactionAcceptanceIcon, + text: i18n.ts.reactionAcceptance, + action: () => { + toggleReactionAcceptance(); + }, + }, { type: 'divider' }, { + icon: 'ti ti-trash', + text: i18n.ts.reset, + danger: true, + action: async () => { + if (props.mock) return; + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + clear(); + }, + }] satisfies MenuItem[]; + + os.popupMenu(menuItems, otherSettingsButton.value); +} +//#endregion + function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); @@ -623,6 +682,8 @@ function onCompositionEnd(ev: CompositionEvent) { justEndedComposition.value = true; } +const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; @@ -633,7 +694,7 @@ async function onPaste(ev: ClipboardEvent) { if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; upload(file, formatted); } } @@ -678,7 +739,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0'); + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); upload(file, `${fileName}.txt`); }); @@ -772,19 +833,19 @@ function deleteDraft() { miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } -async function post(ev?: MouseEvent) { - if (useCw.value && (cw.value == null || cw.value.trim() === '')) { - os.alert({ - type: 'error', - text: i18n.ts.cwNotationRequired, - }); - return; - } +function isAnnoying(text: string): boolean { + return text.includes('$[x2') || + text.includes('$[x3') || + text.includes('$[x4') || + text.includes('$[scale') || + text.includes('$[position'); +} +async function post(ev?: MouseEvent) { if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; - if (el) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -796,14 +857,10 @@ async function post(ev?: MouseEvent) { if (props.mock) return; - const annoying = - text.value.includes('$[x2') || - text.value.includes('$[x3') || - text.value.includes('$[x4') || - text.value.includes('$[scale') || - text.value.includes('$[position'); - - if (annoying && visibility.value === 'public') { + if (visibility.value === 'public' && ( + (useCw.value && cw.value != null && cw.value.trim() !== '' && isAnnoying(cw.value)) || // CWが迷惑になる場合 + ((!useCw.value || cw.value == null || cw.value.trim() === '') && text.value != null && text.value.trim() !== '' && isAnnoying(text.value)) // CWが無い かつ 本文が迷惑になる場合 + )) { const { canceled, result } = await os.actions({ type: 'warning', text: i18n.ts.thisPostMayBeAnnoying, @@ -884,6 +941,7 @@ async function post(ev?: MouseEvent) { } // plugin + const notePostInterruptors = getPluginHandlers('note_post_interruptor'); if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { @@ -898,7 +956,7 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; + token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token; } posting.value = true; @@ -1182,6 +1240,8 @@ defineExpose({ &.modal { width: 100%; max-width: 520px; + overflow-x: clip; + overflow-y: auto; } } @@ -1292,7 +1352,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &:disabled { @@ -1356,7 +1416,7 @@ defineExpose({ margin-right: 14px; padding: 8px 0 8px 8px; border-radius: var(--MI-radius-sm); - background: var(--MI_THEME-X4); + background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); } .hasNotSpecifiedMentions { @@ -1387,7 +1447,12 @@ defineExpose({ } } -.cwFrame { +.cwOuter { + width: 100%; + position: relative; +} + +.cw { z-index: 1; padding-bottom: 8px; border-bottom: solid 0.5px var(--MI_THEME-divider); @@ -1396,6 +1461,23 @@ defineExpose({ position: relative; } +.cwTextCount { + position: absolute; + top: 0; + right: 2px; + padding: 2px 6px; + font-size: .9em; + color: var(--MI_THEME-warn); + border-radius: 6px; + max-width: 100%; + min-width: 1.6em; + text-align: center; + + &.cwTextOver { + color: #ff2a2a; + } +} + .hashtags { z-index: 1; padding-top: 8px; @@ -1470,7 +1552,7 @@ defineExpose({ border-radius: var(--MI-radius-sm); &:hover { - background: var(--MI_THEME-X5); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &.footerButtonActive { |