diff options
Diffstat (limited to 'packages/client/src/ui/chat/post-form.vue')
| -rw-r--r-- | packages/client/src/ui/chat/post-form.vue | 772 |
1 files changed, 772 insertions, 0 deletions
diff --git a/packages/client/src/ui/chat/post-form.vue b/packages/client/src/ui/chat/post-form.vue new file mode 100644 index 0000000000..44461c4a58 --- /dev/null +++ b/packages/client/src/ui/chat/post-form.vue @@ -0,0 +1,772 @@ +<template> +<div class="pxiwixjf" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="form"> + <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button> + </div> + </div> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" /> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <footer> + <div class="left"> + <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> + </div> + <div class="right"> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> + <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> + <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> + <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> + </button> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + </div> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode/'; +import * as mfm from 'mfm-js'; +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 * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; + +export default defineComponent({ + components: { + XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue')) + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: String, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + share: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: 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, + visibleUsers: [], + autocomplete: null, + draghover: false, + quoteId: null, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel }); + } + }), + postFormActions, + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel}` : ''; + + if (this.renote) { + key += `renote:${this.renote.id}`; + } else if (this.reply) { + key += `reply:${this.reply.id}`; + } else { + key += 'note'; + } + + return key; + }, + + 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)]; + } + }, + + submitText(): string { + return this.renote + ? this.$ts.quote + : this.reply + ? this.$ts.reply + : this.$ts.note; + }, + + textLength(): number { + return length((this.text + this.imeText).trim()); + }, + + 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); + }, + + max(): number { + return this.$instance ? this.$instance.maxNoteTextLength : 1000; + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = mfm.parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$i.username == x.username && x.host == null) continue; + if (this.$i.username == x.username && x.host == host) continue; + + // 重複は除外 + 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' }); + + 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 = init.poll; + } + this.visibility = init.visibility; + this.localOnly = init.localOnly; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + 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()); + }, + + 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) { + selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { + for (const file of files) { + this.files.push(file); + } + }); + }, + + 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('@/components/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.dialog({ + type: 'info', + text: this.$ts.quoteQuestion, + showCancelButton: true + }).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 : 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, + viaMobile: isMobile + }; + + // 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 (this.text && this.text != '') { + const hashtags = mfm.parse(this.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.dialog({ + 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); + } + } +}); +</script> + +<style lang="scss" scoped> +.pxiwixjf { + position: relative; + border: solid 0.5px var(--divider); + border-radius: 8px; + + > .form { + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--X4); + + > button { + padding: 4px 8px; + } + } + } + } + + > .cw, + > .text { + display: block; + box-sizing: border-box; + padding: 16px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > .cw { + z-index: 1; + padding-bottom: 8px; + border-bottom: solid 0.5px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 60px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + $height: 44px; + display: flex; + padding: 0 8px 8px 8px; + line-height: $height; + + > .left { + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: $height; + height: $height; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + + > .right { + margin-left: auto; + + > .text-count { + opacity: 0.7; + } + + > .visibility { + width: $height; + margin: 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .submit { + margin: 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + } + + > i { + margin-left: 6px; + } + } + } + } + } +} +</style> |