Messagingの入力中インジケータを実装
This commit is contained in:
		
							parent
							
								
									f3aef8df75
								
							
						
					
					
						commit
						78a963fe33
					
				
					 4 changed files with 104 additions and 4 deletions
				
			
		|  | @ -7,6 +7,7 @@ | ||||||
| 		v-model="text" | 		v-model="text" | ||||||
| 		ref="text" | 		ref="text" | ||||||
| 		@keypress="onKeypress" | 		@keypress="onKeypress" | ||||||
|  | 		@compositionupdate="onCompositionUpdate" | ||||||
| 		@paste="onPaste" | 		@paste="onPaste" | ||||||
| 		:placeholder="$ts.inputMessageHere" | 		:placeholder="$ts.inputMessageHere" | ||||||
| 	></textarea> | 	></textarea> | ||||||
|  | @ -29,6 +30,7 @@ import { formatTimeString } from '../../../misc/format-time-string'; | ||||||
| import { selectFile } from '@/scripts/select-file'; | import { selectFile } from '@/scripts/select-file'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { Autocomplete } from '@/scripts/autocomplete'; | import { Autocomplete } from '@/scripts/autocomplete'; | ||||||
|  | import { throttle } from 'throttle-debounce'; | ||||||
| 
 | 
 | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
| 	props: { | 	props: { | ||||||
|  | @ -46,6 +48,9 @@ export default defineComponent({ | ||||||
| 			text: null, | 			text: null, | ||||||
| 			file: null, | 			file: null, | ||||||
| 			sending: false, | 			sending: false, | ||||||
|  | 			typing: throttle(3000, () => { | ||||||
|  | 				os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); | ||||||
|  | 			}), | ||||||
| 			faPaperPlane, faPhotoVideo, faLaughSquint | 			faPaperPlane, faPhotoVideo, faLaughSquint | ||||||
| 		}; | 		}; | ||||||
| 	}, | 	}, | ||||||
|  | @ -147,11 +152,16 @@ export default defineComponent({ | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
| 		onKeypress(e) { | 		onKeypress(e) { | ||||||
|  | 			this.typing(); | ||||||
| 			if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { | 			if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { | ||||||
| 				this.send(); | 				this.send(); | ||||||
| 			} | 			} | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
|  | 		onCompositionUpdate() { | ||||||
|  | 			this.typing(); | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
| 		chooseFile(e) { | 		chooseFile(e) { | ||||||
| 			selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { | 			selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { | ||||||
| 				this.file = file; | 				this.file = file; | ||||||
|  |  | ||||||
|  | @ -16,6 +16,14 @@ | ||||||
| 			</XList> | 			</XList> | ||||||
| 		</div> | 		</div> | ||||||
| 		<footer> | 		<footer> | ||||||
|  | 			<div class="typers" v-if="typers.length > 0"> | ||||||
|  | 				<I18n :src="$ts.typingUsers" text-tag="span" class="users"> | ||||||
|  | 					<template #users> | ||||||
|  | 						<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> | ||||||
|  | 					</template> | ||||||
|  | 				</I18n> | ||||||
|  | 				<MkEllipsis/> | ||||||
|  | 			</div> | ||||||
| 			<transition name="fade"> | 			<transition name="fade"> | ||||||
| 				<div class="new-message" v-show="showIndicator"> | 				<div class="new-message" v-show="showIndicator"> | ||||||
| 					<button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button> | 					<button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button> | ||||||
|  | @ -86,6 +94,7 @@ const Component = defineComponent({ | ||||||
| 			connection: null, | 			connection: null, | ||||||
| 			showIndicator: false, | 			showIndicator: false, | ||||||
| 			timer: null, | 			timer: null, | ||||||
|  | 			typers: [], | ||||||
| 			ilObserver: new IntersectionObserver( | 			ilObserver: new IntersectionObserver( | ||||||
| 				(entries) => entries.some((entry) => entry.isIntersecting) | 				(entries) => entries.some((entry) => entry.isIntersecting) | ||||||
| 					&& !this.fetching | 					&& !this.fetching | ||||||
|  | @ -142,6 +151,9 @@ const Component = defineComponent({ | ||||||
| 			this.connection.on('message', this.onMessage); | 			this.connection.on('message', this.onMessage); | ||||||
| 			this.connection.on('read', this.onRead); | 			this.connection.on('read', this.onRead); | ||||||
| 			this.connection.on('deleted', this.onDeleted); | 			this.connection.on('deleted', this.onDeleted); | ||||||
|  | 			this.connection.on('typers', typers => { | ||||||
|  | 				this.typers = typers.filter(u => u.id !== this.$i.id); | ||||||
|  | 			}); | ||||||
| 
 | 
 | ||||||
| 			document.addEventListener('visibilitychange', this.onVisibilitychange); | 			document.addEventListener('visibilitychange', this.onVisibilitychange); | ||||||
| 
 | 
 | ||||||
|  | @ -397,6 +409,7 @@ export default Component; | ||||||
| 
 | 
 | ||||||
| 	> footer { | 	> footer { | ||||||
| 		width: 100%; | 		width: 100%; | ||||||
|  | 		position: relative; | ||||||
| 
 | 
 | ||||||
| 		> .new-message { | 		> .new-message { | ||||||
| 			position: absolute; | 			position: absolute; | ||||||
|  | @ -422,6 +435,25 @@ export default Component; | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		> .typers { | ||||||
|  | 			position: absolute; | ||||||
|  | 			bottom: 100%; | ||||||
|  | 			padding: 0 8px 0 8px; | ||||||
|  | 			font-size: 0.9em; | ||||||
|  | 			color: var(--fgTransparentWeak); | ||||||
|  | 
 | ||||||
|  | 			> .users { | ||||||
|  | 				> .user + .user:before { | ||||||
|  | 					content: ", "; | ||||||
|  | 					font-weight: normal; | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				> .user:last-of-type:after { | ||||||
|  | 					content: " "; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -12,6 +12,9 @@ export default class extends Channel { | ||||||
| 	private otherpartyId: string | null; | 	private otherpartyId: string | null; | ||||||
| 	private otherparty?: User; | 	private otherparty?: User; | ||||||
| 	private groupId: string | null; | 	private groupId: string | null; | ||||||
|  | 	private subCh: string; | ||||||
|  | 	private typers: Record<User['id'], Date> = {}; | ||||||
|  | 	private emitTypersIntervalId: ReturnType<typeof setInterval>; | ||||||
| 
 | 
 | ||||||
| 	@autobind | 	@autobind | ||||||
| 	public async init(params: any) { | 	public async init(params: any) { | ||||||
|  | @ -31,14 +34,28 @@ export default class extends Channel { | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		const subCh = this.otherpartyId | 		this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); | ||||||
|  | 
 | ||||||
|  | 		this.subCh = this.otherpartyId | ||||||
| 			? `messagingStream:${this.user!.id}-${this.otherpartyId}` | 			? `messagingStream:${this.user!.id}-${this.otherpartyId}` | ||||||
| 			: `messagingStream:${this.groupId}`; | 			: `messagingStream:${this.groupId}`; | ||||||
| 
 | 
 | ||||||
| 		// Subscribe messaging stream
 | 		// Subscribe messaging stream
 | ||||||
| 		this.subscriber.on(subCh, data => { | 		this.subscriber.on(this.subCh, this.onEvent); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@autobind | ||||||
|  | 	private onEvent(data: any) { | ||||||
|  | 		if (data.type === 'typing') { | ||||||
|  | 			const id = data.body; | ||||||
|  | 			const begin = this.typers[id] == null; | ||||||
|  | 			this.typers[id] = new Date(); | ||||||
|  | 			if (begin) { | ||||||
|  | 				this.emitTypers(); | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
| 			this.send(data); | 			this.send(data); | ||||||
| 		}); | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	@autobind | 	@autobind | ||||||
|  | @ -60,4 +77,28 @@ export default class extends Channel { | ||||||
| 				break; | 				break; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	@autobind | ||||||
|  | 	private async emitTypers() { | ||||||
|  | 		const now = new Date(); | ||||||
|  | 
 | ||||||
|  | 		// Remove not typing users
 | ||||||
|  | 		for (const [userId, date] of Object.entries(this.typers)) { | ||||||
|  | 			if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); | ||||||
|  | 
 | ||||||
|  | 		this.send({ | ||||||
|  | 			type: 'typers', | ||||||
|  | 			body: users, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	@autobind | ||||||
|  | 	public dispose() { | ||||||
|  | 		this.subscriber.off(this.subCh, this.onEvent); | ||||||
|  | 	 | ||||||
|  | 		clearInterval(this.emitTypersIntervalId); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -12,7 +12,8 @@ import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../ | ||||||
| import { ApiError } from '../error'; | import { ApiError } from '../error'; | ||||||
| import { AccessToken } from '../../../models/entities/access-token'; | import { AccessToken } from '../../../models/entities/access-token'; | ||||||
| import { UserProfile } from '../../../models/entities/user-profile'; | import { UserProfile } from '../../../models/entities/user-profile'; | ||||||
| import { publishChannelStream } from '../../../services/stream'; | import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '../../../services/stream'; | ||||||
|  | import { UserGroup } from '../../../models/entities/user-group'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Main stream connection |  * Main stream connection | ||||||
|  | @ -94,7 +95,12 @@ export default class Connection { | ||||||
| 			case 'disconnect': this.onChannelDisconnectRequested(body); break; | 			case 'disconnect': this.onChannelDisconnectRequested(body); break; | ||||||
| 			case 'channel': this.onChannelMessageRequested(body); break; | 			case 'channel': this.onChannelMessageRequested(body); break; | ||||||
| 			case 'ch': this.onChannelMessageRequested(body); break; // alias
 | 			case 'ch': this.onChannelMessageRequested(body); break; // alias
 | ||||||
|  | 
 | ||||||
|  | 			// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
 | ||||||
|  | 			// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
 | ||||||
|  | 			// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
 | ||||||
| 			case 'typingOnChannel': this.typingOnChannel(body.channel); break; | 			case 'typingOnChannel': this.typingOnChannel(body.channel); break; | ||||||
|  | 			case 'typingOnMessaging': this.typingOnMessaging(body); break; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -267,6 +273,17 @@ export default class Connection { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	@autobind | ||||||
|  | 	private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { | ||||||
|  | 		if (this.user) { | ||||||
|  | 			if (param.partner) { | ||||||
|  | 				publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); | ||||||
|  | 			} else if (param.group) { | ||||||
|  | 				publishGroupMessagingStream(param.group, 'typing', this.user.id); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	@autobind | 	@autobind | ||||||
| 	private async updateFollowing() { | 	private async updateFollowing() { | ||||||
| 		const followings = await Followings.find({ | 		const followings = await Followings.find({ | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue