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, 🎨

* test.1
This commit is contained in:
tamaina 2022-06-20 13:20:28 +09:00 committed by Chloe Kudryavtsev
parent 37581ea831
commit 18fea6a36d
7 changed files with 585 additions and 661 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "12.111.1", "version": "12.111.1-test.1",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -1,36 +1,35 @@
<template> <template>
<div ref="rootEl"> <div ref="rootEl">
<slot name="header"></slot> <slot name="header"></slot>
<div ref="bodyEl"> <div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted } from 'vue';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { autoSticky?: boolean;
autoSticky: { }>(), {
type: Boolean, autoSticky: false,
required: false, });
default: false,
},
},
setup(props, context) { const rootEl = $ref<HTMLElement>();
const rootEl = ref<HTMLElement>(null); const bodyEl = $ref<HTMLElement>();
const bodyEl = ref<HTMLElement>(null);
let headerHeight = $ref<string | undefined>();
const calc = () => { const calc = () => {
const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
const header = rootEl.value.children[0]; const header = rootEl.children[0] as HTMLElement;
if (header === bodyEl.value) { if (header === bodyEl) {
bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); bodyEl.style.setProperty('--stickyTop', currentStickyTop);
} else { } else {
bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
headerHeight = header.offsetHeight.toString();
if (props.autoSticky) { if (props.autoSticky) {
header.style.setProperty('--stickyTop', currentStickyTop); header.style.setProperty('--stickyTop', currentStickyTop);
@ -41,32 +40,25 @@ export default defineComponent({
} }
}; };
onMounted(() => {
calc();
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
window.setTimeout(() => { window.setTimeout(() => {
calc(); calc();
}, 100); }, 100);
}); });
observer.observe(rootEl.value, { onMounted(() => {
calc();
observer.observe(rootEl, {
attributes: false, attributes: false,
childList: true, childList: true,
subtree: false, subtree: false,
}); });
});
onUnmounted(() => { onUnmounted(() => {
observer.disconnect(); observer.disconnect();
}); });
});
return {
rootEl,
bodyEl,
};
},
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -5,8 +5,10 @@ export default {
//const query = binding.value; //const query = binding.value;
const header = src.children[0]; const header = src.children[0];
const body = src.children[1];
const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString();
header.style.setProperty('--stickyTop', currentStickyTop); header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky'; header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)'; header.style.top = 'var(--stickyTop)';

View file

@ -1,223 +1,223 @@
<template> <template>
<div class="pemppnzi _block" <div
class="pemppnzi _block"
@dragover.stop="onDragover" @dragover.stop="onDragover"
@drop.stop="onDrop" @drop.stop="onDrop"
> >
<textarea <textarea
ref="text" ref="textEl"
v-model="text" v-model="text"
:placeholder="$ts.inputMessageHere" :placeholder="i18n.ts.inputMessageHere"
@keydown="onKeydown" @keydown="onKeydown"
@compositionupdate="onCompositionUpdate" @compositionupdate="onCompositionUpdate"
@paste="onPaste" @paste="onPaste"
></textarea> ></textarea>
<footer>
<div v-if="file" class="file" @click="file = null">{{ file.name }}</div> <div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send"> <div class="buttons">
<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="chooseFile"><i class="fas fa-photo-video"></i></button>
<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
<input ref="file" type="file" @change="onChangeFile"/> <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> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, defineAsyncComponent } from 'vue'; import { onMounted, watch } from 'vue';
import insertTextAtCursor from 'insert-text-at-cursor'; import * as Misskey from 'misskey-js';
import autosize from 'autosize'; import autosize from 'autosize';
//import insertTextAtCursor from 'insert-text-at-cursor';
import { throttle } from 'throttle-debounce';
import { formatTimeString } from '@/scripts/format-time-string'; import { formatTimeString } from '@/scripts/format-time-string';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { Autocomplete } from '@/scripts/autocomplete'; import { defaultStore } from '@/store';
import { throttle } from 'throttle-debounce'; import { i18n } from '@/i18n';
//import { Autocomplete } from '@/scripts/autocomplete';
import { uploadFile } from '@/scripts/upload'; import { uploadFile } from '@/scripts/upload';
export default defineComponent({ const props = defineProps<{
props: { user?: Misskey.entities.UserDetailed | null;
user: { group?: Misskey.entities.UserGroup | null;
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;
}
},
watch: {
text() {
this.saveDraft();
},
file() {
this.saveDraft();
}
},
mounted() {
autosize(this.$refs.text);
// TODO: detach when unmount let textEl = $ref<HTMLTextAreaElement>();
// TODO let fileEl = $ref<HTMLInputElement>();
//new Autocomplete(this.$refs.text, this, { model: 'text' });
// 稿 let text = $ref<string>('');
const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; let file = $ref<Misskey.entities.DriveFile | null>(null);
if (draft) { let sending = $ref(false);
this.text = draft.data.text; const typing = throttle(3000, () => {
this.file = draft.data.file; stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
} });
},
methods: { let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
async onPaste(evt: ClipboardEvent) { let canSend = $computed(() => (text != null && text !== '') || file != null);
const items = evt.clipboardData.items;
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.length === 1) {
if (items[0].kind === 'file') { if (items[0].kind === 'file') {
const file = items[0].getAsFile(); const pastedFile = items[0].getAsFile();
const lio = file.name.lastIndexOf('.'); if (!pastedFile) return;
const ext = lio >= 0 ? file.name.slice(lio) : ''; const lio = pastedFile.name.lastIndexOf('.');
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
if (formatted) this.upload(file, formatted); const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
if (formatted) upload(pastedFile, formatted);
} }
} else { } else {
if (items[0].kind === 'file') { if (items[0].kind === 'file') {
os.alert({ os.alert({
type: 'error', type: 'error',
text: this.$ts.onlyOneFileCanBeAttached text: i18n.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 { 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 (evt.dataTransfer.files.length === 1) { if (ev.dataTransfer.files.length === 1) {
evt.preventDefault(); ev.preventDefault();
this.upload(evt.dataTransfer.files[0]); upload(ev.dataTransfer.files[0]);
return; return;
} else if (evt.dataTransfer.files.length > 1) { } else if (ev.dataTransfer.files.length > 1) {
evt.preventDefault(); ev.preventDefault();
os.alert({ os.alert({
type: 'error', type: 'error',
text: this.$ts.onlyOneFileCanBeAttached text: i18n.ts.onlyOneFileCanBeAttached,
}); });
return; return;
} }
//#region //#region
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') { if (driveFile != null && driveFile !== '') {
this.file = JSON.parse(driveFile); file = JSON.parse(driveFile);
evt.preventDefault(); ev.preventDefault();
} }
//#endregion //#endregion
},
onKeydown(evt) {
this.typing();
if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
this.send();
} }
},
onCompositionUpdate() { function onKeydown(ev: KeyboardEvent) {
this.typing(); typing();
}, if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
send();
}
}
chooseFile(evt) { function onCompositionUpdate() {
selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => { typing();
this.file = file; }
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
file = selectedFile;
}); });
}, }
onChangeFile() { function onChangeFile() {
this.upload((this.$refs.file as any).files[0]); if (fileEl.files![0]) upload(fileEl.files[0]);
}, }
upload(file: File, name?: string) { function upload(fileToUpload: File, name?: string) {
uploadFile(file, this.$store.state.uploadFolder, name).then(res => { uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
this.file = res; file = res;
}); });
}, }
send() { function send() {
this.sending = true; sending = true;
os.api('messaging/messages/create', { os.api('messaging/messages/create', {
userId: this.user ? this.user.id : undefined, userId: props.user ? props.user.id : undefined,
groupId: this.group ? this.group.id : undefined, groupId: props.group ? props.group.id : undefined,
text: this.text ? this.text : undefined, text: text ? text : undefined,
fileId: this.file ? this.file.id : undefined fileId: file ? file.id : undefined,
}).then(message => { }).then(message => {
this.clear(); clear();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
}).then(() => { }).then(() => {
this.sending = false; sending = false;
}); });
}, }
clear() { function clear() {
this.text = ''; text = '';
this.file = null; file = null;
this.deleteDraft(); deleteDraft();
}, }
saveDraft() { function saveDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
drafts[this.draftKey] = { drafts[draftKey] = {
updatedAt: new Date(), updatedAt: new Date(),
// eslint-disable-next-line id-denylist
data: { data: {
text: this.text, text: text,
file: this.file file: file,
} },
}; };
localStorage.setItem('message_drafts', JSON.stringify(drafts)); localStorage.setItem('message_drafts', JSON.stringify(drafts));
}, }
deleteDraft() { function deleteDraft() {
const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
delete drafts[this.draftKey]; delete drafts[draftKey];
localStorage.setItem('message_drafts', JSON.stringify(drafts)); localStorage.setItem('message_drafts', JSON.stringify(drafts));
}, }
async insertEmoji(ev) { async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text); 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -230,7 +230,7 @@ export default defineComponent({
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
max-width: 100%; max-width: 100%;
height: 80px; min-height: 80px;
margin: 0; margin: 0;
padding: 16px 16px 0 16px; padding: 16px 16px 0 16px;
resize: none; resize: none;
@ -245,27 +245,17 @@ export default defineComponent({
color: var(--fg); color: var(--fg);
} }
footer {
position: sticky;
bottom: 0;
background: var(--panel);
> .file { > .file {
padding: 8px; padding: 8px;
color: #444; color: var(--fg);
background: #eee; background: transparent;
cursor: pointer; cursor: pointer;
} }
> .send {
position: absolute;
bottom: 0;
right: 0;
margin: 0;
padding: 16px;
font-size: 1em;
transition: color 0.1s ease;
color: var(--accent);
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
} }
.files { .files {
@ -316,6 +306,9 @@ export default defineComponent({
} }
} }
.buttons {
display: flex;
._button { ._button {
margin: 0; margin: 0;
padding: 16px; padding: 16px;
@ -334,6 +327,21 @@ export default defineComponent({
} }
} }
> .send {
margin-left: auto;
color: var(--accent);
&:hover {
color: var(--accentLighten);
}
&:active {
color: var(--accentDarken);
transition: color 0s ease;
}
}
}
input[type=file] { input[type=file] {
display: none; display: none;
} }

View file

@ -35,45 +35,28 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import MkUrlPreview from '@/components/url-preview.vue'; import MkUrlPreview from '@/components/url-preview.vue';
import * as os from '@/os'; import * as os from '@/os';
import { $i } from '@/account';
export default defineComponent({ const props = defineProps<{
components: { message: Misskey.entities.MessagingMessage;
MkUrlPreview isGroup?: boolean;
}, }>();
props: {
message: { const isMe = $computed(() => props.message.userId === $i?.id);
required: true const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
},
isGroup: { function del(): void {
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', { os.api('messaging/messages/delete', {
messageId: this.message.id messageId: props.message.id,
}); });
} }
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -266,6 +249,7 @@ export default defineComponent({
&.isMe { &.isMe {
flex-direction: row-reverse; flex-direction: row-reverse;
padding-right: var(--margin); padding-right: var(--margin);
right: var(--margin); // position: absolute使
> .content { > .content {
padding-right: 16px; padding-right: 16px;

View file

@ -1,379 +1,302 @@
<template> <template>
<div class="_section" <div
ref="rootEl"
class="_section"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop" @drop.prevent.stop="onDrop"
> >
<div class="_content mk-messaging-room"> <div class="_content mk-messaging-room">
<div class="body"> <div class="body">
<MkLoading v-if="fetching"/> <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> <template #empty>
<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> <div class="_fullinfo">
<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} <div>{{ i18n.ts.noMessagesYet }}</div>
</button> </div>
<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed> </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"/> <XMessage :key="message.id" :message="message" :is-group="group != null"/>
</XList> </XList>
</template>
</MkPagination>
</div> </div>
<footer> <footer>
<div v-if="typers.length > 0" class="typers"> <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> <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> </template>
</I18n> </I18n>
<MkEllipsis/> <MkEllipsis/>
</div> </div>
<transition :name="$store.state.animation ? 'fade' : ''"> <transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message"> <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> </div>
</transition> </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> </footer>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, defineComponent, markRaw } from 'vue'; import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
import XList from '@/components/date-separated-list.vue'; import * as Misskey from 'misskey-js';
import * as Acct from 'misskey-js/built/acct';
import XMessage from './messaging-room.message.vue'; import XMessage from './messaging-room.message.vue';
import XForm from './messaging-room.form.vue'; import XForm from './messaging-room.form.vue';
import * as Acct from 'misskey-js/built/acct'; import XList from '@/components/date-separated-list.vue';
import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; import MkPagination, { Paging } from '@/components/ui/pagination.vue';
import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { popout } from '@/scripts/popout';
import * as sound from '@/scripts/sound'; import * as sound from '@/scripts/sound';
import * as symbols from '@/symbols'; import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { defaultStore } from '@/store';
const Component = defineComponent({ const props = defineProps<{
components: { userAcct?: string;
XMessage, groupId?: string;
XForm, }>();
XList,
},
inject: ['inWindow'], let rootEl = $ref<HTMLDivElement>();
let formEl = $ref<InstanceType<typeof XForm>>();
let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
props: { let fetching = $ref(true);
userAcct: { let user: Misskey.entities.UserDetailed | null = $ref(null);
type: String, let group: Misskey.entities.UserGroup | null = $ref(null);
required: false, let typers: Misskey.entities.User[] = $ref([]);
}, let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
groupId: { let showIndicator = $ref(false);
type: String, const {
required: false, animation,
}, } = defaultStore.reactiveState;
},
data() { let pagination: Paging | null = $ref(null);
return {
[symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { watch([() => props.userAcct, () => props.groupId], () => {
userName: this.user, if (connection) connection.dispose();
avatar: this.user, fetch();
action: { });
icon: 'fas fa-ellipsis-h',
handler: this.menu, 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,
title: this.group.name, pageEl: $$(rootEl).value,
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()
),
}; };
}, connection = stream.useChannel('messaging', {
otherparty: user.id,
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 { } else {
const group = await os.api('users/groups/show', { groupId: this.groupId }); user = null;
this.group = group; 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,
});
} }
this.connection = markRaw(stream.useChannel('messaging', { connection.on('message', onMessage);
otherparty: this.user ? this.user.id : undefined, connection.on('read', onRead);
group: this.group ? this.group.id : undefined, connection.on('deleted', onDeleted);
})); connection.on('typers', _typers => {
typers = _typers.filter(u => u.id !== $i?.id);
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); document.addEventListener('visibilitychange', onVisibilitychange);
this.fetchMessages().then(() => { nextTick(() => {
this.scrollToBottom(); thisScrollToBottom();
window.setTimeout(() => {
// fetch fetching = false;
// false }, 300);
// scrollendsetTimeout
window.setTimeout(() => this.fetching = false, 300);
}); });
}, }
onDragover(evt) { function onDragover(ev: DragEvent) {
const isFile = evt.dataTransfer.items[0].kind === 'file'; if (!ev.dataTransfer) return;
const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) { if (isFile || isDriveFile) {
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
} else { } else {
evt.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = 'none';
}
} }
},
onDrop(evt): void { function onDrop(ev: DragEvent): void {
if (!ev.dataTransfer) return;
// //
if (evt.dataTransfer.files.length === 1) { if (ev.dataTransfer.files.length === 1) {
this.form.upload(evt.dataTransfer.files[0]); formEl.upload(ev.dataTransfer.files[0]);
return; return;
} else if (evt.dataTransfer.files.length > 1) { } else if (ev.dataTransfer.files.length > 1) {
os.alert({ os.alert({
type: 'error', type: 'error',
text: this.$ts.onlyOneFileCanBeAttached text: i18n.ts.onlyOneFileCanBeAttached,
}); });
return; return;
} }
//#region //#region
const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') { if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile); const file = JSON.parse(driveFile);
this.form.file = file; formEl.file = file;
} }
//#endregion //#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()); function onMessage(message) {
resolve();
});
});
},
fetchMoreMessages() {
this.fetchingMoreMessages = true;
this.fetchMessages().then(() => {
this.fetchingMoreMessages = false;
});
},
onMessage(message) {
sound.play('chat'); sound.play('chat');
const _isBottom = isBottom(this.$el, 64); const _isBottom = isBottomVisible(rootEl, 64);
this.messages.push(message); pagingComponent.prepend(message);
if (message.userId !== this.$i.id && !document.hidden) { if (message.userId !== $i?.id && !document.hidden) {
this.connection.send('read', { connection?.send('read', {
id: message.id id: message.id,
}); });
} }
if (_isBottom) { if (_isBottom) {
// Scroll to bottom // Scroll to bottom
this.$nextTick(() => { nextTick(() => {
this.scrollToBottom(); thisScrollToBottom();
}); });
} else if (message.userId !== this.$i.id) { } else if (message.userId !== $i?.id) {
// Notify // Notify
this.notifyNewMessage(); notifyNewMessage();
}
} }
},
onRead(x) { function onRead(x) {
if (this.user) { if (user) {
if (!Array.isArray(x)) x = [x]; if (!Array.isArray(x)) x = [x];
for (const id of x) { for (const id of x) {
if (this.messages.some(x => x.id === id)) { if (pagingComponent.items.some(y => y.id === id)) {
const exist = this.messages.map(x => x.id).indexOf(id); const exist = pagingComponent.items.map(y => y.id).indexOf(id);
this.messages[exist] = { pagingComponent.items[exist] = {
...this.messages[exist], ...pagingComponent.items[exist],
isRead: true, isRead: true,
}; };
} }
} }
} else if (this.group) { } else if (group) {
for (const id of x.ids) { for (const id of x.ids) {
if (this.messages.some(x => x.id === id)) { if (pagingComponent.items.some(y => y.id === id)) {
const exist = this.messages.map(x => x.id).indexOf(id); const exist = pagingComponent.items.map(y => y.id).indexOf(id);
this.messages[exist] = { pagingComponent.items[exist] = {
...this.messages[exist], ...pagingComponent.items[exist],
reads: [...this.messages[exist].reads, x.userId] reads: [...pagingComponent.items[exist].reads, x.userId],
}; };
} }
} }
} }
}, }
onDeleted(id) { function onDeleted(id) {
const msg = this.messages.find(m => m.id === id); const msg = pagingComponent.items.find(m => m.id === id);
if (msg) { if (msg) {
this.messages = this.messages.filter(m => m.id !== msg.id); pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
}
} }
},
scrollToBottom() { function thisScrollToBottom() {
scroll(this.$el, { top: this.$el.offsetHeight }); scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
}, }
onIndicatorClick() { function onIndicatorClick() {
this.showIndicator = false; showIndicator = false;
this.scrollToBottom(); thisScrollToBottom();
}, }
notifyNewMessage() { let scrollRemove: (() => void) | null = $ref(null);
this.showIndicator = true;
onScrollBottom(this.$el, () => { function notifyNewMessage() {
this.showIndicator = false; showIndicator = true;
scrollRemove = onScrollBottom(rootEl, () => {
showIndicator = false;
scrollRemove = null;
}); });
}
if (this.timer) window.clearTimeout(this.timer); function onVisibilitychange() {
this.timer = window.setTimeout(() => {
this.showIndicator = false;
}, 4000);
},
onVisibilitychange() {
if (document.hidden) return; if (document.hidden) return;
for (const message of this.messages) { for (const message of pagingComponent.items) {
if (message.userId !== this.$i.id && !message.isRead) { if (message.userId !== $i?.id && !message.isRead) {
this.connection.send('read', { connection?.send('read', {
id: message.id 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);
}
} }
onMounted(() => {
fetch();
}); });
export default Component; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-messaging-room { .mk-messaging-room {
position: relative;
> .body { > .body {
> .empty { .more {
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 {
display: block; display: block;
margin: 16px auto; margin: 16px auto;
padding: 0 12px; padding: 0 12px;
@ -399,7 +322,9 @@ export default Component;
} }
} }
> .messages { .messages {
padding: 8px 0;
> ::v-deep(*) { > ::v-deep(*) {
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -408,29 +333,31 @@ export default Component;
> footer { > footer {
width: 100%; 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 { > .new-message {
position: absolute;
top: -48px;
width: 100%; width: 100%;
padding: 8px 0; padding-bottom: 8px;
text-align: center; text-align: center;
> button { > button {
display: inline-block; display: inline-block;
margin: 0; margin: 0;
padding: 0 12px 0 30px; padding: 0 12px;
line-height: 32px; line-height: 32px;
font-size: 12px; font-size: 12px;
border-radius: 16px; border-radius: 16px;
> i { > i {
position: absolute; display: inline-block;
top: 0; margin-right: 8px;
left: 10px;
line-height: 32px;
font-size: 16px;
} }
} }
} }
@ -455,6 +382,8 @@ export default Component;
} }
> .form { > .form {
max-height: 12em;
overflow-y: scroll;
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
} }
} }

View file

@ -1,9 +1,13 @@
type ScrollBehavior = 'auto' | 'smooth' | 'instant'; type ScrollBehavior = 'auto' | 'smooth' | 'instant';
export function getScrollContainer(el: Element | null): Element | null { export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
if (el == null || el.tagName === 'BODY') return null; if (el == null || el.tagName === 'HTML') return null;
const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); 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; return el;
} else { } else {
return getScrollContainer(el.parentElement); return getScrollContainer(el.parentElement);
@ -22,6 +26,11 @@ export function isTopVisible(el: Element | null): boolean {
return scrollTop <= topPosition; 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) { export function onScrollTop(el: Element, cb) {
const container = getScrollContainer(el) || window; const container = getScrollContainer(el) || window;
const onScroll = ev => { const onScroll = ev => {