messaaging-room.form.vue rewrite to compositon api
This commit is contained in:
		
							parent
							
								
									9923cfaf50
								
							
						
					
					
						commit
						19af8e845f
					
				
					 4 changed files with 197 additions and 201 deletions
				
			
		|  | @ -17,7 +17,7 @@ import MkWindow from '@/components/ui/window.vue'; | |||
| import MkEmojiPicker from '@/components/emoji-picker.vue'; | ||||
| 
 | ||||
| withDefaults(defineProps<{ | ||||
| 	src?: HTMLElement; | ||||
| 	src?: HTMLElement | EventTarget; | ||||
| 	showPinned?: boolean; | ||||
| 	asReactionPicker?: boolean; | ||||
| }>(), { | ||||
|  |  | |||
|  | @ -422,7 +422,7 @@ type AwaitType<T> = | |||
| 	T; | ||||
| let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; | ||||
| let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; | ||||
| export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { | ||||
| export async function openEmojiPicker(src?: HTMLElement | EventTarget | null, opts, initialTextarea: typeof activeTextarea) { | ||||
| 	if (openingEmojiPicker) return; | ||||
| 
 | ||||
| 	activeTextarea = initialTextarea; | ||||
|  |  | |||
|  | @ -4,219 +4,212 @@ | |||
| 	@drop.stop="onDrop" | ||||
| > | ||||
| 	<textarea | ||||
| 		ref="text" | ||||
| 		ref="textEl" | ||||
| 		v-model="text" | ||||
| 		:placeholder="$ts.inputMessageHere" | ||||
| 		:placeholder="i18n.locale.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"> | ||||
| 	<button class="send _button" :disabled="!canSend || sending" :title="i18n.locale.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"/> | ||||
| 	<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 { 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'; | ||||
| 
 | ||||
| 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: string = $ref(''); | ||||
| let file: Misskey.entities.DriveFile | null = $ref(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); | ||||
| 
 | ||||
| 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; | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| async function onPaste(e: ClipboardEvent) { | ||||
| 	if (!e.clipboardData) return; | ||||
| 
 | ||||
| 	const data = e.clipboardData; | ||||
| 	const items = data.items; | ||||
| 
 | ||||
| 	if (items.length == 1) { | ||||
| 		if (items[0].kind == 'file') { | ||||
| 			const file = items[0].getAsFile(); | ||||
| 			if (!file) return; | ||||
| 			const lio = file.name.lastIndexOf('.'); | ||||
| 			const ext = lio >= 0 ? file.name.slice(lio) : ''; | ||||
| 			const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; | ||||
| 			if (formatted) upload(file, 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(e: ClipboardEvent) { | ||||
| 			const data = e.clipboardData; | ||||
| 			const items = data.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(e) { | ||||
| 			const isFile = e.dataTransfer.items[0].kind == 'file'; | ||||
| 			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; | ||||
| 			if (isFile || isDriveFile) { | ||||
| 				e.preventDefault(); | ||||
| 				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onDrop(e): void { | ||||
| 			// ファイルだったら | ||||
| 			if (e.dataTransfer.files.length == 1) { | ||||
| 				e.preventDefault(); | ||||
| 				this.upload(e.dataTransfer.files[0]); | ||||
| 				return; | ||||
| 			} else if (e.dataTransfer.files.length > 1) { | ||||
| 				e.preventDefault(); | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					text: this.$ts.onlyOneFileCanBeAttached | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			//#region ドライブのファイル | ||||
| 			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); | ||||
| 			if (driveFile != null && driveFile != '') { | ||||
| 				this.file = JSON.parse(driveFile); | ||||
| 				e.preventDefault(); | ||||
| 			} | ||||
| 			//#endregion | ||||
| 		}, | ||||
| 
 | ||||
| 		onKeydown(e) { | ||||
| 			this.typing(); | ||||
| 			if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { | ||||
| 				this.send(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onCompositionUpdate() { | ||||
| 			this.typing(); | ||||
| 		}, | ||||
| 
 | ||||
| 		chooseFile(e) { | ||||
| 			selectFile(e.currentTarget || e.target, this.$ts.selectFile).then(file => { | ||||
| 				this.file = file; | ||||
| 	} else { | ||||
| 		if (items[0].kind == 'file') { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: i18n.locale.onlyOneFileCanBeAttached | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		onChangeFile() { | ||||
| 			this.upload((this.$refs.file as any).files[0]); | ||||
| 		}, | ||||
| 
 | ||||
| 		upload(file: File, name?: string) { | ||||
| 			os.upload(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 data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 			data[this.draftKey] = { | ||||
| 				updatedAt: new Date(), | ||||
| 				data: { | ||||
| 					text: this.text, | ||||
| 					file: this.file | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
| 
 | ||||
| 		deleteDraft() { | ||||
| 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 			delete data[this.draftKey]; | ||||
| 
 | ||||
| 			localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||
| 		}, | ||||
| 
 | ||||
| 		async insertEmoji(ev) { | ||||
| 			os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function onDragover(e: DragEvent) { | ||||
| 	if (!e.dataTransfer) return; | ||||
| 
 | ||||
| 	const isFile = e.dataTransfer.items[0].kind == 'file'; | ||||
| 	const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; | ||||
| 	if (isFile || isDriveFile) { | ||||
| 		e.preventDefault(); | ||||
| 		e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function onDrop(e: DragEvent): void { | ||||
| 	if (!e.dataTransfer) return; | ||||
| 
 | ||||
| 	// ファイルだったら | ||||
| 	if (e.dataTransfer.files.length == 1) { | ||||
| 		e.preventDefault(); | ||||
| 		upload(e.dataTransfer.files[0]); | ||||
| 		return; | ||||
| 	} else if (e.dataTransfer.files.length > 1) { | ||||
| 		e.preventDefault(); | ||||
| 		os.alert({ | ||||
| 			type: 'error', | ||||
| 			text: i18n.locale.onlyOneFileCanBeAttached | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	//#region ドライブのファイル | ||||
| 	const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); | ||||
| 	if (driveFile != null && driveFile != '') { | ||||
| 		file = JSON.parse(driveFile); | ||||
| 		e.preventDefault(); | ||||
| 	} | ||||
| 	//#endregion | ||||
| } | ||||
| 
 | ||||
| function onKeydown(e: KeyboardEvent) { | ||||
| 	typing(); | ||||
| 	if ((e.key === 'Enter') && (e.ctrlKey || e.metaKey) && canSend) { | ||||
| 		send(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function onCompositionUpdate() { | ||||
| 	typing(); | ||||
| } | ||||
| 
 | ||||
| function chooseFile(e: MouseEvent) { | ||||
| 	selectFile(e.currentTarget || e.target, i18n.locale.selectFile).then(file => { | ||||
| 		file = file; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function onChangeFile() { | ||||
| 	if (fileEl?.files![0]) upload(fileEl.files[0]); | ||||
| } | ||||
| 
 | ||||
| function upload(fileToUpload: File, name?: string) { | ||||
| 	os.upload(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 data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 	data[draftKey] = { | ||||
| 		updatedAt: new Date(), | ||||
| 		data: { | ||||
| 			text: text, | ||||
| 			file: file | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||
| } | ||||
| 
 | ||||
| function deleteDraft() { | ||||
| 	const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); | ||||
| 
 | ||||
| 	delete data[draftKey]; | ||||
| 
 | ||||
| 	localStorage.setItem('message_drafts', JSON.stringify(data)); | ||||
| } | ||||
| 
 | ||||
| async function insertEmoji(ev: MouseEvent) { | ||||
| 	os.openEmojiPicker(ev.currentTarget || ev.target, {}, textEl); | ||||
| } | ||||
| 
 | ||||
| defineExpose({ | ||||
| 	file, | ||||
| 	upload, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -63,7 +63,7 @@ const props = defineProps<{ | |||
| 	groupId?: string; | ||||
| }>(); | ||||
| 
 | ||||
| let rootEl = $ref<Element>(); | ||||
| let rootEl = $ref<HTMLDivElement>(); | ||||
| let formEl = $ref<InstanceType<typeof XForm>>(); | ||||
| let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); | ||||
| 
 | ||||
|  | @ -88,16 +88,18 @@ async function fetch() { | |||
| 	fetching = true; | ||||
| 
 | ||||
| 	if (props.userAcct) { | ||||
| 		user = await os.api('users/show', Acct.parse(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), | ||||
| 			pageEl: $$(rootEl).value, | ||||
| 		}; | ||||
| 		connection = stream.useChannel('messaging', { | ||||
| 			otherparty: user.id, | ||||
|  | @ -108,14 +110,15 @@ async function fetch() { | |||
| 
 | ||||
| 		pagination = { | ||||
| 			endpoint: 'messaging/messages', | ||||
| 			limit: 20, | ||||
| 			params: { | ||||
| 				groupId: group.id, | ||||
| 				groupId: group?.id, | ||||
| 			}, | ||||
| 			reversed: true, | ||||
| 			pageEl: $$(rootEl), | ||||
| 			pageEl: $$(rootEl).value, | ||||
| 		}; | ||||
| 		connection = stream.useChannel('messaging', { | ||||
| 			group: group.id, | ||||
| 			group: group?.id, | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
|  | @ -124,7 +127,7 @@ async function fetch() { | |||
| 	connection.on('read', onRead); | ||||
| 	connection.on('deleted', onDeleted); | ||||
| 	connection.on('typers', typers => { | ||||
| 		typers = typers.filter(u => u.id !== $i.id); | ||||
| 		typers = typers.filter(u => u.id !== $i?.id); | ||||
| 	}); | ||||
| 
 | ||||
| 	document.addEventListener('visibilitychange', onVisibilitychange); | ||||
|  | @ -182,7 +185,7 @@ function onMessage(message) { | |||
| 	const _isBottom = isBottom(rootEl, 64); | ||||
| 
 | ||||
| 	pagingComponent.prepend(message); | ||||
| 	if (message.userId != $i.id && !document.hidden) { | ||||
| 	if (message.userId != $i?.id && !document.hidden) { | ||||
| 		connection?.send('read', { | ||||
| 			id: message.id | ||||
| 		}); | ||||
|  | @ -193,7 +196,7 @@ function onMessage(message) { | |||
| 		nextTick(() => { | ||||
| 			scrollToBottom(); | ||||
| 		}); | ||||
| 	} else if (message.userId != $i.id) { | ||||
| 	} else if (message.userId != $i?.id) { | ||||
| 		// Notify | ||||
| 		notifyNewMessage(); | ||||
| 	} | ||||
|  | @ -251,7 +254,7 @@ function notifyNewMessage() { | |||
| function onVisibilitychange() { | ||||
| 	if (document.hidden) return; | ||||
| 	for (const message of pagingComponent.items) { | ||||
| 		if (message.userId !== $i.id && !message.isRead) { | ||||
| 		if (message.userId !== $i?.id && !message.isRead) { | ||||
| 			connection?.send('read', { | ||||
| 				id: message.id | ||||
| 			}); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue