mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-03 23:14:13 +00:00 
			
		
		
		
	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:
		
							parent
							
								
									b70473ed60
								
							
						
					
					
						commit
						30a39a296d
					
				
					 7 changed files with 585 additions and 661 deletions
				
			
		| 
						 | 
					@ -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",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,71 +1,63 @@
 | 
				
			||||||
<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);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		const calc = () => {
 | 
					let headerHeight = $ref<string | undefined>();
 | 
				
			||||||
			const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const header = rootEl.value.children[0];
 | 
					const calc = () => {
 | 
				
			||||||
			if (header === bodyEl.value) {
 | 
						const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
 | 
				
			||||||
				bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
				if (props.autoSticky) {
 | 
						const header = rootEl.children[0] as HTMLElement;
 | 
				
			||||||
					header.style.setProperty('--stickyTop', currentStickyTop);
 | 
						if (header === bodyEl) {
 | 
				
			||||||
					header.style.position = 'sticky';
 | 
							bodyEl.style.setProperty('--stickyTop', currentStickyTop);
 | 
				
			||||||
					header.style.top = 'var(--stickyTop)';
 | 
						} else {
 | 
				
			||||||
					header.style.zIndex = '1';
 | 
							bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
 | 
				
			||||||
				}
 | 
							headerHeight = header.offsetHeight.toString();
 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		onMounted(() => {
 | 
							if (props.autoSticky) {
 | 
				
			||||||
			calc();
 | 
								header.style.setProperty('--stickyTop', currentStickyTop);
 | 
				
			||||||
 | 
								header.style.position = 'sticky';
 | 
				
			||||||
 | 
								header.style.top = 'var(--stickyTop)';
 | 
				
			||||||
 | 
								header.style.zIndex = '1';
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const observer = new MutationObserver(() => {
 | 
					const observer = new MutationObserver(() => {
 | 
				
			||||||
				window.setTimeout(() => {
 | 
						window.setTimeout(() => {
 | 
				
			||||||
					calc();
 | 
							calc();
 | 
				
			||||||
				}, 100);
 | 
						}, 100);
 | 
				
			||||||
			});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			observer.observe(rootEl.value, {
 | 
					onMounted(() => {
 | 
				
			||||||
				attributes: false,
 | 
						calc();
 | 
				
			||||||
				childList: true,
 | 
					 | 
				
			||||||
				subtree: false,
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			onUnmounted(() => {
 | 
						observer.observe(rootEl, {
 | 
				
			||||||
				observer.disconnect();
 | 
							attributes: false,
 | 
				
			||||||
			});
 | 
							childList: true,
 | 
				
			||||||
		});
 | 
							subtree: false,
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return {
 | 
					onUnmounted(() => {
 | 
				
			||||||
			rootEl,
 | 
						observer.disconnect();
 | 
				
			||||||
			bodyEl,
 | 
					 | 
				
			||||||
		};
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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)';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,222 +1,222 @@
 | 
				
			||||||
<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>
 | 
				
			||||||
	<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
 | 
						<footer>
 | 
				
			||||||
	<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
 | 
							<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
 | 
				
			||||||
		<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>
 | 
							<div class="buttons">
 | 
				
			||||||
	</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>
 | 
								<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
 | 
				
			||||||
	<input ref="file" type="file" @change="onChangeFile"/>
 | 
									<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,
 | 
					
 | 
				
			||||||
		},
 | 
					let textEl = $ref<HTMLTextAreaElement>();
 | 
				
			||||||
		group: {
 | 
					let fileEl = $ref<HTMLInputElement>();
 | 
				
			||||||
			type: Object,
 | 
					
 | 
				
			||||||
			requird: false,
 | 
					let text = $ref<string>('');
 | 
				
			||||||
		},
 | 
					let file = $ref<Misskey.entities.DriveFile | null>(null);
 | 
				
			||||||
	},
 | 
					let sending = $ref(false);
 | 
				
			||||||
	data() {
 | 
					const typing = throttle(3000, () => {
 | 
				
			||||||
		return {
 | 
						stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
 | 
				
			||||||
			text: null,
 | 
					});
 | 
				
			||||||
			file: null,
 | 
					
 | 
				
			||||||
			sending: false,
 | 
					let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
 | 
				
			||||||
			typing: throttle(3000, () => {
 | 
					let canSend = $computed(() => (text != null && text !== '') || file != null);
 | 
				
			||||||
				stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
 | 
					
 | 
				
			||||||
			}),
 | 
					watch([$$(text), $$(file)], saveDraft);
 | 
				
			||||||
		};
 | 
					
 | 
				
			||||||
	},
 | 
					async function onPaste(ev: ClipboardEvent) {
 | 
				
			||||||
	computed: {
 | 
						if (!ev.clipboardData) return;
 | 
				
			||||||
		draftKey(): string {
 | 
					
 | 
				
			||||||
			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
 | 
						const clipboardData = ev.clipboardData;
 | 
				
			||||||
		},
 | 
						const items = clipboardData.items;
 | 
				
			||||||
		canSend(): boolean {
 | 
					
 | 
				
			||||||
			return (this.text != null && this.text !== '') || this.file != null;
 | 
						if (items.length === 1) {
 | 
				
			||||||
		},
 | 
							if (items[0].kind === 'file') {
 | 
				
			||||||
		room(): any {
 | 
								const pastedFile = items[0].getAsFile();
 | 
				
			||||||
			return this.$parent;
 | 
								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);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	},
 | 
						} else {
 | 
				
			||||||
	watch: {
 | 
							if (items[0].kind === 'file') {
 | 
				
			||||||
		text() {
 | 
								os.alert({
 | 
				
			||||||
			this.saveDraft();
 | 
									type: 'error',
 | 
				
			||||||
		},
 | 
									text: i18n.ts.onlyOneFileCanBeAttached,
 | 
				
			||||||
		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;
 | 
					 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		},
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		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>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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,26 +245,16 @@ export default defineComponent({
 | 
				
			||||||
		color: var(--fg);
 | 
							color: var(--fg);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	> .file {
 | 
						footer {
 | 
				
			||||||
		padding: 8px;
 | 
							position: sticky;
 | 
				
			||||||
		color: #444;
 | 
					 | 
				
			||||||
		background: #eee;
 | 
					 | 
				
			||||||
		cursor: pointer;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	> .send {
 | 
					 | 
				
			||||||
		position: absolute;
 | 
					 | 
				
			||||||
		bottom: 0;
 | 
							bottom: 0;
 | 
				
			||||||
		right: 0;
 | 
							background: var(--panel);
 | 
				
			||||||
		margin: 0;
 | 
					 | 
				
			||||||
		padding: 16px;
 | 
					 | 
				
			||||||
		font-size: 1em;
 | 
					 | 
				
			||||||
		transition: color 0.1s ease;
 | 
					 | 
				
			||||||
		color: var(--accent);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		&:active {
 | 
							> .file {
 | 
				
			||||||
			color: var(--accentDarken);
 | 
								padding: 8px;
 | 
				
			||||||
			transition: color 0s ease;
 | 
								color: var(--fg);
 | 
				
			||||||
 | 
								background: transparent;
 | 
				
			||||||
 | 
								cursor: pointer;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -316,21 +306,39 @@ export default defineComponent({
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	._button {
 | 
						.buttons {
 | 
				
			||||||
		margin: 0;
 | 
							display: flex;
 | 
				
			||||||
		padding: 16px;
 | 
					 | 
				
			||||||
		font-size: 1em;
 | 
					 | 
				
			||||||
		font-weight: normal;
 | 
					 | 
				
			||||||
		text-decoration: none;
 | 
					 | 
				
			||||||
		transition: color 0.1s ease;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		&:hover {
 | 
							._button {
 | 
				
			||||||
			color: var(--accent);
 | 
								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 {
 | 
							> .send {
 | 
				
			||||||
			color: var(--accentDarken);
 | 
								margin-left: auto;
 | 
				
			||||||
			transition: color 0s ease;
 | 
								color: var(--accent);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								&:hover {
 | 
				
			||||||
 | 
									color: var(--accentLighten);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								&:active {
 | 
				
			||||||
 | 
									color: var(--accentDarken);
 | 
				
			||||||
 | 
									transition: color 0s ease;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
						os.api('messaging/messages/delete', {
 | 
				
			||||||
		}
 | 
							messageId: props.message.id,
 | 
				
			||||||
	},
 | 
						});
 | 
				
			||||||
	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
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
</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;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
				<XMessage :key="message.id" :message="message" :is-group="group != null"/>
 | 
					
 | 
				
			||||||
			</XList>
 | 
									<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>
 | 
							</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 ? {
 | 
					 | 
				
			||||||
				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: {
 | 
					watch([() => props.userAcct, () => props.groupId], () => {
 | 
				
			||||||
		form(): any {
 | 
						if (connection) connection.dispose();
 | 
				
			||||||
			return this.$refs.form;
 | 
						fetch();
 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	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);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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>
 | 
					</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);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue