| @@ -160,12 +160,6 @@ let hasNotSpecifiedMentions = $ref(false); | ||||
| let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]')); | ||||
| let imeText = $ref(''); | ||||
|  | ||||
| const typing = throttle(3000, () => { | ||||
| 	if (props.channel) { | ||||
| 		stream.send('typingOnChannel', { channel: props.channel.id }); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| const draftKey = $computed((): string => { | ||||
| 	let key = props.channel ? `channel:${props.channel.id}` : ''; | ||||
|  | ||||
| @@ -447,12 +441,10 @@ function clear() { | ||||
| function onKeydown(ev: KeyboardEvent) { | ||||
| 	if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post(); | ||||
| 	if (ev.which === 27) emit('esc'); | ||||
| 	typing(); | ||||
| } | ||||
|  | ||||
| function onCompositionUpdate(ev: CompositionEvent) { | ||||
| 	imeText = ev.data; | ||||
| 	typing(); | ||||
| } | ||||
|  | ||||
| function onCompositionEnd(ev: CompositionEvent) { | ||||
|   | ||||
| @@ -505,15 +505,6 @@ if ($i) { | ||||
| 		updateAccount({ hasUnreadSpecifiedNotes: false }); | ||||
| 	}); | ||||
|  | ||||
| 	main.on('readAllMessagingMessages', () => { | ||||
| 		updateAccount({ hasUnreadMessagingMessage: false }); | ||||
| 	}); | ||||
|  | ||||
| 	main.on('unreadMessagingMessage', () => { | ||||
| 		updateAccount({ hasUnreadMessagingMessage: true }); | ||||
| 		sound.play('chatBg'); | ||||
| 	}); | ||||
|  | ||||
| 	main.on('readAllAntennas', () => { | ||||
| 		updateAccount({ hasUnreadAntenna: false }); | ||||
| 	}); | ||||
|   | ||||
| @@ -15,13 +15,6 @@ export const navbarItemDef = reactive({ | ||||
| 		indicated: computed(() => $i != null && $i.hasUnreadNotification), | ||||
| 		to: '/my/notifications', | ||||
| 	}, | ||||
| 	messaging: { | ||||
| 		title: i18n.ts.messaging, | ||||
| 		icon: 'ti ti-messages', | ||||
| 		show: computed(() => $i != null), | ||||
| 		indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage), | ||||
| 		to: '/my/messaging', | ||||
| 	}, | ||||
| 	drive: { | ||||
| 		title: i18n.ts.drive, | ||||
| 		icon: 'ti ti-cloud', | ||||
|   | ||||
| @@ -1,305 +0,0 @@ | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :content-max="800"> | ||||
| 		<div class="yweeujhr"> | ||||
| 			<MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton> | ||||
|  | ||||
| 			<div v-if="messages.length > 0" class="history"> | ||||
| 				<MkA | ||||
| 					v-for="(message, i) in messages" | ||||
| 					:key="message.id" | ||||
| 					v-anim="i" | ||||
| 					class="message _panel" | ||||
| 					:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" | ||||
| 					:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" | ||||
| 					:data-index="i" | ||||
| 				> | ||||
| 					<div> | ||||
| 						<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" indicator link preview/> | ||||
| 						<header v-if="message.groupId"> | ||||
| 							<span class="name">{{ message.group.name }}</span> | ||||
| 							<MkTime :time="message.createdAt" class="time"/> | ||||
| 						</header> | ||||
| 						<header v-else> | ||||
| 							<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> | ||||
| 							<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> | ||||
| 							<MkTime :time="message.createdAt" class="time"/> | ||||
| 						</header> | ||||
| 						<div class="body"> | ||||
| 							<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</MkA> | ||||
| 			</div> | ||||
| 			<div v-if="!fetching && messages.length == 0" class="_fullinfo"> | ||||
| 				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||
| 				<div>{{ $ts.noHistory }}</div> | ||||
| 			</div> | ||||
| 			<MkLoading v-if="fetching"/> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue'; | ||||
| import * as Acct from 'misskey-js/built/acct'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { acct } from '@/filters/user'; | ||||
| import * as os from '@/os'; | ||||
| import { stream } from '@/stream'; | ||||
| import { useRouter } from '@/router'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { $i } from '@/account'; | ||||
|  | ||||
| const router = useRouter(); | ||||
|  | ||||
| let fetching = $ref(true); | ||||
| let moreFetching = $ref(false); | ||||
| let messages = $ref([]); | ||||
| let connection = $ref(null); | ||||
|  | ||||
| const getAcct = Acct.toString; | ||||
|  | ||||
| function isMe(message) { | ||||
| 	return message.userId === $i.id; | ||||
| } | ||||
|  | ||||
| function onMessage(message) { | ||||
| 	if (message.recipientId) { | ||||
| 		messages = messages.filter(m => !( | ||||
| 			(m.recipientId === message.recipientId && m.userId === message.userId) || | ||||
| 			(m.recipientId === message.userId && m.userId === message.recipientId))); | ||||
|  | ||||
| 		messages.unshift(message); | ||||
| 	} else if (message.groupId) { | ||||
| 		messages = messages.filter(m => m.groupId !== message.groupId); | ||||
| 		messages.unshift(message); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function onRead(ids) { | ||||
| 	for (const id of ids) { | ||||
| 		const found = messages.find(m => m.id === id); | ||||
| 		if (found) { | ||||
| 			if (found.recipientId) { | ||||
| 				found.isRead = true; | ||||
| 			} else if (found.groupId) { | ||||
| 				found.reads.push($i.id); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function start(ev) { | ||||
| 	os.popupMenu([{ | ||||
| 		text: i18n.ts.messagingWithUser, | ||||
| 		icon: 'ti ti-user', | ||||
| 		action: () => { startUser(); }, | ||||
| 	}, { | ||||
| 		text: i18n.ts.messagingWithGroup, | ||||
| 		icon: 'ti ti-users', | ||||
| 		action: () => { startGroup(); }, | ||||
| 	}], ev.currentTarget ?? ev.target); | ||||
| } | ||||
|  | ||||
| async function startUser() { | ||||
| 	os.selectUser().then(user => { | ||||
| 		router.push(`/my/messaging/${Acct.toString(user)}`); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function startGroup() { | ||||
| 	const groups1 = await os.api('users/groups/owned'); | ||||
| 	const groups2 = await os.api('users/groups/joined'); | ||||
| 	if (groups1.length === 0 && groups2.length === 0) { | ||||
| 		os.alert({ | ||||
| 			type: 'warning', | ||||
| 			title: i18n.ts.youHaveNoGroups, | ||||
| 			text: i18n.ts.joinOrCreateGroup, | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
| 	const { canceled, result: group } = await os.select({ | ||||
| 		title: i18n.ts.group, | ||||
| 		items: groups1.concat(groups2).map(group => ({ | ||||
| 			value: group, text: group.name, | ||||
| 		})), | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	router.push(`/my/messaging/group/${group.id}`); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	connection = markRaw(stream.useChannel('messagingIndex')); | ||||
|  | ||||
| 	connection.on('message', onMessage); | ||||
| 	connection.on('read', onRead); | ||||
|  | ||||
| 	os.api('messaging/history', { group: false }).then(userMessages => { | ||||
| 		os.api('messaging/history', { group: true }).then(groupMessages => { | ||||
| 			const _messages = userMessages.concat(groupMessages); | ||||
| 			_messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); | ||||
| 			messages = _messages; | ||||
| 			fetching = false; | ||||
| 		}); | ||||
| 	}); | ||||
| }); | ||||
|  | ||||
| onUnmounted(() => { | ||||
| 	if (connection) connection.dispose(); | ||||
| }); | ||||
|  | ||||
| const headerActions = $computed(() => []); | ||||
|  | ||||
| const headerTabs = $computed(() => []); | ||||
|  | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.messaging, | ||||
| 	icon: 'ti ti-messages', | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .yweeujhr { | ||||
|  | ||||
| 	> .start { | ||||
| 		margin: 0 auto var(--margin) auto; | ||||
| 	} | ||||
|  | ||||
| 	> .history { | ||||
| 		> .message { | ||||
| 			display: block; | ||||
| 			text-decoration: none; | ||||
| 			margin-bottom: var(--margin); | ||||
|  | ||||
| 			* { | ||||
| 				pointer-events: none; | ||||
| 				user-select: none; | ||||
| 			} | ||||
|  | ||||
| 			&:hover { | ||||
| 				.avatar { | ||||
| 					filter: saturate(200%); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			&:active { | ||||
| 			} | ||||
|  | ||||
| 			&.isRead, | ||||
| 			&.isMe { | ||||
| 				opacity: 0.8; | ||||
| 			} | ||||
|  | ||||
| 			&:not(.isMe):not(.isRead) { | ||||
| 				> div { | ||||
| 					background-image: url("/client-assets/unread.svg"); | ||||
| 					background-repeat: no-repeat; | ||||
| 					background-position: 0 center; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			&:after { | ||||
| 				content: ""; | ||||
| 				display: block; | ||||
| 				clear: both; | ||||
| 			} | ||||
|  | ||||
| 			> div { | ||||
| 				padding: 20px 30px; | ||||
|  | ||||
| 				&:after { | ||||
| 					content: ""; | ||||
| 					display: block; | ||||
| 					clear: both; | ||||
| 				} | ||||
|  | ||||
| 				> header { | ||||
| 					display: flex; | ||||
| 					align-items: center; | ||||
| 					margin-bottom: 2px; | ||||
| 					white-space: nowrap; | ||||
| 					overflow: hidden; | ||||
|  | ||||
| 					> .name { | ||||
| 						margin: 0; | ||||
| 						padding: 0; | ||||
| 						overflow: hidden; | ||||
| 						text-overflow: ellipsis; | ||||
| 						font-size: 1em; | ||||
| 						font-weight: bold; | ||||
| 						transition: all 0.1s ease; | ||||
| 					} | ||||
|  | ||||
| 					> .username { | ||||
| 						margin: 0 8px; | ||||
| 					} | ||||
|  | ||||
| 					> .time { | ||||
| 						margin: 0 0 0 auto; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				> .avatar { | ||||
| 					float: left; | ||||
| 					width: 54px; | ||||
| 					height: 54px; | ||||
| 					margin: 0 16px 0 0; | ||||
| 					border-radius: 8px; | ||||
| 					transition: all 0.1s ease; | ||||
| 				} | ||||
|  | ||||
| 				> .body { | ||||
|  | ||||
| 					> .text { | ||||
| 						display: block; | ||||
| 						margin: 0 0 0 0; | ||||
| 						padding: 0; | ||||
| 						overflow: hidden; | ||||
| 						overflow-wrap: break-word; | ||||
| 						font-size: 1.1em; | ||||
| 						color: var(--faceText); | ||||
|  | ||||
| 						.me { | ||||
| 							opacity: 0.7; | ||||
| 						} | ||||
| 					} | ||||
|  | ||||
| 					> .image { | ||||
| 						display: block; | ||||
| 						max-width: 100%; | ||||
| 						max-height: 512px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @container (max-width: 400px) { | ||||
| 	.yweeujhr { | ||||
| 		> .history { | ||||
| 			> .message { | ||||
| 				&:not(.isMe):not(.isRead) { | ||||
| 					> div { | ||||
| 						background-image: none; | ||||
| 						border-left: solid 4px #3aa2dc; | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				> div { | ||||
| 					padding: 16px; | ||||
| 					font-size: 0.9em; | ||||
|  | ||||
| 					> .avatar { | ||||
| 						margin: 0 12px 0 0; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
| @@ -1,366 +0,0 @@ | ||||
| <template> | ||||
| <div | ||||
| 	:class="$style['root']" | ||||
| 	@dragover.stop="onDragover" | ||||
| 	@drop.stop="onDrop" | ||||
| > | ||||
| 	<textarea | ||||
| 		:class="$style['textarea']" | ||||
| 		class="_acrylic" | ||||
| 		ref="textEl" | ||||
| 		v-model="text" | ||||
| 		:placeholder="i18n.ts.inputMessageHere" | ||||
| 		@keydown="onKeydown" | ||||
| 		@compositionupdate="onCompositionUpdate" | ||||
| 		@paste="onPaste" | ||||
| 	></textarea> | ||||
| 	<footer :class="$style['footer']"> | ||||
| 		<div v-if="file" :class="$style['file']" @click="file = null">{{ file.name }}</div> | ||||
| 		<div :class="$style['buttons']"> | ||||
| 			<button class="_button" :class="$style['button']" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> | ||||
| 			<button class="_button" :class="$style['button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> | ||||
| 			<button class="_button" :class="[$style['button'], $style['send']]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> | ||||
| 				<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> | ||||
| 			</button> | ||||
| 		</div> | ||||
| 	</footer> | ||||
| 	<input :class="$style['file-input']" ref="fileEl" type="file" @change="onChangeFile"/> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <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 { defaultStore } from '@/store'; | ||||
| import { i18n } from '@/i18n'; | ||||
| //import { Autocomplete } from '@/scripts/autocomplete'; | ||||
| import { uploadFile } from '@/scripts/upload'; | ||||
| import { miLocalStorage } from '@/local-storage'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	user?: Misskey.entities.UserDetailed | null; | ||||
| 	group?: Misskey.entities.UserGroup | null; | ||||
| }>(); | ||||
|  | ||||
| let textEl = $shallowRef<HTMLTextAreaElement>(); | ||||
| let fileEl = $shallowRef<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); | ||||
| 		} | ||||
| 	} else { | ||||
| 		if (items[0].kind === 'file') { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: i18n.ts.onlyOneFileCanBeAttached, | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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(); | ||||
| 		switch (ev.dataTransfer.effectAllowed) { | ||||
| 			case 'all': | ||||
| 			case 'uninitialized': | ||||
| 			case 'copy':  | ||||
| 			case 'copyLink':  | ||||
| 			case 'copyMove':  | ||||
| 				ev.dataTransfer.dropEffect = 'copy'; | ||||
| 				break; | ||||
| 			case 'linkMove': | ||||
| 			case 'move': | ||||
| 				ev.dataTransfer.dropEffect = 'move'; | ||||
| 				break; | ||||
| 			default: | ||||
| 				ev.dataTransfer.dropEffect = 'none'; | ||||
| 				break; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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(miLocalStorage.getItem('message_drafts') || '{}'); | ||||
|  | ||||
| 	drafts[draftKey] = { | ||||
| 		updatedAt: new Date(), | ||||
| 		// eslint-disable-next-line id-denylist | ||||
| 		data: { | ||||
| 			text: text, | ||||
| 			file: file, | ||||
| 		}, | ||||
| 	}; | ||||
|  | ||||
| 	miLocalStorage.setItem('message_drafts', JSON.stringify(drafts)); | ||||
| } | ||||
|  | ||||
| function deleteDraft() { | ||||
| 	const drafts = JSON.parse(miLocalStorage.getItem('message_drafts') || '{}'); | ||||
|  | ||||
| 	delete drafts[draftKey]; | ||||
|  | ||||
| 	miLocalStorage.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(miLocalStorage.getItem('message_drafts') || '{}')[draftKey]; | ||||
| 	if (draft) { | ||||
| 		text = draft.data.text; | ||||
| 		file = draft.data.file; | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| defineExpose({ | ||||
| 	file, | ||||
| 	upload, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	position: relative; | ||||
| } | ||||
|  | ||||
| .textarea { | ||||
| 	cursor: auto; | ||||
| 	display: block; | ||||
| 	width: 100%; | ||||
| 	min-width: 100%; | ||||
| 	max-width: 100%; | ||||
| 	min-height: 80px; | ||||
| 	margin: 0; | ||||
| 	padding: 16px 16px 0 16px; | ||||
| 	resize: none; | ||||
| 	font-size: 1em; | ||||
| 	font-family: inherit; | ||||
| 	outline: none; | ||||
| 	border: none; | ||||
| 	border-radius: 0; | ||||
| 	box-shadow: none; | ||||
| 	box-sizing: border-box; | ||||
| 	color: var(--fg); | ||||
| } | ||||
|  | ||||
| .footer { | ||||
| 	position: sticky; | ||||
| 	bottom: 0; | ||||
| 	background: var(--panel); | ||||
| } | ||||
|  | ||||
| .file { | ||||
| 	padding: 8px; | ||||
| 	color: var(--fg); | ||||
| 	background: transparent; | ||||
| 	cursor: pointer; | ||||
| } | ||||
| /* | ||||
| .files { | ||||
| 	display: block; | ||||
| 	margin: 0; | ||||
| 	padding: 0 8px; | ||||
| 	list-style: none; | ||||
|  | ||||
| 	&:after { | ||||
| 		content: ''; | ||||
| 		display: block; | ||||
| 		clear: both; | ||||
| 	} | ||||
|  | ||||
| 	> li { | ||||
| 		display: block; | ||||
| 		float: left; | ||||
| 		margin: 4px; | ||||
| 		padding: 0; | ||||
| 		width: 64px; | ||||
| 		height: 64px; | ||||
| 		background-color: #eee; | ||||
| 		background-repeat: no-repeat; | ||||
| 		background-position: center center; | ||||
| 		background-size: cover; | ||||
| 		cursor: move; | ||||
|  | ||||
| 		&:hover { | ||||
| 			> .remove { | ||||
| 				display: block; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .file-remove { | ||||
| 	display: none; | ||||
| 	position: absolute; | ||||
| 	right: -6px; | ||||
| 	top: -6px; | ||||
| 	margin: 0; | ||||
| 	padding: 0; | ||||
| 	background: transparent; | ||||
| 	outline: none; | ||||
| 	border: none; | ||||
| 	border-radius: 0; | ||||
| 	box-shadow: none; | ||||
| 	cursor: pointer; | ||||
| } | ||||
| */ | ||||
|  | ||||
| .buttons { | ||||
| 	display: flex; | ||||
| } | ||||
|  | ||||
| .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; | ||||
| 	} | ||||
| } | ||||
| .send { | ||||
| 	margin-left: auto; | ||||
| 	color: var(--accent); | ||||
|  | ||||
| 	&:hover { | ||||
| 		color: var(--accentLighten); | ||||
| 	} | ||||
|  | ||||
| 	&:active { | ||||
| 		color: var(--accentDarken); | ||||
| 		transition: color 0s ease; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .file-input { | ||||
| 	display: none; | ||||
| } | ||||
| </style> | ||||
| @@ -1,338 +0,0 @@ | ||||
| <template> | ||||
| <div class="thvuemwp" :class="{ isMe }"> | ||||
| 	<MkAvatar class="avatar" :user="message.user" indicator link preview/> | ||||
| 	<div class="content"> | ||||
| 		<div class="balloon" :class="{ noText: message.text == null }"> | ||||
| 			<button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del"> | ||||
| 				<img src="/client-assets/remove.png" alt="Delete"/> | ||||
| 			</button> | ||||
| 			<div v-if="!message.isDeleted" class="content"> | ||||
| 				<Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/> | ||||
| 				<div v-if="message.file" class="file"> | ||||
| 					<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> | ||||
| 						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> | ||||
| 						<p v-else>{{ message.file.name }}</p> | ||||
| 					</a> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div v-else class="content"> | ||||
| 				<p class="is-deleted">{{ $ts.deleted }}</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div></div> | ||||
| 		<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> | ||||
| 		<footer> | ||||
| 			<template v-if="isGroup"> | ||||
| 				<span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span> | ||||
| 			</template> | ||||
| 			<template v-else> | ||||
| 				<span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span> | ||||
| 			</template> | ||||
| 			<MkTime :time="message.createdAt"/> | ||||
| 			<template v-if="message.is_edited"><i class="ti ti-pencil"></i></template> | ||||
| 		</footer> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <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/MkUrlPreview.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { $i } from '@/account'; | ||||
|  | ||||
| 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> | ||||
| .thvuemwp { | ||||
| 	$me-balloon-color: var(--accent); | ||||
|  | ||||
| 	position: relative; | ||||
| 	background-color: transparent; | ||||
| 	display: flex; | ||||
|  | ||||
| 	> .avatar { | ||||
| 		position: sticky; | ||||
| 		top: calc(var(--stickyTop, 0px) + 16px); | ||||
| 		display: block; | ||||
| 		width: 54px; | ||||
| 		height: 54px; | ||||
| 		transition: all 0.1s ease; | ||||
| 	} | ||||
|  | ||||
| 	> .content { | ||||
| 		min-width: 0; | ||||
|  | ||||
| 		> .balloon { | ||||
| 			position: relative; | ||||
| 			display: inline-flex; | ||||
| 			align-items: center; | ||||
| 			padding: 0; | ||||
| 			min-height: 38px; | ||||
| 			border-radius: 16px; | ||||
| 			max-width: 100%; | ||||
|  | ||||
| 			&:before { | ||||
| 				content: ""; | ||||
| 				pointer-events: none; | ||||
| 				display: block; | ||||
| 				position: absolute; | ||||
| 				top: 12px; | ||||
| 			} | ||||
|  | ||||
| 			& + * { | ||||
| 				clear: both; | ||||
| 			} | ||||
|  | ||||
| 			&:hover { | ||||
| 				> .delete-button { | ||||
| 					display: block; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> .delete-button { | ||||
| 				display: none; | ||||
| 				position: absolute; | ||||
| 				z-index: 1; | ||||
| 				top: -4px; | ||||
| 				right: -4px; | ||||
| 				margin: 0; | ||||
| 				padding: 0; | ||||
| 				cursor: pointer; | ||||
| 				outline: none; | ||||
| 				border: none; | ||||
| 				border-radius: 0; | ||||
| 				box-shadow: none; | ||||
| 				background: transparent; | ||||
|  | ||||
| 				> img { | ||||
| 					vertical-align: bottom; | ||||
| 					width: 16px; | ||||
| 					height: 16px; | ||||
| 					cursor: pointer; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> .content { | ||||
| 				max-width: 100%; | ||||
|  | ||||
| 				> .is-deleted { | ||||
| 					display: block; | ||||
| 					margin: 0; | ||||
| 					padding: 0; | ||||
| 					overflow: hidden; | ||||
| 					overflow-wrap: break-word; | ||||
| 					font-size: 1em; | ||||
| 					color: rgba(#000, 0.5); | ||||
| 				} | ||||
|  | ||||
| 				> .text { | ||||
| 					display: block; | ||||
| 					margin: 0; | ||||
| 					padding: 12px 18px; | ||||
| 					overflow: hidden; | ||||
| 					overflow-wrap: break-word; | ||||
| 					word-break: break-word; | ||||
| 					font-size: 1em; | ||||
| 					color: rgba(#000, 0.8); | ||||
|  | ||||
| 					& + .file { | ||||
| 						> a { | ||||
| 							border-radius: 0 0 16px 16px; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				> .file { | ||||
| 					> a { | ||||
| 						display: block; | ||||
| 						max-width: 100%; | ||||
| 						border-radius: 16px; | ||||
| 						overflow: hidden; | ||||
| 						text-decoration: none; | ||||
|  | ||||
| 						&:hover { | ||||
| 							text-decoration: none; | ||||
|  | ||||
| 							> p { | ||||
| 								background: #ccc; | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						> * { | ||||
| 							display: block; | ||||
| 							margin: 0; | ||||
| 							width: 100%; | ||||
| 							max-height: 512px; | ||||
| 							object-fit: contain; | ||||
| 							box-sizing: border-box; | ||||
| 						} | ||||
|  | ||||
| 						> p { | ||||
| 							padding: 30px; | ||||
| 							text-align: center; | ||||
| 							color: #555; | ||||
| 							background: #ddd; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		> footer { | ||||
| 			display: block; | ||||
| 			margin: 2px 0 0 0; | ||||
| 			font-size: 0.65em; | ||||
|  | ||||
| 			> .read { | ||||
| 				margin: 0 8px; | ||||
| 			} | ||||
|  | ||||
| 			> i { | ||||
| 				margin-left: 4px; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	&:not(.isMe) { | ||||
| 		padding-left: var(--margin); | ||||
|  | ||||
| 		> .content { | ||||
| 			padding-left: 16px; | ||||
| 			padding-right: 32px; | ||||
|  | ||||
| 			> .balloon { | ||||
| 				$color: var(--messageBg); | ||||
| 				background: $color; | ||||
|  | ||||
| 				&.noText { | ||||
| 					background: transparent; | ||||
| 				} | ||||
|  | ||||
| 				&:not(.noText):before { | ||||
| 					left: -14px; | ||||
| 					border-top: solid 8px transparent; | ||||
| 					border-right: solid 8px $color; | ||||
| 					border-bottom: solid 8px transparent; | ||||
| 					border-left: solid 8px transparent; | ||||
| 				} | ||||
|  | ||||
| 				> .content { | ||||
| 					> .text { | ||||
| 						color: var(--fg); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> footer { | ||||
| 				text-align: left; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	&.isMe { | ||||
| 		flex-direction: row-reverse; | ||||
| 		padding-right: var(--margin); | ||||
| 		right: var(--margin); // 削除時にposition: absoluteになったときに使う | ||||
|  | ||||
| 		> .content { | ||||
| 			padding-right: 16px; | ||||
| 			padding-left: 32px; | ||||
| 			text-align: right; | ||||
|  | ||||
| 			> .balloon { | ||||
| 				background: $me-balloon-color; | ||||
| 				text-align: left; | ||||
|  | ||||
| 				::selection { | ||||
| 					color: var(--accent); | ||||
| 					background-color: #fff; | ||||
| 				}  | ||||
|  | ||||
| 				&.noText { | ||||
| 					background: transparent; | ||||
| 				} | ||||
|  | ||||
| 				&:not(.noText):before { | ||||
| 					right: -14px; | ||||
| 					left: auto; | ||||
| 					border-top: solid 8px transparent; | ||||
| 					border-right: solid 8px transparent; | ||||
| 					border-bottom: solid 8px transparent; | ||||
| 					border-left: solid 8px $me-balloon-color; | ||||
| 				} | ||||
|  | ||||
| 				> .content { | ||||
|  | ||||
| 					> p.is-deleted { | ||||
| 						color: rgba(#fff, 0.5); | ||||
| 					} | ||||
|  | ||||
| 					> .text { | ||||
| 						&, ::v-deep(*) { | ||||
| 							color: var(--fgOnAccent) !important; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			> footer { | ||||
| 				text-align: right; | ||||
|  | ||||
| 				> .read { | ||||
| 					user-select: none; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @container (max-width: 400px) { | ||||
| 	.thvuemwp { | ||||
| 		> .avatar { | ||||
| 			width: 48px; | ||||
| 			height: 48px; | ||||
| 		} | ||||
|  | ||||
| 		> .content { | ||||
| 			> .balloon { | ||||
| 				> .content { | ||||
| 					> .text { | ||||
| 						font-size: 0.9em; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @container (max-width: 500px) { | ||||
| 	.thvuemwp { | ||||
| 		> .content { | ||||
| 			> .balloon { | ||||
| 				> .content { | ||||
| 					> .text { | ||||
| 						padding: 8px 16px; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
| @@ -1,415 +0,0 @@ | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| <template #header> | ||||
| 	<MkPageHeader /> | ||||
| </template> | ||||
| <div | ||||
| 	ref="rootEl" | ||||
| 	:class="$style['root']" | ||||
| 	@dragover.prevent.stop="onDragover" | ||||
| 	@drop.prevent.stop="onDrop" | ||||
| > | ||||
| 	<div :class="$style['body']"> | ||||
| 		<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 }"> | ||||
| 				<MkDateSeparatedList | ||||
| 					v-if="messages.length > 0" | ||||
| 					v-slot="{ item: message }" | ||||
| 					:class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }" | ||||
| 					:items="messages" | ||||
| 					direction="up" | ||||
| 					reversed | ||||
| 				> | ||||
| 					<XMessage :key="message.id" :message="message" :is-group="group != null"/> | ||||
| 				</MkDateSeparatedList> | ||||
| 			</template> | ||||
| 		</MkPagination> | ||||
| 	</div> | ||||
| 	<footer :class="$style['footer']"> | ||||
| 		<div v-if="typers.length > 0" :class="$style['typers']"> | ||||
| 			<I18n :src="i18n.ts.typingUsers" text-tag="span"> | ||||
| 				<template #users> | ||||
| 					<b v-for="typer in typers" :key="typer.id" :class="$style['user']">{{ typer.username }}</b> | ||||
| 				</template> | ||||
| 			</I18n> | ||||
| 			<MkEllipsis/> | ||||
| 		</div> | ||||
| 		<Transition :name="animation ? 'fade' : ''"> | ||||
| 			<div v-show="showIndicator" :class="$style['new-message']"> | ||||
| 				<button class="_buttonPrimary" @click="onIndicatorClick" :class="$style['new-message-button']"> | ||||
| 					<i class="fas ti-fw fa-arrow-circle-down" :class="$style['new-message-icon']"></i>{{ i18n.ts.newMessageExists }} | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		</Transition> | ||||
| 		<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" :class="$style['form']"/> | ||||
| 	</footer> | ||||
| </div> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
|  | ||||
| <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 MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
| import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; | ||||
| import * as os from '@/os'; | ||||
| import { stream } from '@/stream'; | ||||
| import * as sound from '@/scripts/sound'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { $i } from '@/account'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	userAcct?: string; | ||||
| 	groupId?: string; | ||||
| }>(); | ||||
|  | ||||
| let rootEl = $shallowRef<HTMLDivElement>(); | ||||
| let formEl = $shallowRef<InstanceType<typeof XForm>>(); | ||||
| let pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>(); | ||||
|  | ||||
| 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; | ||||
|  | ||||
| let pagination: Paging | null = $ref(null); | ||||
|  | ||||
| watch([() => props.userAcct, () => props.groupId], () => { | ||||
| 	if (connection) connection.dispose(); | ||||
| 	fetch(); | ||||
| }); | ||||
|  | ||||
| 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(() => { | ||||
| 		pagingComponent.inited.then(() => { | ||||
| 			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) { | ||||
| 		switch (ev.dataTransfer.effectAllowed) { | ||||
| 			case 'all': | ||||
| 			case 'uninitialized': | ||||
| 			case 'copy':  | ||||
| 			case 'copyLink':  | ||||
| 			case 'copyMove':  | ||||
| 				ev.dataTransfer.dropEffect = 'copy'; | ||||
| 				break; | ||||
| 			case 'linkMove': | ||||
| 			case 'move': | ||||
| 				ev.dataTransfer.dropEffect = 'move'; | ||||
| 				break; | ||||
| 			default: | ||||
| 				ev.dataTransfer.dropEffect = 'none'; | ||||
| 				break; | ||||
| 		} | ||||
| 	} 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(); | ||||
| }); | ||||
|  | ||||
| definePageMetadata(computed(() => !fetching ? user ? { | ||||
| 	userName: user, | ||||
| 	avatar: user, | ||||
| } : { | ||||
| 	title: group?.name, | ||||
| 	icon: 'ti ti-users', | ||||
| } : null)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	display: content; | ||||
| } | ||||
|  | ||||
| .body { | ||||
| 	min-height: 80%; | ||||
| } | ||||
|  | ||||
| .more { | ||||
| 	display: block; | ||||
| 	margin: 16px auto; | ||||
| 	padding: 0 12px; | ||||
| 	line-height: 24px; | ||||
| 	color: #fff; | ||||
| 	background: rgba(#000, 0.3); | ||||
| 	border-radius: 12px; | ||||
| 	&:hover { | ||||
| 		background: rgba(#000, 0.4); | ||||
| 	} | ||||
| 	&:active { | ||||
| 		background: rgba(#000, 0.5); | ||||
| 	} | ||||
| 	> i { | ||||
| 		margin-right: 4px; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .fetching { | ||||
| 	cursor: wait; | ||||
| } | ||||
|  | ||||
| .messages { | ||||
| 	padding: 16px 0 0; | ||||
|  | ||||
| 	> * { | ||||
| 		margin-bottom: 16px; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .footer { | ||||
| 	width: 100%; | ||||
| 	position: sticky; | ||||
| 	z-index: 2; | ||||
| 	padding-top: 8px; | ||||
| 	bottom: var(--minBottomSpacing); | ||||
| } | ||||
|  | ||||
| .new-message { | ||||
| 	width: 100%; | ||||
| 	padding-bottom: 8px; | ||||
| 	text-align: center; | ||||
| } | ||||
|  | ||||
| .new-message-button { | ||||
| 	display: inline-block; | ||||
| 	margin: 0; | ||||
| 	padding: 0 12px; | ||||
| 	line-height: 32px; | ||||
| 	font-size: 12px; | ||||
| 	border-radius: 16px; | ||||
| } | ||||
|  | ||||
| .new-message-icon { | ||||
| 	display: inline-block; | ||||
| 	margin-right: 8px; | ||||
| } | ||||
|  | ||||
| .typers { | ||||
| 	position: absolute; | ||||
| 	bottom: 100%; | ||||
| 	padding: 0 8px 0 8px; | ||||
| 	font-size: 0.9em; | ||||
| 	color: var(--fgTransparentWeak); | ||||
| } | ||||
|  | ||||
|  | ||||
| .user + .user:before { | ||||
| 	content: ", "; | ||||
| 	font-weight: normal; | ||||
| } | ||||
|  | ||||
| .user:last-of-type:after { | ||||
| 	content: " "; | ||||
| } | ||||
|  | ||||
| .form { | ||||
| 	max-height: 12em; | ||||
| 	overflow-y: scroll; | ||||
| 	border-top: solid 0.5px var(--divider); | ||||
| 	border-bottom-left-radius: 0; | ||||
| 	border-bottom-right-radius: 0; | ||||
| } | ||||
|  | ||||
| .fade-enter-active, .fade-leave-active { | ||||
| 	transition: opacity 0.1s; | ||||
| } | ||||
|  | ||||
| .fade-enter-from, .fade-leave-to { | ||||
| 	transition: opacity 0.5s; | ||||
| 	opacity: 0; | ||||
| } | ||||
| </style> | ||||
| @@ -5,7 +5,6 @@ | ||||
| 		<div class="_gaps_m"> | ||||
| 			<FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> | ||||
| 			<FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> | ||||
| 			<FormLink @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink> | ||||
| 		</div> | ||||
| 	</FormSection> | ||||
| 	<FormSection> | ||||
| @@ -47,10 +46,6 @@ async function readAllUnreadNotes() { | ||||
| 	await os.api('i/read-all-unread-notes'); | ||||
| } | ||||
|  | ||||
| async function readAllMessagingMessages() { | ||||
| 	await os.api('i/read-all-messaging-messages'); | ||||
| } | ||||
|  | ||||
| async function readAllNotifications() { | ||||
| 	await os.api('notifications/mark-all-as-read'); | ||||
| } | ||||
|   | ||||
| @@ -420,19 +420,6 @@ export const routes = [{ | ||||
| 	path: '/my/achievements', | ||||
| 	component: page(() => import('./pages/achievements.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	name: 'messaging', | ||||
| 	path: '/my/messaging', | ||||
| 	component: page(() => import('./pages/messaging/index.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	path: '/my/messaging/:userAcct', | ||||
| 	component: page(() => import('./pages/messaging/messaging-room.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	path: '/my/messaging/group/:groupId', | ||||
| 	component: page(() => import('./pages/messaging/messaging-room.vue')), | ||||
| 	loginRequired: true, | ||||
| }, { | ||||
| 	path: '/my/drive/folder/:folder', | ||||
| 	component: page(() => import('./pages/drive.vue')), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo