From c17e8fa8a4be4cc7b20e18adb37605a444823318 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 16 Jan 2022 01:46:25 +0900
Subject: [PATCH] wip: refactor(client): migrate components to composition api

---
 packages/client/src/components/post-form.vue | 1170 +++++++++---------
 packages/client/src/scripts/autocomplete.ts  |   26 +-
 2 files changed, 564 insertions(+), 632 deletions(-)

diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
index 24f35da2e9..7b2f79e389 100644
--- a/packages/client/src/components/post-form.vue
+++ b/packages/client/src/components/post-form.vue
@@ -9,7 +9,7 @@
 	<header>
 		<button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button>
 		<div>
-			<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+			<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
 			<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
 			<button ref="visibilityButton" v-tooltip="$ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
 				<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
@@ -36,9 +36,9 @@
 			</div>
 		</div>
 		<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo>
-		<input v-show="useCw" ref="cw" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
-		<textarea ref="text" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
-		<input v-show="withHashtags" ref="hashtags" v-model="hashtags" class="hashtags" :placeholder="$ts.hashtags" list="hashtags">
+		<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+		<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+		<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="$ts.hashtags" list="hashtags">
 		<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
 		<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
 		<XNotePreview v-if="showPreview" class="preview" :text="text"/>
@@ -58,667 +58,603 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { inject, watch, nextTick, onMounted } from 'vue';
+import * as mfm from 'mfm-js';
+import * as misskey from 'misskey-js';
 import insertTextAtCursor from 'insert-text-at-cursor';
 import { length } from 'stringz';
 import { toASCII } from 'punycode/';
 import XNoteSimple from './note-simple.vue';
 import XNotePreview from './note-preview.vue';
-import * as mfm from 'mfm-js';
+import XPostFormAttaches from './post-form-attaches.vue';
+import XPollEditor from './poll-editor.vue';
 import { host, url } from '@/config';
 import { erase, unique } from '@/scripts/array';
 import { extractMentions } from '@/scripts/extract-mentions';
 import * as Acct from 'misskey-js/built/acct';
 import { formatTimeString } from '@/scripts/format-time-string';
 import { Autocomplete } from '@/scripts/autocomplete';
-import { noteVisibilities } from 'misskey-js';
 import * as os from '@/os';
 import { stream } from '@/stream';
 import { selectFiles } from '@/scripts/select-file';
 import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
 import { throttle } from 'throttle-debounce';
 import MkInfo from '@/components/ui/info.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { $i } from '@/account';
 
-export default defineComponent({
-	components: {
-		XNoteSimple,
-		XNotePreview,
-		XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')),
-		XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')),
-		MkInfo,
-	},
+const modal = inject('modal');
 
-	inject: ['modal'],
+const props = withDefaults(defineProps<{
+	reply?: misskey.entities.Note;
+	renote?: misskey.entities.Note;
+	channel?: any; // TODO
+	mention?: misskey.entities.User;
+	specified?: misskey.entities.User;
+	initialText?: string;
+	initialVisibility?: typeof misskey.noteVisibilities;
+	initialFiles?: misskey.entities.DriveFile[];
+	initialLocalOnly?: boolean;
+	initialVisibleUsers?: misskey.entities.User[];
+	initialNote?: misskey.entities.Note;
+	share?: boolean;
+	fixed?: boolean;
+	autofocus?: boolean;
+}>(), {
+	initialVisibleUsers: [],
+	autofocus: true,
+});
 
-	props: {
-		reply: {
-			type: Object,
-			required: false
-		},
-		renote: {
-			type: Object,
-			required: false
-		},
-		channel: {
-			type: Object,
-			required: false
-		},
-		mention: {
-			type: Object,
-			required: false
-		},
-		specified: {
-			type: Object,
-			required: false
-		},
-		initialText: {
-			type: String,
-			required: false
-		},
-		initialVisibility: {
-			type: String,
-			required: false
-		},
-		initialFiles: {
-			type: Array,
-			required: false
-		},
-		initialLocalOnly: {
-			type: Boolean,
-			required: false
-		},
-		initialVisibleUsers: {
-			type: Array,
-			required: false,
-			default: () => []
-		},
-		initialNote: {
-			type: Object,
-			required: false
-		},
-		share: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		fixed: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
-		autofocus: {
-			type: Boolean,
-			required: false,
-			default: true
-		},
-	},
+const emit = defineEmits<{
+	(e: 'posted'): void;
+	(e: 'cancel'): void;
+	(e: 'esc'): void;
+}>();
 
-	emits: ['posted', 'cancel', 'esc'],
+const textareaEl = $ref<HTMLTextAreaElement | null>(null);
+const cwInputEl = $ref<HTMLInputElement | null>(null);
+const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
+const visibilityButton = $ref<HTMLElement | null>(null);
 
-	data() {
-		return {
-			posting: false,
-			text: '',
-			files: [],
-			poll: null,
-			useCw: false,
-			showPreview: false,
-			cw: null,
-			localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
-			visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number],
-			visibleUsers: [],
-			autocomplete: null,
-			draghover: false,
-			quoteId: null,
-			hasNotSpecifiedMentions: false,
-			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
-			imeText: '',
-			typing: throttle(3000, () => {
-				if (this.channel) {
-					stream.send('typingOnChannel', { channel: this.channel.id });
-				}
-			}),
-			postFormActions,
-		};
-	},
+let posting = $ref(false);
+let text = $ref(props.initialText ?? '');
+let files = $ref(props.initialFiles ?? []);
+let poll = $ref<{
+	choices: string[];
+	multiple: boolean;
+	expiresAt: string;
+	expiredAfter: string;
+} | null>(null);
+let useCw = $ref(false);
+let showPreview = $ref(false);
+let cw = $ref<string | null>(null);
+let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
+let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]);
+let visibleUsers = $ref(props.initialVisibleUsers ?? []);
+let autocomplete = $ref(null);
+let draghover = $ref(false);
+let quoteId = $ref(null);
+let hasNotSpecifiedMentions = $ref(false);
+let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]'));
+let imeText = $ref('');
 
