From 25d37302a8bfda954c7ede1e9d355db587c82228 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 20 Feb 2021 20:20:05 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=81=E3=83=A3=E3=83=B3=E3=83=8D=E3=83=AB?=
 =?UTF-8?q?=E3=81=A7=E5=85=A5=E5=8A=9B=E4=B8=AD=E3=83=A6=E3=83=BC=E3=82=B6?=
 =?UTF-8?q?=E3=83=BC=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B=E3=82=88?=
 =?UTF-8?q?=E3=81=86=E3=81=AB=E3=80=81Chat=20UI=E3=81=A7=E3=82=BF=E3=82=A4?=
 =?UTF-8?q?=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A7=E3=81=AF=E6=8A=95?=
 =?UTF-8?q?=E7=A8=BF=E3=83=95=E3=82=A9=E3=83=BC=E3=83=A0=E3=82=92=E4=B8=8A?=
 =?UTF-8?q?=E3=81=AB=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B=E3=82=88=E3=81=86?=
 =?UTF-8?q?=E3=81=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/ja-JP.yml                             |  1 +
 src/client/components/post-form.vue           |  8 ++
 src/client/ui/chat/date-separated-list.vue    |  2 +-
 src/client/ui/chat/index.vue                  | 23 +-----
 src/client/ui/chat/note.vue                   |  2 +-
 src/client/ui/chat/post-form.vue              |  8 ++
 src/client/ui/chat/timeline.vue               | 76 +++++++++++++++----
 src/server/api/stream/channels/channel.ts     | 39 +++++++++-
 .../api/stream/channels/games/reversi-game.ts |  2 +-
 src/server/api/stream/index.ts                | 17 ++++-
 src/services/stream.ts                        |  6 ++
 11 files changed, 143 insertions(+), 41 deletions(-)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a927d108bb..e7057e3f89 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -706,6 +706,7 @@ receiveAnnouncementFromInstance: "インスタンスからのお知らせを受
 emailNotification: "メール通知"
 inChannelSearch: "チャンネル内検索"
 useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く"
+typingUsers: "{users}が入力中"
 
 _email:
   _follow:
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index fa9aeff8af..7849095ba8 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -70,6 +70,7 @@ import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
 import { notePostInterruptors, postFormActions } from '@/store';
 import { isMobile } from '@/scripts/is-mobile';
+import { throttle } from 'throttle-debounce';
 
 export default defineComponent({
 	components: {
@@ -144,6 +145,11 @@ export default defineComponent({
 			quoteId: null,
 			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
 			imeText: '',
+			typing: throttle(3000, () => {
+				if (this.channel) {
+					os.stream.send('typingOnChannel', { channel: this.channel.id });
+				}
+			}),
 			postFormActions,
 			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
 		};
@@ -434,10 +440,12 @@ export default defineComponent({
 		onKeydown(e: KeyboardEvent) {
 			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
 			if (e.which === 27) this.$emit('esc');
+			this.typing();
 		},
 
 		onCompositionUpdate(e: CompositionEvent) {
 			this.imeText = e.data;
+			this.typing();
 		},
 
 		onCompositionEnd(e: CompositionEvent) {
diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue
index b209330656..65deb9e1c2 100644
--- a/src/client/ui/chat/date-separated-list.vue
+++ b/src/client/ui/chat/date-separated-list.vue
@@ -32,7 +32,7 @@ export default defineComponent({
 			});
 		}
 
-		return h(TransitionGroup, {
+		return h(this.reversed ? 'div' : TransitionGroup, {
 			class: 'hmjzthxl',
 			name: this.reversed ? 'list-reversed' : 'list',
 			tag: 'div',
diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue
index dd3c82d8ba..0ee32833a8 100644
--- a/src/client/ui/chat/index.vue
+++ b/src/client/ui/chat/index.vue
@@ -114,14 +114,9 @@
 				</button>
 			</div>
 		</header>
-		<div class="body">
-			<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
-			<XTimeline v-else :src="tl" :key="tl"/>
-		</div>
-		<footer class="footer">
-			<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/>
-			<XPostForm v-else/>
-		</footer>
+
+		<XTimeline class="body" v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
+		<XTimeline class="body" v-else :src="tl" :key="tl"/>
 	</main>
 
 	<XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
@@ -143,7 +138,6 @@ import XWidgets from './widgets.vue';
 import XCommon from '../_common_/common.vue';
 import XSide from './side.vue';
 import XTimeline from './timeline.vue';
-import XPostForm from './post-form.vue';
 import XHeaderClock from './header-clock.vue';
 import * as os from '@/os';
 import { router } from '@/router';
@@ -159,7 +153,6 @@ export default defineComponent({
 		XWidgets,
 		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
 		XTimeline,
-		XPostForm,
 		XHeaderClock,
 	},
 
@@ -584,16 +577,6 @@ export default defineComponent({
 				}
 			}
 		}
-
-		> .footer {
-			padding: 0 16px 16px 16px;
-		}
-
-		> .body {
-			flex: 1;
-			min-width: 0;
-			overflow: auto;
-		}
 	}
 
 	> .side {
diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue
index 4e4a303c36..315f5c91e3 100644
--- a/src/client/ui/chat/note.vue
+++ b/src/client/ui/chat/note.vue
@@ -1010,7 +1010,7 @@ export default defineComponent({
 			flex-shrink: 0;
 			display: block;
 			position: sticky;
-			top: 12px;
+			top: 0;
 			margin: 0 14px 0 0;
 			width: 46px;
 			height: 46px;
diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue
index 38fe48cc62..b0a31b097d 100644
--- a/src/client/ui/chat/post-form.vue
+++ b/src/client/ui/chat/post-form.vue
@@ -65,6 +65,7 @@ import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
 import { notePostInterruptors, postFormActions } from '@/store';
 import { isMobile } from '@/scripts/is-mobile';
+import { throttle } from 'throttle-debounce';
 
 export default defineComponent({
 	components: {
@@ -131,6 +132,11 @@ export default defineComponent({
 			quoteId: null,
 			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
 			imeText: '',
+			typing: throttle(3000, () => {
+				if (this.channel) {
+					os.stream.send('typingOnChannel', { channel: this.channel });
+				}
+			}),
 			postFormActions,
 			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
 		};
@@ -421,10 +427,12 @@ export default defineComponent({
 		onKeydown(e: KeyboardEvent) {
 			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
 			if (e.which === 27) this.$emit('esc');
+			this.typing();
 		},
 
 		onCompositionUpdate(e: CompositionEvent) {
 			this.imeText = e.data;
+			this.typing();
 		},
 
 		onCompositionEnd(e: CompositionEvent) {
diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue
index f96a48a776..12cb7af7d2 100644
--- a/src/client/ui/chat/timeline.vue
+++ b/src/client/ui/chat/timeline.vue
@@ -1,8 +1,22 @@
 <template>
-<div class="dbiokgaf">
+<div class="dbiokgaf top" v-if="['home', 'local', 'social', 'global'].includes(src)">
+	<XPostForm/>
+</div>
+<div class="dbiokgaf tl" ref="body">
 	<div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
 	<XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/>
 </div>
+<div class="dbiokgaf bottom" v-if="src === 'channel'">
+	<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>
+	<XPostForm :channel="channel"/>
+</div>
 </template>
 
 <script lang="ts">
@@ -12,10 +26,12 @@ import * as os from '@/os';
 import * as sound from '@/scripts/sound';
 import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 import follow from '@/directives/follow-append';
+import XPostForm from './post-form.vue';
 
 export default defineComponent({
 	components: {
-		XNotes
+		XNotes,
+		XPostForm,
 	},
 
 	directives: {
@@ -69,6 +85,7 @@ export default defineComponent({
 			width: 0,
 			top: 0,
 			bottom: 0,
+			typers: [],
 		};
 	},
 
@@ -166,6 +183,9 @@ export default defineComponent({
 				channelId: this.channel
 			});
 			this.connection.on('note', prepend);
+			this.connection.on('typers', typers => {
+				this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
+			});
 		}
 
 		this.pagination = {
@@ -190,21 +210,21 @@ export default defineComponent({
 
 	methods: {
 		focus() {
-			this.$refs.tl.focus();
+			this.$refs.body.focus();
 		},
 
 		goTop() {
-			const container = getScrollContainer(this.$el);
+			const container = getScrollContainer(this.$refs.body);
 			container.scrollTop = 0;
 		},
 
 		queueUpdated(q) {
-			if (this.$el.offsetWidth !== 0) {
-				const rect = this.$el.getBoundingClientRect();
-				const scrollTop = getScrollPosition(this.$el);
-				this.width = this.$el.offsetWidth;
+			if (this.$refs.body.offsetWidth !== 0) {
+				const rect = this.$refs.body.getBoundingClientRect();
+				const scrollTop = getScrollPosition(this.$refs.body);
+				this.width = this.$refs.body.offsetWidth;
 				this.top = rect.top + scrollTop;
-				this.bottom = this.$el.offsetHeight;
+				this.bottom = this.$refs.body.offsetHeight;
 			}
 			this.queue = q;
 		},
@@ -213,11 +233,41 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
-.dbiokgaf {
-	padding: 16px 0;
+.dbiokgaf.top {
+	padding: 16px 16px 0 16px;
+}
 
-	// TODO: これはノート追加アニメーションによるスクロール発生を抑えるために必要だが、position stickyが効かなくなるので、両者を両立させる良い方法を考える
-	overflow: hidden;
+.dbiokgaf.bottom {
+	padding: 0 16px 16px 16px;
+	position: relative;
+
+	> .typers {
+		position: absolute;
+		bottom: 100%;
+		padding: 0 8px 0 8px;
+		font-size: 0.9em;
+		background: var(--panel);
+		border-radius: 0 8px 0 0;
+		color: var(--fgTransparentWeak);
+
+		> .users {
+			> .user + .user:before {
+				content: ", ";
+				font-weight: normal;
+			}
+
+			> .user:last-of-type:after {
+				content: " ";
+			}
+		}
+	}
+}
+
+.dbiokgaf.tl {
+	padding: 16px 0;
+	flex: 1;
+	min-width: 0;
+	overflow: auto;
 
 	> .new {
 		position: fixed;
diff --git a/src/server/api/stream/channels/channel.ts b/src/server/api/stream/channels/channel.ts
index c24b3db937..aa570d1ef4 100644
--- a/src/server/api/stream/channels/channel.ts
+++ b/src/server/api/stream/channels/channel.ts
@@ -1,14 +1,17 @@
 import autobind from 'autobind-decorator';
 import Channel from '../channel';
-import { Notes } from '../../../../models';
+import { Notes, Users } from '../../../../models';
 import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
 import { PackedNote } from '../../../../models/repositories/note';
+import { User } from '../../../../models/entities/user';
 
 export default class extends Channel {
 	public readonly chName = 'channel';
 	public static shouldShare = false;
 	public static requireCredential = false;
 	private channelId: string;
+	private typers: Record<User['id'], Date> = {};
+	private emitTypersIntervalId: ReturnType<typeof setInterval>;
 
 	@autobind
 	public async init(params: any) {
@@ -16,6 +19,8 @@ export default class extends Channel {
 
 		// Subscribe stream
 		this.subscriber.on('notesStream', this.onNote);
+		this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent);
+		this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
 	}
 
 	@autobind
@@ -41,9 +46,41 @@ export default class extends Channel {
 		this.send('note', note);
 	}
 
+	@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();
+			}
+		}
+	}
+
+	@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() {
 		// Unsubscribe events
 		this.subscriber.off('notesStream', this.onNote);
+		this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent);
+
+		clearInterval(this.emitTypersIntervalId);
 	}
 }
diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts
index ea62ab1e88..e1c2116ac6 100644
--- a/src/server/api/stream/channels/games/reversi-game.ts
+++ b/src/server/api/stream/channels/games/reversi-game.ts
@@ -15,7 +15,7 @@ export default class extends Channel {
 
 	private gameId: ReversiGame['id'] | null = null;
 	private watchers: Record<User['id'], Date> = {};
-	private emitWatchersIntervalId: any;
+	private emitWatchersIntervalId: ReturnType<typeof setInterval>;
 
 	@autobind
 	public async init(params: any) {
diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts
index 5b975d07db..b04bed0c06 100644
--- a/src/server/api/stream/index.ts
+++ b/src/server/api/stream/index.ts
@@ -12,6 +12,7 @@ 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';
 
 /**
  * Main stream connection
@@ -27,10 +28,10 @@ export default class Connection {
 	public subscriber: EventEmitter;
 	private channels: Channel[] = [];
 	private subscribingNotes: any = {};
-	private followingClock: NodeJS.Timer;
-	private mutingClock: NodeJS.Timer;
-	private followingChannelsClock: NodeJS.Timer;
-	private userProfileClock: NodeJS.Timer;
+	private followingClock: ReturnType<typeof setInterval>;
+	private mutingClock: ReturnType<typeof setInterval>;
+	private followingChannelsClock: ReturnType<typeof setInterval>;
+	private userProfileClock: ReturnType<typeof setInterval>;
 
 	constructor(
 		wsConnection: websocket.connection,
@@ -93,6 +94,7 @@ 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;
 		}
 	}
 
@@ -258,6 +260,13 @@ export default class Connection {
 		}
 	}
 
+	@autobind
+	private typingOnChannel(channel: ChannelModel['id']) {
+		if (this.user) {
+			publishChannelStream(channel, 'typing', this.user.id);
+		}
+	}
+
 	@autobind
 	private async updateFollowing() {
 		const followings = await Followings.find({
diff --git a/src/services/stream.ts b/src/services/stream.ts
index ec43c6ff2c..d833d700fe 100644
--- a/src/services/stream.ts
+++ b/src/services/stream.ts
@@ -6,6 +6,7 @@ import { ReversiGame } from '../models/entities/games/reversi/game';
 import { UserGroup } from '../models/entities/user-group';
 import config from '../config';
 import { Antenna } from '../models/entities/antenna';
+import { Channel } from '../models/entities/channel';
 
 class Publisher {
 	private publish = (channel: string, type: string | null, value?: any): void => {
@@ -38,6 +39,10 @@ class Publisher {
 		});
 	}
 
+	public publishChannelStream = (channelId: Channel['id'], type: string, value?: any): void => {
+		this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	public publishUserListStream = (listId: UserList['id'], type: string, value?: any): void => {
 		this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
 	}
@@ -84,6 +89,7 @@ export const publishMainStream = publisher.publishMainStream;
 export const publishDriveStream = publisher.publishDriveStream;
 export const publishNoteStream = publisher.publishNoteStream;
 export const publishNotesStream = publisher.publishNotesStream;
+export const publishChannelStream = publisher.publishChannelStream;
 export const publishUserListStream = publisher.publishUserListStream;
 export const publishAntennaStream = publisher.publishAntennaStream;
 export const publishMessagingStream = publisher.publishMessagingStream;