summaryrefslogtreecommitdiff
path: root/packages/client/src
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2022-06-20 13:20:28 +0900
committerGitHub <noreply@github.com>2022-06-20 13:20:28 +0900
commit30a39a296dcea701deb1cf5ac323aa1e6bcee13f (patch)
tree60d62d0af304b64af3c6178563ce3b7e612cfb1b /packages/client/src
parentfeat: Add Badge Image to Push Notification (#8012) (diff)
downloadmisskey-30a39a296dcea701deb1cf5ac323aa1e6bcee13f.tar.gz
misskey-30a39a296dcea701deb1cf5ac323aa1e6bcee13f.tar.bz2
misskey-30a39a296dcea701deb1cf5ac323aa1e6bcee13f.zip
refactor: チャットルームをComposition API化 (#8850)
* 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
Diffstat (limited to 'packages/client/src')
-rw-r--r--packages/client/src/components/global/sticky-container.vue92
-rw-r--r--packages/client/src/directives/sticky-container.ts2
-rw-r--r--packages/client/src/pages/messaging/messaging-room.form.vue420
-rw-r--r--packages/client/src/pages/messaging/messaging-room.message.vue52
-rw-r--r--packages/client/src/pages/messaging/messaging-room.vue567
-rw-r--r--packages/client/src/scripts/scroll.ts15
6 files changed, 536 insertions, 612 deletions
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,223 +1,223 @@
<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();
+ } else {
+ if (items[0].kind === 'file') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
}
- },
- mounted() {
- autosize(this.$refs.text);
+ }
+}
- // TODO: detach when unmount
- // TODO
- //new Autocomplete(this.$refs.text, this, { model: 'text' });
+function onDragover(ev: DragEvent) {
+ if (!ev.dataTransfer) return;
- // 書きかけの投稿を復元
- 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;
+ 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';
+ }
+}
- 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
- });
- }
- }
- },
+function onDrop(ev: DragEvent): void {
+ if (!ev.dataTransfer) return;
- 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';
- }
- },
+ // ファイルだったら
+ 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;
+ }
- 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 = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile !== '') {
+ file = JSON.parse(driveFile);
+ ev.preventDefault();
+ }
+ //#endregion
+}
- //#region ドライブのファイル
- const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- this.file = JSON.parse(driveFile);
- evt.preventDefault();
- }
- //#endregion
- },
+function onKeydown(ev: KeyboardEvent) {
+ typing();
+ if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
+ send();
+ }
+}
- onKeydown(evt) {
- this.typing();
- if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
- this.send();
- }
- },
+function onCompositionUpdate() {
+ typing();
+}
- onCompositionUpdate() {
- this.typing();
- },
+function chooseFile(ev: MouseEvent) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+ file = selectedFile;
+ });
+}
- chooseFile(evt) {
- selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => {
- this.file = file;
- });
- },
+function onChangeFile() {
+ if (fileEl.files![0]) upload(fileEl.files[0]);
+}
- onChangeFile() {
- this.upload((this.$refs.file as any).files[0]);
- },
+function upload(fileToUpload: File, name?: string) {
+ uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
+ file = res;
+ });
+}
- upload(file: File, name?: string) {
- uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
- this.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;
+ });
+}
- 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;
- });
- },
+function clear() {
+ text = '';
+ file = null;
+ deleteDraft();
+}
+
+function saveDraft() {
+ const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
- clear() {
- this.text = '';
- this.file = null;
- this.deleteDraft();
+ drafts[draftKey] = {
+ updatedAt: new Date(),
+ // eslint-disable-next-line id-denylist
+ data: {
+ text: text,
+ file: file,
},
+ };
- saveDraft() {
- const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+ localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
- drafts[this.draftKey] = {
- updatedAt: new Date(),
- data: {
- text: this.text,
- file: this.file
- }
- };
+function deleteDraft() {
+ const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
- localStorage.setItem('message_drafts', JSON.stringify(drafts));
- },
+ delete drafts[draftKey];
- deleteDraft() {
- const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+ localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
- delete drafts[this.draftKey];
+async function insertEmoji(ev: MouseEvent) {
+ os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
+}
- localStorage.setItem('message_drafts', JSON.stringify(drafts));
- },
+onMounted(() => {
+ autosize(textEl);
- async insertEmoji(ev) {
- os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text);
- }
+ // 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>
<style lang="scss" scoped>
@@ -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()
- ),
- };
- },
-
- 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));
- }
- },
+let pagination: Paging | null = $ref(null);
- 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;
- }
+watch([() => props.userAcct, () => props.groupId], () => {
+ if (connection) connection.dispose();
+ fetch();
+});
- this.connection = markRaw(stream.useChannel('messaging', {
- otherparty: this.user ? this.user.id : undefined,
- group: this.group ? this.group.id : undefined,
- }));
+async function fetch() {
+ fetching = true;
- 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);
- });
+ 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 });
- document.addEventListener('visibilitychange', this.onVisibilitychange);
+ pagination = {
+ endpoint: 'messaging/messages',
+ limit: 20,
+ params: {
+ groupId: group?.id,
+ },
+ reversed: true,
+ pageEl: $$(rootEl).value,
+ };
+ connection = stream.useChannel('messaging', {
+ group: group?.id,
+ });
+ }
- this.fetchMessages().then(() => {
- this.scrollToBottom();
+ connection.on('message', onMessage);
+ connection.on('read', onRead);
+ connection.on('deleted', onDeleted);
+ connection.on('typers', _typers => {
+ typers = _typers.filter(u => u.id !== $i?.id);
+ });
- // もっと見るの交差検知を発火させないためにfetchは
- // スクロールが終わるまでfalseにしておく
- // scrollendのようなイベントはないのでsetTimeoutで
- window.setTimeout(() => this.fetching = false, 300);
- });
- },
+ document.addEventListener('visibilitychange', onVisibilitychange);
- onDragover(evt) {
- const isFile = evt.dataTransfer.items[0].kind === 'file';
- const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+ nextTick(() => {
+ thisScrollToBottom();
+ window.setTimeout(() => {
+ fetching = false;
+ }, 300);
+ });
+}
- if (isFile || isDriveFile) {
- evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
- } else {
- evt.dataTransfer.dropEffect = 'none';
- }
- },
+function onDragover(ev: DragEvent) {
+ if (!ev.dataTransfer) return;
- 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
- },
+ const isFile = ev.dataTransfer.items[0].kind === 'file';
+ const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- fetchMessages() {
- return new Promise((resolve, reject) => {
- const max = this.existMoreMessages ? 20 : 10;
+ if (isFile || isDriveFile) {
+ ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
+ } else {
+ ev.dataTransfer.dropEffect = 'none';
+ }
+}
- 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;
- }
+function onDrop(ev: DragEvent): void {
+ if (!ev.dataTransfer) return;
- this.messages.unshift.apply(this.messages, messages.reverse());
- resolve();
- });
- });
- },
+ // ファイルだったら
+ 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;
+ }
- fetchMoreMessages() {
- this.fetchingMoreMessages = true;
- this.fetchMessages().then(() => {
- this.fetchingMoreMessages = false;
- });
- },
+ //#region ドライブのファイル
+ const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile !== '') {
+ const file = JSON.parse(driveFile);
+ formEl.file = file;
+ }
+ //#endregion
+}
- onMessage(message) {
- sound.play('chat');
+function onMessage(message) {
+ sound.play('chat');
- const _isBottom = isBottom(this.$el, 64);
+ const _isBottom = isBottomVisible(rootEl, 64);
- this.messages.push(message);
- if (message.userId !== this.$i.id && !document.hidden) {
- this.connection.send('read', {
- id: message.id
- });
- }
+ pagingComponent.prepend(message);
+ if (message.userId !== $i?.id && !document.hidden) {
+ 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();
- }
- },
+ if (_isBottom) {
+ // Scroll to bottom
+ nextTick(() => {
+ thisScrollToBottom();
+ });
+ } else if (message.userId !== $i?.id) {
+ // Notify
+ 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]
- };
- }
- }
+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,
+ };
}
- },
-
- onDeleted(id) {
- const msg = this.messages.find(m => m.id === id);
- if (msg) {
- this.messages = this.messages.filter(m => m.id !== msg.id);
+ }
+ } 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],
+ };
}
- },
-
- scrollToBottom() {
- scroll(this.$el, { top: this.$el.offsetHeight });
- },
+ }
+ }
+}
- onIndicatorClick() {
- this.showIndicator = false;
- this.scrollToBottom();
- },
+function onDeleted(id) {
+ const msg = pagingComponent.items.find(m => m.id === id);
+ if (msg) {
+ pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
+ }
+}
- notifyNewMessage() {
- this.showIndicator = true;
+function thisScrollToBottom() {
+ scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
+}
- onScrollBottom(this.$el, () => {
- this.showIndicator = false;
- });
+function onIndicatorClick() {
+ showIndicator = false;
+ thisScrollToBottom();
+}
- if (this.timer) window.clearTimeout(this.timer);
+let scrollRemove: (() => void) | null = $ref(null);
- this.timer = window.setTimeout(() => {
- this.showIndicator = false;
- }, 4000);
- },
+function notifyNewMessage() {
+ showIndicator = true;
- 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}`;
+ scrollRemove = onScrollBottom(rootEl, () => {
+ showIndicator = false;
+ scrollRemove = null;
+ });
+}
- 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);
+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();
});
-export default Component;
+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 {
- > .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;
- }
- }
+ position: relative;
- > .more {
+ > .body {
+ .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 => {