-	computed: {
-		draftKey(): string {
-			let key = this.channel ? `channel:${this.channel.id}` : '';
+const typing = throttle(3000, () => {
+	if (props.channel) {
+		stream.send('typingOnChannel', { channel: props.channel.id });
+	}
+});
 
-			if (this.renote) {
-				key += `renote:${this.renote.id}`;
-			} else if (this.reply) {
-				key += `reply:${this.reply.id}`;
-			} else {
-				key += 'note';
-			}
+const draftKey = $computed((): string => {
+	let key = props.channel ? `channel:${props.channel.id}` : '';
 
-			return key;
-		},
+	if (props.renote) {
+		key += `renote:${props.renote.id}`;
+	} else if (props.reply) {
+		key += `reply:${props.reply.id}`;
+	} else {
+		key += 'note';
+	}
 
-		placeholder(): string {
-			if (this.renote) {
-				return this.$ts._postForm.quotePlaceholder;
-			} else if (this.reply) {
-				return this.$ts._postForm.replyPlaceholder;
-			} else if (this.channel) {
-				return this.$ts._postForm.channelPlaceholder;
-			} else {
-				const xs = [
-					this.$ts._postForm._placeholders.a,
-					this.$ts._postForm._placeholders.b,
-					this.$ts._postForm._placeholders.c,
-					this.$ts._postForm._placeholders.d,
-					this.$ts._postForm._placeholders.e,
-					this.$ts._postForm._placeholders.f
-				];
-				return xs[Math.floor(Math.random() * xs.length)];
-			}
-		},
+	return key;
+});
 
-		submitText(): string {
-			return this.renote
-				? this.$ts.quote
-				: this.reply
-					? this.$ts.reply
-					: this.$ts.note;
-		},
+const placeholder = $computed((): string => {
+	if (props.renote) {
+		return i18n.locale._postForm.quotePlaceholder;
+	} else if (props.reply) {
+		return i18n.locale._postForm.replyPlaceholder;
+	} else if (props.channel) {
+		return i18n.locale._postForm.channelPlaceholder;
+	} else {
+		const xs = [
+			i18n.locale._postForm._placeholders.a,
+			i18n.locale._postForm._placeholders.b,
+			i18n.locale._postForm._placeholders.c,
+			i18n.locale._postForm._placeholders.d,
+			i18n.locale._postForm._placeholders.e,
+			i18n.locale._postForm._placeholders.f
+		];
+		return xs[Math.floor(Math.random() * xs.length)];
+	}
+});
 
-		textLength(): number {
-			return length((this.text + this.imeText).trim());
-		},
+const submitText = $computed((): string => {
+	return props.renote
+		? i18n.locale.quote
+		: props.reply
+			? i18n.locale.reply
+			: i18n.locale.note;
+});
 
-		canPost(): boolean {
-			return !this.posting &&
-				(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
-				(this.textLength <= this.max) &&
-				(!this.poll || this.poll.choices.length >= 2);
-		},
+const textLength = $computed((): number => {
+	return length((text + imeText).trim());
+});
 
-		max(): number {
-			return this.$instance ? this.$instance.maxNoteTextLength : 1000;
-		},
+const maxTextLength = $computed((): number => {
+	return instance ? instance.maxNoteTextLength : 1000;
+});
 
-		withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'),
-		hashtags: defaultStore.makeGetterSetter('postFormHashtags'),
-	},
+const canPost = $computed((): boolean => {
+	return !posting &&
+		(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
+		(textLength <= maxTextLength) &&
+		(!poll || poll.choices.length >= 2);
+});
 
-	watch: {
-		text() {
-			this.checkMissingMention();
-		},
-		visibleUsers: {
-			handler() {
-				this.checkMissingMention();
-			},
-			deep: true
-		}
-	},
+const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
+const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
 
-	mounted() {
-		if (this.initialText) {
-			this.text = this.initialText;
-		}
+watch($$(text), () => {
+	checkMissingMention();
+});
 
-		if (this.initialVisibility) {
-			this.visibility = this.initialVisibility;
-		}
+watch($$(visibleUsers), () => {
+	checkMissingMention();
+}, {
+	deep: true,
+});
 
-		if (this.initialFiles) {
-			this.files = this.initialFiles;
-		}
+if (props.mention) {
+	text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
+	text += ' ';
+}
 
-		if (typeof this.initialLocalOnly === 'boolean') {
-			this.localOnly = this.initialLocalOnly;
-		}
+if (props.reply && (props.reply.user.username != $i.username || (props.reply.user.host != null && props.reply.user.host != host))) {
+	text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
+}
 
-		if (this.initialVisibleUsers) {
-			this.visibleUsers = this.initialVisibleUsers;
-		}
+if (props.reply && props.reply.text != null) {
+	const ast = mfm.parse(props.reply.text);
+	const otherHost = props.reply.user.host;
 
-		if (this.mention) {
-			this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
-			this.text += ' ';
-		}
+	for (const x of extractMentions(ast)) {
+		const mention = x.host ?
+											`@${x.username}@${toASCII(x.host)}` :
+											(otherHost == null || otherHost == host) ?
+												`@${x.username}` :
+												`@${x.username}@${toASCII(otherHost)}`;
 
-		if (this.reply && (this.reply.user.username != this.$i.username || (this.reply.user.host != null && this.reply.user.host != host))) {
-			this.text = `@${this.reply.user.username}${this.reply.user.host != null ? '@' + toASCII(this.reply.user.host) : ''} `;
-		}
+		// 自分は除外
+		if ($i.username == x.username && x.host == null) continue;
+		if ($i.username == x.username && x.host == host) continue;
 
-		if (this.reply && this.reply.text != null) {
-			const ast = mfm.parse(this.reply.text);
-			const otherHost = this.reply.user.host;
+		// 重複は除外
+		if (text.indexOf(`${mention} `) != -1) continue;
 
-			for (const x of extractMentions(ast)) {
-				const mention = x.host ?
-													`@${x.username}@${toASCII(x.host)}` :
-													(otherHost == null || otherHost == host) ?
-														`@${x.username}` :
-														`@${x.username}@${toASCII(otherHost)}`;
+		text += `${mention} `;
+	}
+}
 
-				// 自分は除外
-				if (this.$i.username == x.username && x.host == null) continue;
-				if (this.$i.username == x.username && x.host == host) continue;
+if (props.channel) {
+	visibility = 'public';
+	localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+}
 
-				// 重複は除外
-				if (this.text.indexOf(`${mention} `) != -1) continue;
-
-				this.text += `${mention} `;
-			}
-		}
-
-		if (this.channel) {
-			this.visibility = 'public';
-			this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
-		}
-
-		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
-			this.visibility = this.reply.visibility;
-			if (this.reply.visibility === 'specified') {
-				os.api('users/show', {
-					userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
-				}).then(users => {
-					this.visibleUsers.push(...users);
-				});
-
-				if (this.reply.userId !== this.$i.id) {
-					os.api('users/show', { userId: this.reply.userId }).then(user => {
-						this.visibleUsers.push(user);
-					});
-				}
-			}
-		}
-
-		if (this.specified) {
-			this.visibility = 'specified';
-			this.visibleUsers.push(this.specified);
-		}
-
-		// keep cw when reply
-		if (this.$store.state.keepCw && this.reply && this.reply.cw) {
-			this.useCw = true;
-			this.cw = this.reply.cw;
-		}
-
-		if (this.autofocus) {
-			this.focus();
-
-			this.$nextTick(() => {
-				this.focus();
-			});
-		}
-
-		// TODO: detach when unmount
-		new Autocomplete(this.$refs.text, this, { model: 'text' });
-		new Autocomplete(this.$refs.cw, this, { model: 'cw' });
-		new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' });
-
-		this.$nextTick(() => {
-			// 書きかけの投稿を復元
-			if (!this.share && !this.mention && !this.specified) {
-				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
-				if (draft) {
-					this.text = draft.data.text;
-					this.useCw = draft.data.useCw;
-					this.cw = draft.data.cw;
-					this.visibility = draft.data.visibility;
-					this.localOnly = draft.data.localOnly;
-					this.files = (draft.data.files || []).filter(e => e);
-					if (draft.data.poll) {
-						this.poll = draft.data.poll;
-					}
-				}
-			}
-
-			// 削除して編集
-			if (this.initialNote) {
-				const init = this.initialNote;
-				this.text = init.text ? init.text : '';
-				this.files = init.files;
-				this.cw = init.cw;
-				this.useCw = init.cw != null;
-				if (init.poll) {
-					this.poll = {
-						choices: init.poll.choices.map(x => x.text),
-						multiple: init.poll.multiple,
-						expiresAt: init.poll.expiresAt,
-						expiredAfter: init.poll.expiredAfter,
-					};
-				}
-				this.visibility = init.visibility;
-				this.localOnly = init.localOnly;
-				this.quoteId = init.renote ? init.renote.id : null;
-			}
-
-			this.$nextTick(() => this.watch());
+// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
+	visibility = props.reply.visibility;
+	if (props.reply.visibility === 'specified') {
+		os.api('users/show', {
+			userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId)
+		}).then(users => {
+			visibleUsers.push(...users);
 		});
-	},
 
-	methods: {
-		watch() {
-			this.$watch('text', () => this.saveDraft());
-			this.$watch('useCw', () => this.saveDraft());
-			this.$watch('cw', () => this.saveDraft());
-			this.$watch('poll', () => this.saveDraft());
-			this.$watch('files', () => this.saveDraft(), { deep: true });
-			this.$watch('visibility', () => this.saveDraft());
-			this.$watch('localOnly', () => this.saveDraft());
-		},
-
-		checkMissingMention() {
-			if (this.visibility === 'specified') {
-				const ast = mfm.parse(this.text);
-
-				for (const x of extractMentions(ast)) {
-					if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
-						this.hasNotSpecifiedMentions = true;
-						return;
-					}
-				}
-				this.hasNotSpecifiedMentions = false;
-			}
-		},
-
-		addMissingMention() {
-			const ast = mfm.parse(this.text);
-
-			for (const x of extractMentions(ast)) {
-				if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
-					os.api('users/show', { username: x.username, host: x.host }).then(user => {
-						this.visibleUsers.push(user);
-					});
-				}
-			}
-		},
-
-		togglePoll() {
-			if (this.poll) {
-				this.poll = null;
-			} else {
-				this.poll = {
-					choices: ['', ''],
-					multiple: false,
-					expiresAt: null,
-					expiredAfter: null,
-				};
-			}
-		},
-
-		addTag(tag: string) {
-			insertTextAtCursor(this.$refs.text, ` #${tag} `);
-		},
-
-		focus() {
-			(this.$refs.text as any).focus();
-		},
-
-		chooseFileFrom(ev) {
-			selectFiles(ev.currentTarget || ev.target, this.$ts.attachFile).then(files => {
-				for (const file of files) {
-					this.files.push(file);
-				}
+		if (props.reply.userId !== $i.id) {
+			os.api('users/show', { userId: props.reply.userId }).then(user => {
+				visibleUsers.push(user);
 			});
-		},
-
-		detachFile(id) {
-			this.files = this.files.filter(x => x.id != id);
-		},
-
-		updateFiles(files) {
-			this.files = files;
-		},
-
-		updateFileSensitive(file, sensitive) {
-			this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
-		},
-
-		updateFileName(file, name) {
-			this.files[this.files.findIndex(x => x.id === file.id)].name = name;
-		},
-
-		upload(file: File, name?: string) {
-			os.upload(file, this.$store.state.uploadFolder, name).then(res => {
-				this.files.push(res);
-			});
-		},
-
-		onPollUpdate(poll) {
-			this.poll = poll;
-			this.saveDraft();
-		},
-
-		setVisibility() {
-			if (this.channel) {
-				// TODO: information dialog
-				return;
-			}
-
-			os.popup(import('./visibility-picker.vue'), {
-				currentVisibility: this.visibility,
-				currentLocalOnly: this.localOnly,
-				src: this.$refs.visibilityButton
-			}, {
-				changeVisibility: visibility => {
-					this.visibility = visibility;
-					if (this.$store.state.rememberNoteVisibility) {
-						this.$store.set('visibility', visibility);
-					}
-				},
-				changeLocalOnly: localOnly => {
-					this.localOnly = localOnly;
-					if (this.$store.state.rememberNoteVisibility) {
-						this.$store.set('localOnly', localOnly);
-					}
-				}
-			}, 'closed');
-		},
-
-		addVisibleUser() {
-			os.selectUser().then(user => {
-				this.visibleUsers.push(user);
-			});
-		},
-
-		removeVisibleUser(user) {
-			this.visibleUsers = erase(user, this.visibleUsers);
-		},
-
-		clear() {
-			this.text = '';
-			this.files = [];
-			this.poll = null;
-			this.quoteId = null;
-		},
-
-		onKeydown(e: KeyboardEvent) {
-			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
-			if (e.which === 27) this.$emit('esc');
-			this.typing();
-		},
-
-		onCompositionUpdate(e: CompositionEvent) {
-			this.imeText = e.data;
-			this.typing();
-		},
-
-		onCompositionEnd(e: CompositionEvent) {
-			this.imeText = '';
-		},
-
-		async onPaste(e: ClipboardEvent) {
-			for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
-				if (item.kind == 'file') {
-					const file = item.getAsFile();
-					const lio = file.name.lastIndexOf('.');
-					const ext = lio >= 0 ? file.name.slice(lio) : '';
-					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
-					this.upload(file, formatted);
-				}
-			}
-
-			const paste = e.clipboardData.getData('text');
-
-			if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
-				e.preventDefault();
-
-				os.confirm({
-					type: 'info',
-					text: this.$ts.quoteQuestion,
-				}).then(({ canceled }) => {
-					if (canceled) {
-						insertTextAtCursor(this.$refs.text, paste);
-						return;
-					}
-
-					this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
-				});
-			}
-		},
-
-		onDragover(e) {
-			if (!e.dataTransfer.items[0]) return;
-			const isFile = e.dataTransfer.items[0].kind == 'file';
-			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
-			if (isFile || isDriveFile) {
-				e.preventDefault();
-				this.draghover = true;
-				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
-			}
-		},
-
-		onDragenter(e) {
-			this.draghover = true;
-		},
-
-		onDragleave(e) {
-			this.draghover = false;
-		},
-
-		onDrop(e): void {
-			this.draghover = false;
-
-			// ファイルだったら
-			if (e.dataTransfer.files.length > 0) {
-				e.preventDefault();
-				for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
-				return;
-			}
-
-			//#region ドライブのファイル
-			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-			if (driveFile != null && driveFile != '') {
-				const file = JSON.parse(driveFile);
-				this.files.push(file);
-				e.preventDefault();
-			}
-			//#endregion
-		},
-
-		saveDraft() {
-			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
-
-			data[this.draftKey] = {
-				updatedAt: new Date(),
-				data: {
-					text: this.text,
-					useCw: this.useCw,
-					cw: this.cw,
-					visibility: this.visibility,
-					localOnly: this.localOnly,
-					files: this.files,
-					poll: this.poll
-				}
-			};
-
-			localStorage.setItem('drafts', JSON.stringify(data));
-		},
-
-		deleteDraft() {
-			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
-
-			delete data[this.draftKey];
-
-			localStorage.setItem('drafts', JSON.stringify(data));
-		},
-
-		async post() {
-			let data = {
-				text: this.text == '' ? undefined : this.text,
-				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
-				replyId: this.reply ? this.reply.id : undefined,
-				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
-				channelId: this.channel ? this.channel.id : undefined,
-				poll: this.poll,
-				cw: this.useCw ? this.cw || '' : undefined,
-				localOnly: this.localOnly,
-				visibility: this.visibility,
-				visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
-			};
-
-			if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') {
-				const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
-				data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
-			}
-
-			// plugin
-			if (notePostInterruptors.length > 0) {
-				for (const interruptor of notePostInterruptors) {
-					data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
-				}
-			}
-
-			this.posting = true;
-			os.api('notes/create', data).then(() => {
-				this.clear();
-				this.$nextTick(() => {
-					this.deleteDraft();
-					this.$emit('posted');
-					if (data.text && data.text != '') {
-						const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
-						const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
-						localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
-					}
-					this.posting = false;
-				});
-			}).catch(err => {
-				this.posting = false;
-				os.alert({
-					type: 'error',
-					text: err.message + '\n' + (err as any).id,
-				});
-			});
-		},
-
-		cancel() {
-			this.$emit('cancel');
-		},
-
-		insertMention() {
-			os.selectUser().then(user => {
-				insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
-			});
-		},
-
-		async insertEmoji(ev) {
-			os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
-		},
-
-		showActions(ev) {
-			os.popupMenu(postFormActions.map(action => ({
-				text: action.title,
-				action: () => {
-					action.handler({
-						text: this.text
-					}, (key, value) => {
-						if (key === 'text') { this.text = value; }
-					});
-				}
-			})), ev.currentTarget || ev.target);
 		}
 	}
+}
+
+if (props.specified) {
+	visibility = 'specified';
+	visibleUsers.push(props.specified);
+}
+
+// keep cw when reply
+if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
+	useCw = true;
+	cw = props.reply.cw;
+}
+
+function watchForDraft() {
+	watch($$(text), () => saveDraft());
+	watch($$(useCw), () => saveDraft());
+	watch($$(cw), () => saveDraft());
+	watch($$(poll), () => saveDraft());
+	watch($$(files), () => saveDraft(), { deep: true });
+	watch($$(visibility), () => saveDraft());
+	watch($$(localOnly), () => saveDraft());
+}
+
+function checkMissingMention() {
+	if (visibility === 'specified') {
+		const ast = mfm.parse(text);
+
+		for (const x of extractMentions(ast)) {
+			if (!visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+				hasNotSpecifiedMentions = true;
+				return;
+			}
+		}
+		hasNotSpecifiedMentions = false;
+	}
+}
+
+function addMissingMention() {
+	const ast = mfm.parse(text);
+
+	for (const x of extractMentions(ast)) {
+		if (!visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+			os.api('users/show', { username: x.username, host: x.host }).then(user => {
+				visibleUsers.push(user);
+			});
+		}
+	}
+}
+
+function togglePoll() {
+	if (poll) {
+		poll = null;
+	} else {
+		poll = {
+			choices: ['', ''],
+			multiple: false,
+			expiresAt: null,
+			expiredAfter: null,
+		};
+	}
+}
+
+function addTag(tag: string) {
+	insertTextAtCursor(textareaEl, ` #${tag} `);
+}
+
+function focus() {
+	textareaEl.focus();
+}
+
+function chooseFileFrom(ev) {
+	selectFiles(ev.currentTarget || ev.target, i18n.locale.attachFile).then(files => {
+		for (const file of files) {
+			files.push(file);
+		}
+	});
+}
+
+function detachFile(id) {
+	files = files.filter(x => x.id != id);
+}
+
+function updateFiles(files) {
+	files = files;
+}
+
+function updateFileSensitive(file, sensitive) {
+	files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+}
+
+function updateFileName(file, name) {
+	files[files.findIndex(x => x.id === file.id)].name = name;
+}
+
+function upload(file: File, name?: string) {
+	os.upload(file, defaultStore.state.uploadFolder, name).then(res => {
+		files.push(res);
+	});
+}
+
+function onPollUpdate(poll) {
+	poll = poll;
+	saveDraft();
+}
+
+function setVisibility() {
+	if (props.channel) {
+		// TODO: information dialog
+		return;
+	}
+
+	os.popup(import('./visibility-picker.vue'), {
+		currentVisibility: visibility,
+		currentLocalOnly: localOnly,
+		src: visibilityButton,
+	}, {
+		changeVisibility: visibility => {
+			visibility = visibility;
+			if (defaultStore.state.rememberNoteVisibility) {
+				defaultStore.set('visibility', visibility);
+			}
+		},
+		changeLocalOnly: localOnly => {
+			localOnly = localOnly;
+			if (defaultStore.state.rememberNoteVisibility) {
+				defaultStore.set('localOnly', localOnly);
+			}
+		}
+	}, 'closed');
+}
+
+function addVisibleUser() {
+	os.selectUser().then(user => {
+		visibleUsers.push(user);
+	});
+}
+
+function removeVisibleUser(user) {
+	visibleUsers = erase(user, visibleUsers);
+}
+
+function clear() {
+	text = '';
+	files = [];
+	poll = null;
+	quoteId = null;
+}
+
+function onKeydown(e: KeyboardEvent) {
+	if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && canPost) post();
+	if (e.which === 27) emit('esc');
+	typing();
+}
+
+function onCompositionUpdate(e: CompositionEvent) {
+	imeText = e.data;
+	typing();
+}
+
+function onCompositionEnd(e: CompositionEvent) {
+	imeText = '';
+}
+
+async function onPaste(e: ClipboardEvent) {
+	for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
+		if (item.kind == 'file') {
+			const file = item.getAsFile();
+			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}`;
+			upload(file, formatted);
+		}
+	}
+
+	const paste = e.clipboardData.getData('text');
+
+	if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) {
+		e.preventDefault();
+
+		os.confirm({
+			type: 'info',
+			text: i18n.locale.quoteQuestion,
+		}).then(({ canceled }) => {
+			if (canceled) {
+				insertTextAtCursor(textareaEl, paste);
+				return;
+			}
+
+			quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+		});
+	}
+}
+
+function onDragover(e) {
+	if (!e.dataTransfer.items[0]) return;
+	const isFile = e.dataTransfer.items[0].kind == 'file';
+	const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+	if (isFile || isDriveFile) {
+		e.preventDefault();
+		draghover = true;
+		e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+	}
+}
+
+function onDragenter(e) {
+	draghover = true;
+}
+
+function onDragleave(e) {
+	draghover = false;
+}
+
+function onDrop(e): void {
+	draghover = false;
+
+	// ファイルだったら
+	if (e.dataTransfer.files.length > 0) {
+		e.preventDefault();
+		for (const x of Array.from(e.dataTransfer.files)) upload(x);
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile != '') {
+		const file = JSON.parse(driveFile);
+		files.push(file);
+		e.preventDefault();
+	}
+	//#endregion
+}
+
+function saveDraft() {
+	const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+	data[draftKey] = {
+		updatedAt: new Date(),
+		data: {
+			text: text,
+			useCw: useCw,
+			cw: cw,
+			visibility: visibility,
+			localOnly: localOnly,
+			files: files,
+			poll: poll
+		}
+	};
+
+	localStorage.setItem('drafts', JSON.stringify(data));
+}
+
+function deleteDraft() {
+	const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+	delete data[draftKey];
+
+	localStorage.setItem('drafts', JSON.stringify(data));
+}
+
+async function post() {
+	let data = {
+		text: text == '' ? undefined : text,
+		fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
+		replyId: props.reply ? props.reply.id : undefined,
+		renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
+		channelId: props.channel ? props.channel.id : undefined,
+		poll: poll,
+		cw: useCw ? cw || '' : undefined,
+		localOnly: localOnly,
+		visibility: visibility,
+		visibleUserIds: visibility == 'specified' ? visibleUsers.map(u => u.id) : undefined,
+	};
+
+	if (withHashtags && hashtags && hashtags.trim() !== '') {
+		const hashtags = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
+		data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
+	}
+
+	// plugin
+	if (notePostInterruptors.length > 0) {
+		for (const interruptor of notePostInterruptors) {
+			data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+		}
+	}
+
+	posting = true;
+	os.api('notes/create', data).then(() => {
+		clear();
+		nextTick(() => {
+			deleteDraft();
+			emit('posted');
+			if (data.text && data.text != '') {
+				const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
+			}
+			posting = false;
+		});
+	}).catch(err => {
+		posting = false;
+		os.alert({
+			type: 'error',
+			text: err.message + '\n' + (err as any).id,
+		});
+	});
+}
+
+function cancel() {
+	emit('cancel');
+}
+
+function insertMention() {
+	os.selectUser().then(user => {
+		insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' ');
+	});
+}
+
+async function insertEmoji(ev) {
+	os.openEmojiPicker(ev.currentTarget || ev.target, {}, textareaEl);
+}
+
+function showActions(ev) {
+	os.popupMenu(postFormActions.map(action => ({
+		text: action.title,
+		action: () => {
+			action.handler({
+				text: text
+			}, (key, value) => {
+				if (key === 'text') { text = value; }
+			});
+		}
+	})), ev.currentTarget || ev.target);
+}
+
+onMounted(() => {
+	if (props.autofocus) {
+		focus();
+
+		nextTick(() => {
+			focus();
+		});
+	}
+
+	// TODO: detach when unmount
+	new Autocomplete(textareaEl, $$(text));
+	new Autocomplete(cwInputEl, $$(cw));
+	new Autocomplete(hashtagsInputEl, $$(hashtags));
+
+	nextTick(() => {
+		// 書きかけの投稿を復元
+		if (!props.share && !props.mention && !props.specified) {
+			const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
+			if (draft) {
+				text = draft.data.text;
+				useCw = draft.data.useCw;
+				cw = draft.data.cw;
+				visibility = draft.data.visibility;
+				localOnly = draft.data.localOnly;
+				files = (draft.data.files || []).filter(e => e);
+				if (draft.data.poll) {
+					poll = draft.data.poll;
+				}
+			}
+		}
+
+		// 削除して編集
+		if (props.initialNote) {
+			const init = props.initialNote;
+			text = init.text ? init.text : '';
+			files = init.files;
+			cw = init.cw;
+			useCw = init.cw != null;
+			if (init.poll) {
+				poll = {
+					choices: init.poll.choices.map(x => x.text),
+					multiple: init.poll.multiple,
+					expiresAt: init.poll.expiresAt,
+					expiredAfter: init.poll.expiredAfter,
+				};
+			}
+			visibility = init.visibility;
+			localOnly = init.localOnly;
+			quoteId = init.renote ? init.renote.id : null;
+		}
+
+		nextTick(() => watchForDraft());
+	});
 });
 </script>
 
diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts
index f2d5806484..f4a3a4c0fc 100644
--- a/packages/client/src/scripts/autocomplete.ts
+++ b/packages/client/src/scripts/autocomplete.ts
@@ -1,4 +1,4 @@
-import { Ref, ref } from 'vue';
+import { nextTick, Ref, ref } from 'vue';
 import * as getCaretCoordinates from 'textarea-caret';
 import { toASCII } from 'punycode/';
 import { popup } from '@/os';
@@ -10,26 +10,23 @@ export class Autocomplete {
 		q: Ref<string | null>;
 		close: Function;
 	} | null;
-	private textarea: any;
-	private vm: any;
+	private textarea: HTMLInputElement | HTMLTextAreaElement;
 	private currentType: string;
-	private opts: {
-		model: string;
-	};
+	private textRef: Ref<string>;
 	private opening: boolean;
 
 	private get text(): string {
-		return this.vm[this.opts.model];
+		return this.textRef.value;
 	}
 
 	private set text(text: string) {
-		this.vm[this.opts.model] = text;
+		this.textRef.value = text;
 	}
 
 	/**
 	 * 対象のテキストエリアを与えてインスタンスを初期化します。
 	 */
-	constructor(textarea, vm, opts) {
+	constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
 		//#region BIND
 		this.onInput = this.onInput.bind(this);
 		this.complete = this.complete.bind(this);
@@ -38,8 +35,7 @@ export class Autocomplete {
 
 		this.suggestion = null;
 		this.textarea = textarea;
-		this.vm = vm;
-		this.opts = opts;
+		this.textRef = textRef;
 		this.opening = false;
 
 		this.attach();
@@ -218,7 +214,7 @@ export class Autocomplete {
 			this.text = `${trimmedBefore}@${acct} ${after}`;
 
 			// キャレットを戻す
-			this.vm.$nextTick(() => {
+			nextTick(() => {
 				this.textarea.focus();
 				const pos = trimmedBefore.length + (acct.length + 2);
 				this.textarea.setSelectionRange(pos, pos);
@@ -234,7 +230,7 @@ export class Autocomplete {
 			this.text = `${trimmedBefore}#${value} ${after}`;
 
 			// キャレットを戻す
-			this.vm.$nextTick(() => {
+			nextTick(() => {
 				this.textarea.focus();
 				const pos = trimmedBefore.length + (value.length + 2);
 				this.textarea.setSelectionRange(pos, pos);
@@ -250,7 +246,7 @@ export class Autocomplete {
 			this.text = trimmedBefore + value + after;
 
 			// キャレットを戻す
-			this.vm.$nextTick(() => {
+			nextTick(() => {
 				this.textarea.focus();
 				const pos = trimmedBefore.length + value.length;
 				this.textarea.setSelectionRange(pos, pos);
@@ -266,7 +262,7 @@ export class Autocomplete {
 			this.text = `${trimmedBefore}$[${value} ]${after}`;
 
 			// キャレットを戻す
-			this.vm.$nextTick(() => {
+			nextTick(() => {
 				this.textarea.focus();
 				const pos = trimmedBefore.length + (value.length + 3);
 				this.textarea.setSelectionRange(pos, pos);