チャンネルで入力中ユーザーを表示するように、Chat UIでタイムラインでは投稿フォームを上に表示するように

This commit is contained in:
syuilo 2021-02-20 20:20:05 +09:00
parent 6e5eb0f900
commit d1efb26468
11 changed files with 143 additions and 41 deletions

View File

@ -706,6 +706,7 @@ receiveAnnouncementFromInstance: "インスタンスからのお知らせを受
emailNotification: "メール通知" emailNotification: "メール通知"
inChannelSearch: "チャンネル内検索" inChannelSearch: "チャンネル内検索"
useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く" useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く"
typingUsers: "{users}が入力中"
_email: _email:
_follow: _follow:

View File

@ -70,6 +70,7 @@ import * as os from '@/os';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import { notePostInterruptors, postFormActions } from '@/store'; import { notePostInterruptors, postFormActions } from '@/store';
import { isMobile } from '@/scripts/is-mobile'; import { isMobile } from '@/scripts/is-mobile';
import { throttle } from 'throttle-debounce';
export default defineComponent({ export default defineComponent({
components: { components: {
@ -144,6 +145,11 @@ export default defineComponent({
quoteId: null, quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
imeText: '', imeText: '',
typing: throttle(3000, () => {
if (this.channel) {
os.stream.send('typingOnChannel', { channel: this.channel.id });
}
}),
postFormActions, postFormActions,
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug 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) { onKeydown(e: KeyboardEvent) {
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
if (e.which === 27) this.$emit('esc'); if (e.which === 27) this.$emit('esc');
this.typing();
}, },
onCompositionUpdate(e: CompositionEvent) { onCompositionUpdate(e: CompositionEvent) {
this.imeText = e.data; this.imeText = e.data;
this.typing();
}, },
onCompositionEnd(e: CompositionEvent) { onCompositionEnd(e: CompositionEvent) {

View File

@ -32,7 +32,7 @@ export default defineComponent({
}); });
} }
return h(TransitionGroup, { return h(this.reversed ? 'div' : TransitionGroup, {
class: 'hmjzthxl', class: 'hmjzthxl',
name: this.reversed ? 'list-reversed' : 'list', name: this.reversed ? 'list-reversed' : 'list',
tag: 'div', tag: 'div',

View File

@ -114,14 +114,9 @@
</button> </button>
</div> </div>
</header> </header>
<div class="body">
<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> <XTimeline class="body" v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
<XTimeline v-else :src="tl" :key="tl"/> <XTimeline class="body" 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>
</main> </main>
<XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> <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 XCommon from '../_common_/common.vue';
import XSide from './side.vue'; import XSide from './side.vue';
import XTimeline from './timeline.vue'; import XTimeline from './timeline.vue';
import XPostForm from './post-form.vue';
import XHeaderClock from './header-clock.vue'; import XHeaderClock from './header-clock.vue';
import * as os from '@/os'; import * as os from '@/os';
import { router } from '@/router'; import { router } from '@/router';
@ -159,7 +153,6 @@ export default defineComponent({
XWidgets, XWidgets,
XSide, // NOTE: dynamic importAsyncComponentWrapperref XSide, // NOTE: dynamic importAsyncComponentWrapperref
XTimeline, XTimeline,
XPostForm,
XHeaderClock, XHeaderClock,
}, },
@ -584,16 +577,6 @@ export default defineComponent({
} }
} }
} }
> .footer {
padding: 0 16px 16px 16px;
}
> .body {
flex: 1;
min-width: 0;
overflow: auto;
}
} }
> .side { > .side {

View File

@ -1010,7 +1010,7 @@ export default defineComponent({
flex-shrink: 0; flex-shrink: 0;
display: block; display: block;
position: sticky; position: sticky;
top: 12px; top: 0;
margin: 0 14px 0 0; margin: 0 14px 0 0;
width: 46px; width: 46px;
height: 46px; height: 46px;

View File

@ -65,6 +65,7 @@ import * as os from '@/os';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import { notePostInterruptors, postFormActions } from '@/store'; import { notePostInterruptors, postFormActions } from '@/store';
import { isMobile } from '@/scripts/is-mobile'; import { isMobile } from '@/scripts/is-mobile';
import { throttle } from 'throttle-debounce';
export default defineComponent({ export default defineComponent({
components: { components: {
@ -131,6 +132,11 @@ export default defineComponent({
quoteId: null, quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
imeText: '', imeText: '',
typing: throttle(3000, () => {
if (this.channel) {
os.stream.send('typingOnChannel', { channel: this.channel });
}
}),
postFormActions, postFormActions,
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug 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) { onKeydown(e: KeyboardEvent) {
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
if (e.which === 27) this.$emit('esc'); if (e.which === 27) this.$emit('esc');
this.typing();
}, },
onCompositionUpdate(e: CompositionEvent) { onCompositionUpdate(e: CompositionEvent) {
this.imeText = e.data; this.imeText = e.data;
this.typing();
}, },
onCompositionEnd(e: CompositionEvent) { onCompositionEnd(e: CompositionEvent) {

View File

@ -1,8 +1,22 @@
<template> <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> <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"/> <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/>
</div> </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> </template>
<script lang="ts"> <script lang="ts">
@ -12,10 +26,12 @@ import * as os from '@/os';
import * as sound from '@/scripts/sound'; import * as sound from '@/scripts/sound';
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
import follow from '@/directives/follow-append'; import follow from '@/directives/follow-append';
import XPostForm from './post-form.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
XNotes XNotes,
XPostForm,
}, },
directives: { directives: {
@ -69,6 +85,7 @@ export default defineComponent({
width: 0, width: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
typers: [],
}; };
}, },
@ -166,6 +183,9 @@ export default defineComponent({
channelId: this.channel channelId: this.channel
}); });
this.connection.on('note', prepend); 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 = { this.pagination = {
@ -190,21 +210,21 @@ export default defineComponent({
methods: { methods: {
focus() { focus() {
this.$refs.tl.focus(); this.$refs.body.focus();
}, },
goTop() { goTop() {
const container = getScrollContainer(this.$el); const container = getScrollContainer(this.$refs.body);
container.scrollTop = 0; container.scrollTop = 0;
}, },
queueUpdated(q) { queueUpdated(q) {
if (this.$el.offsetWidth !== 0) { if (this.$refs.body.offsetWidth !== 0) {
const rect = this.$el.getBoundingClientRect(); const rect = this.$refs.body.getBoundingClientRect();
const scrollTop = getScrollPosition(this.$el); const scrollTop = getScrollPosition(this.$refs.body);
this.width = this.$el.offsetWidth; this.width = this.$refs.body.offsetWidth;
this.top = rect.top + scrollTop; this.top = rect.top + scrollTop;
this.bottom = this.$el.offsetHeight; this.bottom = this.$refs.body.offsetHeight;
} }
this.queue = q; this.queue = q;
}, },
@ -213,11 +233,41 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.dbiokgaf { .dbiokgaf.top {
padding: 16px 0; padding: 16px 16px 0 16px;
}
// TODO: position sticky .dbiokgaf.bottom {
overflow: hidden; 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 { > .new {
position: fixed; position: fixed;

View File

@ -1,14 +1,17 @@
import autobind from 'autobind-decorator'; import autobind from 'autobind-decorator';
import Channel from '../channel'; import Channel from '../channel';
import { Notes } from '../../../../models'; import { Notes, Users } from '../../../../models';
import { isMutedUserRelated } from '../../../../misc/is-muted-user-related'; import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
import { PackedNote } from '../../../../models/repositories/note'; import { PackedNote } from '../../../../models/repositories/note';
import { User } from '../../../../models/entities/user';
export default class extends Channel { export default class extends Channel {
public readonly chName = 'channel'; public readonly chName = 'channel';
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private channelId: string; private channelId: string;
private typers: Record<User['id'], Date> = {};
private emitTypersIntervalId: ReturnType<typeof setInterval>;
@autobind @autobind
public async init(params: any) { public async init(params: any) {
@ -16,6 +19,8 @@ export default class extends Channel {
// Subscribe stream // Subscribe stream
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent);
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
} }
@autobind @autobind
@ -41,9 +46,41 @@ export default class extends Channel {
this.send('note', note); 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 @autobind
public dispose() { public dispose() {
// Unsubscribe events // Unsubscribe events
this.subscriber.off('notesStream', this.onNote); this.subscriber.off('notesStream', this.onNote);
this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent);
clearInterval(this.emitTypersIntervalId);
} }
} }

View File

@ -15,7 +15,7 @@ export default class extends Channel {
private gameId: ReversiGame['id'] | null = null; private gameId: ReversiGame['id'] | null = null;
private watchers: Record<User['id'], Date> = {}; private watchers: Record<User['id'], Date> = {};
private emitWatchersIntervalId: any; private emitWatchersIntervalId: ReturnType<typeof setInterval>;
@autobind @autobind
public async init(params: any) { public async init(params: any) {

View File

@ -12,6 +12,7 @@ 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';
/** /**
* Main stream connection * Main stream connection
@ -27,10 +28,10 @@ export default class Connection {
public subscriber: EventEmitter; public subscriber: EventEmitter;
private channels: Channel[] = []; private channels: Channel[] = [];
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private followingClock: NodeJS.Timer; private followingClock: ReturnType<typeof setInterval>;
private mutingClock: NodeJS.Timer; private mutingClock: ReturnType<typeof setInterval>;
private followingChannelsClock: NodeJS.Timer; private followingChannelsClock: ReturnType<typeof setInterval>;
private userProfileClock: NodeJS.Timer; private userProfileClock: ReturnType<typeof setInterval>;
constructor( constructor(
wsConnection: websocket.connection, wsConnection: websocket.connection,
@ -93,6 +94,7 @@ 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;
} }
} }
@ -258,6 +260,13 @@ export default class Connection {
} }
} }
@autobind
private typingOnChannel(channel: ChannelModel['id']) {
if (this.user) {
publishChannelStream(channel, 'typing', this.user.id);
}
}
@autobind @autobind
private async updateFollowing() { private async updateFollowing() {
const followings = await Followings.find({ const followings = await Followings.find({

View File

@ -6,6 +6,7 @@ import { ReversiGame } from '../models/entities/games/reversi/game';
import { UserGroup } from '../models/entities/user-group'; import { UserGroup } from '../models/entities/user-group';
import config from '../config'; import config from '../config';
import { Antenna } from '../models/entities/antenna'; import { Antenna } from '../models/entities/antenna';
import { Channel } from '../models/entities/channel';
class Publisher { class Publisher {
private publish = (channel: string, type: string | null, value?: any): void => { 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 => { public publishUserListStream = (listId: UserList['id'], type: string, value?: any): void => {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); 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 publishDriveStream = publisher.publishDriveStream;
export const publishNoteStream = publisher.publishNoteStream; export const publishNoteStream = publisher.publishNoteStream;
export const publishNotesStream = publisher.publishNotesStream; export const publishNotesStream = publisher.publishNotesStream;
export const publishChannelStream = publisher.publishChannelStream;
export const publishUserListStream = publisher.publishUserListStream; export const publishUserListStream = publisher.publishUserListStream;
export const publishAntennaStream = publisher.publishAntennaStream; export const publishAntennaStream = publisher.publishAntennaStream;
export const publishMessagingStream = publisher.publishMessagingStream; export const publishMessagingStream = publisher.publishMessagingStream;