summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkPostForm.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkPostForm.vue')
-rw-r--r--packages/frontend/src/components/MkPostForm.vue224
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 {