From 30a39a296dcea701deb1cf5ac323aa1e6bcee13f Mon Sep 17 00:00:00 2001 From: tamaina <tamaina@hotmail.co.jp> Date: Mon, 20 Jun 2022 13:20:28 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=E3=83=81=E3=83=A3=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=83=AB=E3=83=BC=E3=83=A0=E3=82=92Composition=20API?= =?UTF-8?q?=E5=8C=96=20(#8850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pick form * pick message * pick room * fix lint * fix scroll? * fix scroll.ts * fix directives/sticky-container * update global/sticky-container.vue * fix, :art: * test.1 --- package.json | 2 +- .../components/global/sticky-container.vue | 92 ++- .../client/src/directives/sticky-container.ts | 2 + .../pages/messaging/messaging-room.form.vue | 460 ++++++------- .../messaging/messaging-room.message.vue | 52 +- .../src/pages/messaging/messaging-room.vue | 623 ++++++++---------- packages/client/src/scripts/scroll.ts | 15 +- 7 files changed, 585 insertions(+), 661 deletions(-) diff --git a/package.json b/package.json index a035745727..fd565f7cec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "12.111.1", + "version": "12.111.1-test.1", "codename": "indigo", "repository": { "type": "git", diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue index 89d397f082..98a7ee9c30 100644 --- a/packages/client/src/components/global/sticky-container.vue +++ b/packages/client/src/components/global/sticky-container.vue @@ -1,71 +1,63 @@ <template> <div ref="rootEl"> <slot name="header"></slot> - <div ref="bodyEl"> + <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> <slot></slot> </div> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted } from 'vue'; -export default defineComponent({ - props: { - autoSticky: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + autoSticky?: boolean; +}>(), { + autoSticky: false, +}); - setup(props, context) { - const rootEl = ref<HTMLElement>(null); - const bodyEl = ref<HTMLElement>(null); +const rootEl = $ref<HTMLElement>(); +const bodyEl = $ref<HTMLElement>(); - const calc = () => { - const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; +let headerHeight = $ref<string | undefined>(); - const header = rootEl.value.children[0]; - if (header === bodyEl.value) { - bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); - } else { - bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); +const calc = () => { + const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px'; - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } - }; + const header = rootEl.children[0] as HTMLElement; + if (header === bodyEl) { + bodyEl.style.setProperty('--stickyTop', currentStickyTop); + } else { + bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + headerHeight = header.offsetHeight.toString(); - onMounted(() => { - calc(); + if (props.autoSticky) { + header.style.setProperty('--stickyTop', currentStickyTop); + header.style.position = 'sticky'; + header.style.top = 'var(--stickyTop)'; + header.style.zIndex = '1'; + } + } +}; - const observer = new MutationObserver(() => { - window.setTimeout(() => { - calc(); - }, 100); - }); +const observer = new MutationObserver(() => { + window.setTimeout(() => { + calc(); + }, 100); +}); - observer.observe(rootEl.value, { - attributes: false, - childList: true, - subtree: false, - }); +onMounted(() => { + calc(); - onUnmounted(() => { - observer.disconnect(); - }); - }); + observer.observe(rootEl, { + attributes: false, + childList: true, + subtree: false, + }); +}); - return { - rootEl, - bodyEl, - }; - }, +onUnmounted(() => { + observer.disconnect(); }); </script> diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts index 9610eba4da..3cf813054b 100644 --- a/packages/client/src/directives/sticky-container.ts +++ b/packages/client/src/directives/sticky-container.ts @@ -5,8 +5,10 @@ export default { //const query = binding.value; const header = src.children[0]; + const body = src.children[1]; const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString(); header.style.setProperty('--stickyTop', currentStickyTop); header.style.position = 'sticky'; header.style.top = 'var(--stickyTop)'; diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue index 8e779c4f39..38bab90502 100644 --- a/packages/client/src/pages/messaging/messaging-room.form.vue +++ b/packages/client/src/pages/messaging/messaging-room.form.vue @@ -1,222 +1,222 @@ <template> -<div class="pemppnzi _block" +<div + class="pemppnzi _block" @dragover.stop="onDragover" @drop.stop="onDrop" > <textarea - ref="text" + ref="textEl" v-model="text" - :placeholder="$ts.inputMessageHere" + :placeholder="i18n.ts.inputMessageHere" @keydown="onKeydown" @compositionupdate="onCompositionUpdate" @paste="onPaste" ></textarea> - <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> - <button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send"> - <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> - </button> - <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> - <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> - <input ref="file" type="file" @change="onChangeFile"/> + <footer> + <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> + <div class="buttons"> + <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> + <button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + </div> + </footer> + <input ref="fileEl" type="file" @change="onChangeFile"/> </div> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import insertTextAtCursor from 'insert-text-at-cursor'; +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import autosize from 'autosize'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; import { formatTimeString } from '@/scripts/format-time-string'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; import { stream } from '@/stream'; -import { Autocomplete } from '@/scripts/autocomplete'; -import { throttle } from 'throttle-debounce'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +//import { Autocomplete } from '@/scripts/autocomplete'; import { uploadFile } from '@/scripts/upload'; -export default defineComponent({ - props: { - user: { - type: Object, - requird: false, - }, - group: { - type: Object, - requird: false, - }, - }, - data() { - return { - text: null, - file: null, - sending: false, - typing: throttle(3000, () => { - stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); - }), - }; - }, - computed: { - draftKey(): string { - return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; - }, - canSend(): boolean { - return (this.text != null && this.text !== '') || this.file != null; - }, - room(): any { - return this.$parent; +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + group?: Misskey.entities.UserGroup | null; +}>(); + +let textEl = $ref<HTMLTextAreaElement>(); +let fileEl = $ref<HTMLInputElement>(); + +let text = $ref<string>(''); +let file = $ref<Misskey.entities.DriveFile | null>(null); +let sending = $ref(false); +const typing = throttle(3000, () => { + stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id }); +}); + +let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); +let canSend = $computed(() => (text != null && text !== '') || file != null); + +watch([$$(text), $$(file)], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); } - }, - watch: { - text() { - this.saveDraft(); - }, - file() { - this.saveDraft(); - } - }, - mounted() { - autosize(this.$refs.text); - - // TODO: detach when unmount - // TODO - //new Autocomplete(this.$refs.text, this, { model: 'text' }); - - // 書きかけの投稿を復元 - const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; - if (draft) { - this.text = draft.data.text; - this.file = draft.data.file; - } - }, - methods: { - async onPaste(evt: ClipboardEvent) { - const items = evt.clipboardData.items; - - if (items.length === 1) { - if (items[0].kind === 'file') { - const file = items[0].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, '1')}${ext}`; - if (formatted) this.upload(file, formatted); - } - } else { - if (items[0].kind === 'file') { - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - } - } - }, - - onDragover(evt) { - const isFile = evt.dataTransfer.items[0].kind === 'file'; - const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { - evt.preventDefault(); - evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; - } - }, - - onDrop(evt): void { - // ファイルだったら - if (evt.dataTransfer.files.length === 1) { - evt.preventDefault(); - this.upload(evt.dataTransfer.files[0]); - return; - } else if (evt.dataTransfer.files.length > 1) { - evt.preventDefault(); - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - return; - } - - //#region ドライブのファイル - const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - this.file = JSON.parse(driveFile); - evt.preventDefault(); - } - //#endregion - }, - - onKeydown(evt) { - this.typing(); - if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) { - this.send(); - } - }, - - onCompositionUpdate() { - this.typing(); - }, - - chooseFile(evt) { - selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => { - this.file = file; + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, }); - }, - - onChangeFile() { - this.upload((this.$refs.file as any).files[0]); - }, - - upload(file: File, name?: string) { - uploadFile(file, this.$store.state.uploadFolder, name).then(res => { - this.file = res; - }); - }, - - send() { - this.sending = true; - os.api('messaging/messages/create', { - userId: this.user ? this.user.id : undefined, - groupId: this.group ? this.group.id : undefined, - text: this.text ? this.text : undefined, - fileId: this.file ? this.file.id : undefined - }).then(message => { - this.clear(); - }).catch(err => { - console.error(err); - }).then(() => { - this.sending = false; - }); - }, - - clear() { - this.text = ''; - this.file = null; - this.deleteDraft(); - }, - - saveDraft() { - const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); - - drafts[this.draftKey] = { - updatedAt: new Date(), - data: { - text: this.text, - file: this.file - } - }; - - localStorage.setItem('message_drafts', JSON.stringify(drafts)); - }, - - deleteDraft() { - const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); - - delete drafts[this.draftKey]; - - localStorage.setItem('message_drafts', JSON.stringify(drafts)); - }, - - async insertEmoji(ev) { - os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text); } } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + typing(); + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) { + send(); + } +} + +function onCompositionUpdate() { + typing(); +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.files![0]) upload(fileEl.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => { + file = res; + }); +} + +function send() { + sending = true; + os.api('messaging/messages/create', { + userId: props.user ? props.user.id : undefined, + groupId: props.group ? props.group.id : undefined, + text: text ? text : undefined, + fileId: file ? file.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending = false; + }); +} + +function clear() { + text = ''; + file = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + drafts[draftKey] = { + updatedAt: new Date(), + // eslint-disable-next-line id-denylist + data: { + text: text, + file: file, + }, + }; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete drafts[draftKey]; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl); +} + +onMounted(() => { + autosize(textEl); + + // TODO: detach when unmount + // TODO + //new Autocomplete(textEl, this, { model: 'text' }); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey]; + if (draft) { + text = draft.data.text; + file = draft.data.file; + } +}); + +defineExpose({ + file, + upload, }); </script> @@ -230,7 +230,7 @@ export default defineComponent({ width: 100%; min-width: 100%; max-width: 100%; - height: 80px; + min-height: 80px; margin: 0; padding: 16px 16px 0 16px; resize: none; @@ -245,26 +245,16 @@ export default defineComponent({ color: var(--fg); } - > .file { - padding: 8px; - color: #444; - background: #eee; - cursor: pointer; - } - - > .send { - position: absolute; + footer { + position: sticky; bottom: 0; - right: 0; - margin: 0; - padding: 16px; - font-size: 1em; - transition: color 0.1s ease; - color: var(--accent); + background: var(--panel); - &:active { - color: var(--accentDarken); - transition: color 0s ease; + > .file { + padding: 8px; + color: var(--fg); + background: transparent; + cursor: pointer; } } @@ -316,21 +306,39 @@ export default defineComponent({ } } - ._button { - margin: 0; - padding: 16px; - font-size: 1em; - font-weight: normal; - text-decoration: none; - transition: color 0.1s ease; + .buttons { + display: flex; - &:hover { - color: var(--accent); + ._button { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + transition: color 0.1s ease; + + &:hover { + color: var(--accent); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } } - &:active { - color: var(--accentDarken); - transition: color 0s ease; + > .send { + margin-left: auto; + color: var(--accent); + + &:hover { + color: var(--accentLighten); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } } } diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue index 4315bbecdb..393d2a17b2 100644 --- a/packages/client/src/pages/messaging/messaging-room.message.vue +++ b/packages/client/src/pages/messaging/messaging-room.message.vue @@ -35,45 +35,28 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import MkUrlPreview from '@/components/url-preview.vue'; import * as os from '@/os'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - MkUrlPreview - }, - props: { - message: { - required: true - }, - isGroup: { - required: false - } - }, - computed: { - isMe(): boolean { - return this.message.userId === this.$i.id; - }, - urls(): string[] { - if (this.message.text) { - return extractUrlFromMfm(mfm.parse(this.message.text)); - } else { - return []; - } - } - }, - methods: { - del() { - os.api('messaging/messages/delete', { - messageId: this.message.id - }); - } - } -}); +const props = defineProps<{ + message: Misskey.entities.MessagingMessage; + isGroup?: boolean; +}>(); + +const isMe = $computed(() => props.message.userId === $i?.id); +const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function del(): void { + os.api('messaging/messages/delete', { + messageId: props.message.id, + }); +} </script> <style lang="scss" scoped> @@ -266,6 +249,7 @@ export default defineComponent({ &.isMe { flex-direction: row-reverse; padding-right: var(--margin); + right: var(--margin); // 削除時にposition: absoluteになったときに使う > .content { padding-right: 16px; diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue index fd1962218a..65c67e6354 100644 --- a/packages/client/src/pages/messaging/messaging-room.vue +++ b/packages/client/src/pages/messaging/messaging-room.vue @@ -1,379 +1,302 @@ <template> -<div class="_section" +<div + ref="rootEl" + class="_section" @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > <div class="_content mk-messaging-room"> <div class="body"> - <MkLoading v-if="fetching"/> - <p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> - <p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> - <button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages"> - <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} - </button> - <XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed> - <XMessage :key="message.id" :message="message" :is-group="group != null"/> - </XList> + <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noMessagesYet }}</div> + </div> + </template> + + <template #default="{ items: messages, fetching: pFetching }"> + <XList + v-if="messages.length > 0" + v-slot="{ item: message }" + :class="{ messages: true, 'deny-move-transition': pFetching }" + :items="messages" + direction="up" + reversed + > + <XMessage :key="message.id" :message="message" :is-group="group != null"/> + </XList> + </template> + </MkPagination> </div> <footer> <div v-if="typers.length > 0" class="typers"> - <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> <template #users> - <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> </template> </I18n> <MkEllipsis/> </div> - <transition :name="$store.state.animation ? 'fade' : ''"> + <transition :name="animation ? 'fade' : ''"> <div v-show="showIndicator" class="new-message"> - <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> </div> </transition> - <XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> </footer> </div> </div> </template> -<script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import XList from '@/components/date-separated-list.vue'; +<script lang="ts" setup> +import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; -import * as Acct from 'misskey-js/built/acct'; -import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; +import XList from '@/components/date-separated-list.vue'; +import MkPagination, { Paging } from '@/components/ui/pagination.vue'; +import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; import * as os from '@/os'; import { stream } from '@/stream'; -import { popout } from '@/scripts/popout'; import * as sound from '@/scripts/sound'; import * as symbols from '@/symbols'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; -const Component = defineComponent({ - components: { - XMessage, - XForm, - XList, - }, +const props = defineProps<{ + userAcct?: string; + groupId?: string; +}>(); - inject: ['inWindow'], +let rootEl = $ref<HTMLDivElement>(); +let formEl = $ref<InstanceType<typeof XForm>>(); +let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); - props: { - userAcct: { - type: String, - required: false, - }, - groupId: { - type: String, - required: false, - }, - }, +let fetching = $ref(true); +let user: Misskey.entities.UserDetailed | null = $ref(null); +let group: Misskey.entities.UserGroup | null = $ref(null); +let typers: Misskey.entities.User[] = $ref([]); +let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null); +let showIndicator = $ref(false); +const { + animation, +} = defaultStore.reactiveState; - data() { - return { - [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { - userName: this.user, - avatar: this.user, - action: { - icon: 'fas fa-ellipsis-h', - handler: this.menu, - }, - } : { - title: this.group.name, - icon: 'fas fa-users', - action: { - icon: 'fas fa-ellipsis-h', - handler: this.menu, - }, - } : null), - fetching: true, - user: null, - group: null, - fetchingMoreMessages: false, - messages: [], - existMoreMessages: false, - connection: null, - showIndicator: false, - timer: null, - typers: [], - ilObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.fetching - && !this.fetchingMoreMessages - && this.existMoreMessages - && this.fetchMoreMessages() - ), - }; - }, +let pagination: Paging | null = $ref(null); - computed: { - form(): any { - return this.$refs.form; - } - }, - - watch: { - userAcct: 'fetch', - groupId: 'fetch', - }, - - mounted() { - this.fetch(); - if (this.$store.state.enableInfiniteScroll) { - this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element)); - } - }, - - beforeUnmount() { - this.connection.dispose(); - - document.removeEventListener('visibilitychange', this.onVisibilitychange); - - this.ilObserver.disconnect(); - }, - - methods: { - async fetch() { - this.fetching = true; - if (this.userAcct) { - const user = await os.api('users/show', Acct.parse(this.userAcct)); - this.user = user; - } else { - const group = await os.api('users/groups/show', { groupId: this.groupId }); - this.group = group; - } - - this.connection = markRaw(stream.useChannel('messaging', { - otherparty: this.user ? this.user.id : undefined, - group: this.group ? this.group.id : undefined, - })); - - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - this.connection.on('deleted', this.onDeleted); - this.connection.on('typers', typers => { - this.typers = typers.filter(u => u.id !== this.$i.id); - }); - - document.addEventListener('visibilitychange', this.onVisibilitychange); - - this.fetchMessages().then(() => { - this.scrollToBottom(); - - // もっと見るの交差検知を発火させないためにfetchは - // スクロールが終わるまでfalseにしておく - // scrollendのようなイベントはないのでsetTimeoutで - window.setTimeout(() => this.fetching = false, 300); - }); - }, - - onDragover(evt) { - const isFile = evt.dataTransfer.items[0].kind === 'file'; - const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - - if (isFile || isDriveFile) { - evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; - } else { - evt.dataTransfer.dropEffect = 'none'; - } - }, - - onDrop(evt): void { - // ファイルだったら - if (evt.dataTransfer.files.length === 1) { - this.form.upload(evt.dataTransfer.files[0]); - return; - } else if (evt.dataTransfer.files.length > 1) { - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - return; - } - - //#region ドライブのファイル - const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - this.form.file = file; - } - //#endregion - }, - - fetchMessages() { - return new Promise((resolve, reject) => { - const max = this.existMoreMessages ? 20 : 10; - - os.api('messaging/messages', { - userId: this.user ? this.user.id : undefined, - groupId: this.group ? this.group.id : undefined, - limit: max + 1, - untilId: this.existMoreMessages ? this.messages[0].id : undefined - }).then(messages => { - if (messages.length === max + 1) { - this.existMoreMessages = true; - messages.pop(); - } else { - this.existMoreMessages = false; - } - - this.messages.unshift.apply(this.messages, messages.reverse()); - resolve(); - }); - }); - }, - - fetchMoreMessages() { - this.fetchingMoreMessages = true; - this.fetchMessages().then(() => { - this.fetchingMoreMessages = false; - }); - }, - - onMessage(message) { - sound.play('chat'); - - const _isBottom = isBottom(this.$el, 64); - - this.messages.push(message); - if (message.userId !== this.$i.id && !document.hidden) { - this.connection.send('read', { - id: message.id - }); - } - - if (_isBottom) { - // Scroll to bottom - this.$nextTick(() => { - this.scrollToBottom(); - }); - } else if (message.userId !== this.$i.id) { - // Notify - this.notifyNewMessage(); - } - }, - - onRead(x) { - if (this.user) { - if (!Array.isArray(x)) x = [x]; - for (const id of x) { - if (this.messages.some(x => x.id === id)) { - const exist = this.messages.map(x => x.id).indexOf(id); - this.messages[exist] = { - ...this.messages[exist], - isRead: true, - }; - } - } - } else if (this.group) { - for (const id of x.ids) { - if (this.messages.some(x => x.id === id)) { - const exist = this.messages.map(x => x.id).indexOf(id); - this.messages[exist] = { - ...this.messages[exist], - reads: [...this.messages[exist].reads, x.userId] - }; - } - } - } - }, - - onDeleted(id) { - const msg = this.messages.find(m => m.id === id); - if (msg) { - this.messages = this.messages.filter(m => m.id !== msg.id); - } - }, - - scrollToBottom() { - scroll(this.$el, { top: this.$el.offsetHeight }); - }, - - onIndicatorClick() { - this.showIndicator = false; - this.scrollToBottom(); - }, - - notifyNewMessage() { - this.showIndicator = true; - - onScrollBottom(this.$el, () => { - this.showIndicator = false; - }); - - if (this.timer) window.clearTimeout(this.timer); - - this.timer = window.setTimeout(() => { - this.showIndicator = false; - }, 4000); - }, - - onVisibilitychange() { - if (document.hidden) return; - for (const message of this.messages) { - if (message.userId !== this.$i.id && !message.isRead) { - this.connection.send('read', { - id: message.id - }); - } - } - }, - - menu(ev) { - const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; - - os.popupMenu([this.inWindow ? undefined : { - text: this.$ts.openInWindow, - icon: 'fas fa-window-maximize', - action: () => { - os.pageWindow(path); - this.$router.back(); - }, - }, this.inWindow ? undefined : { - text: this.$ts.popout, - icon: 'fas fa-external-link-alt', - action: () => { - popout(path); - this.$router.back(); - }, - }], ev.currentTarget ?? ev.target); - } - } +watch([() => props.userAcct, () => props.groupId], () => { + if (connection) connection.dispose(); + fetch(); }); -export default Component; +async function fetch() { + fetching = true; + + if (props.userAcct) { + const acct = Acct.parse(props.userAcct); + user = await os.api('users/show', { username: acct.username, host: acct.host || undefined }); + group = null; + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + userId: user.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + otherparty: user.id, + }); + } else { + user = null; + group = await os.api('users/groups/show', { groupId: props.groupId }); + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + groupId: group?.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + group: group?.id, + }); + } + + connection.on('message', onMessage); + connection.on('read', onRead); + connection.on('deleted', onDeleted); + connection.on('typers', _typers => { + typers = _typers.filter(u => u.id !== $i?.id); + }); + + document.addEventListener('visibilitychange', onVisibilitychange); + + nextTick(() => { + thisScrollToBottom(); + window.setTimeout(() => { + fetching = false; + }, 300); + }); +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + + if (isFile || isDriveFile) { + ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + } else { + ev.dataTransfer.dropEffect = 'none'; + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + formEl.upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + formEl.file = file; + } + //#endregion +} + +function onMessage(message) { + sound.play('chat'); + + const _isBottom = isBottomVisible(rootEl, 64); + + pagingComponent.prepend(message); + if (message.userId !== $i?.id && !document.hidden) { + connection?.send('read', { + id: message.id, + }); + } + + if (_isBottom) { + // Scroll to bottom + nextTick(() => { + thisScrollToBottom(); + }); + } else if (message.userId !== $i?.id) { + // Notify + notifyNewMessage(); + } +} + +function onRead(x) { + if (user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + isRead: true, + }; + } + } + } else if (group) { + for (const id of x.ids) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + reads: [...pagingComponent.items[exist].reads, x.userId], + }; + } + } + } +} + +function onDeleted(id) { + const msg = pagingComponent.items.find(m => m.id === id); + if (msg) { + pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id); + } +} + +function thisScrollToBottom() { + scrollToBottom($$(rootEl).value, { behavior: 'smooth' }); +} + +function onIndicatorClick() { + showIndicator = false; + thisScrollToBottom(); +} + +let scrollRemove: (() => void) | null = $ref(null); + +function notifyNewMessage() { + showIndicator = true; + + scrollRemove = onScrollBottom(rootEl, () => { + showIndicator = false; + scrollRemove = null; + }); +} + +function onVisibilitychange() { + if (document.hidden) return; + for (const message of pagingComponent.items) { + if (message.userId !== $i?.id && !message.isRead) { + connection?.send('read', { + id: message.id, + }); + } + } +} + +onMounted(() => { + fetch(); +}); + +onBeforeUnmount(() => { + connection?.dispose(); + document.removeEventListener('visibilitychange', onVisibilitychange); + if (scrollRemove) scrollRemove(); +}); + +defineExpose({ + [symbols.PAGE_INFO]: computed(() => !fetching ? user ? { + userName: user, + avatar: user, + } : { + title: group?.name, + icon: 'fas fa-users', + } : null), +}); </script> <style lang="scss" scoped> .mk-messaging-room { + position: relative; + > .body { - > .empty { - width: 100%; - margin: 0; - padding: 16px 8px 8px 8px; - text-align: center; - font-size: 0.8em; - opacity: 0.5; - - i { - margin-right: 4px; - } - } - - > .no-history { - display: block; - margin: 0; - padding: 16px; - text-align: center; - font-size: 0.8em; - color: var(--messagingRoomInfo); - opacity: 0.5; - - i { - margin-right: 4px; - } - } - - > .more { + .more { display: block; margin: 16px auto; padding: 0 12px; @@ -399,7 +322,9 @@ export default Component; } } - > .messages { + .messages { + padding: 8px 0; + > ::v-deep(*) { margin-bottom: 16px; } @@ -408,29 +333,31 @@ export default Component; > footer { width: 100%; - position: relative; + position: sticky; + z-index: 2; + bottom: 0; + padding-top: 8px; + + @media (max-width: 500px) { + bottom: calc(env(safe-area-inset-bottom, 0px) + 92px); + } > .new-message { - position: absolute; - top: -48px; width: 100%; - padding: 8px 0; + padding-bottom: 8px; text-align: center; > button { display: inline-block; margin: 0; - padding: 0 12px 0 30px; + padding: 0 12px; line-height: 32px; font-size: 12px; border-radius: 16px; > i { - position: absolute; - top: 0; - left: 10px; - line-height: 32px; - font-size: 16px; + display: inline-block; + margin-right: 8px; } } } @@ -455,6 +382,8 @@ export default Component; } > .form { + max-height: 12em; + overflow-y: scroll; border-top: solid 0.5px var(--divider); } } diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts index 621fe88105..0643bad2fb 100644 --- a/packages/client/src/scripts/scroll.ts +++ b/packages/client/src/scripts/scroll.ts @@ -1,9 +1,13 @@ type ScrollBehavior = 'auto' | 'smooth' | 'instant'; -export function getScrollContainer(el: Element | null): Element | null { - if (el == null || el.tagName === 'BODY') return null; +export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (el == null || el.tagName === 'HTML') return null; const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); - if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる + if ( + // xとyを個別に指定している場合、`hidden scroll`みたいな値になる + overflow.endsWith('scroll') || + overflow.endsWith('auto') + ) { return el; } else { return getScrollContainer(el.parentElement); @@ -22,6 +26,11 @@ export function isTopVisible(el: Element | null): boolean { return scrollTop <= topPosition; } +export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { + if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; + return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; +} + export function onScrollTop(el: Element, cb) { const container = getScrollContainer(el) || window; const onScroll = ev => {