チャンネルで入力中ユーザーを表示するように、Chat UIでタイムラインでは投稿フォームを上に表示するように
This commit is contained in:
		
							parent
							
								
									5f1a6b6f64
								
							
						
					
					
						commit
						25d37302a8
					
				
					 11 changed files with 143 additions and 41 deletions
				
			
		|  | @ -706,6 +706,7 @@ receiveAnnouncementFromInstance: "インスタンスからのお知らせを受 | |||
| emailNotification: "メール通知" | ||||
| inChannelSearch: "チャンネル内検索" | ||||
| useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く" | ||||
| typingUsers: "{users}が入力中" | ||||
| 
 | ||||
| _email: | ||||
|   _follow: | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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({ | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue