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