From 78a963fe334caae564424c6458a8565da957c8be Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 21 Feb 2021 12:26:49 +0900 Subject: Messagingの入力中インジケータを実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/api/stream/channels/messaging.ts | 47 +++++++++++++++++++++++++++-- src/server/api/stream/index.ts | 19 +++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) (limited to 'src/server') diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts index 8456871e6a..7279da3ece 100644 --- a/src/server/api/stream/channels/messaging.ts +++ b/src/server/api/stream/channels/messaging.ts @@ -12,6 +12,9 @@ export default class extends Channel { private otherpartyId: string | null; private otherparty?: User; private groupId: string | null; + private subCh: string; + private typers: Record = {}; + private emitTypersIntervalId: ReturnType; @autobind 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.groupId}`; // 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); - }); + } } @autobind @@ -60,4 +77,28 @@ export default class extends Channel { 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); + } } diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index b04bed0c06..c56a0a157b 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -12,7 +12,8 @@ import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../ import { ApiError } from '../error'; import { AccessToken } from '../../../models/entities/access-token'; 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 @@ -94,7 +95,12 @@ export default class Connection { case 'disconnect': this.onChannelDisconnectRequested(body); break; case 'channel': this.onChannelMessageRequested(body); break; case 'ch': this.onChannelMessageRequested(body); break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 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 private async updateFollowing() { const followings = await Followings.find({ -- cgit v1.2.3-freya