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;