Messagingの入力中インジケータを実装

This commit is contained in:
syuilo 2021-02-21 12:26:49 +09:00
parent f3aef8df75
commit 78a963fe33
4 changed files with 104 additions and 4 deletions

View file

@ -7,6 +7,7 @@
v-model="text" v-model="text"
ref="text" ref="text"
@keypress="onKeypress" @keypress="onKeypress"
@compositionupdate="onCompositionUpdate"
@paste="onPaste" @paste="onPaste"
:placeholder="$ts.inputMessageHere" :placeholder="$ts.inputMessageHere"
></textarea> ></textarea>
@ -29,6 +30,7 @@ import { formatTimeString } from '../../../misc/format-time-string';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import * as os from '@/os'; import * as os from '@/os';
import { Autocomplete } from '@/scripts/autocomplete'; import { Autocomplete } from '@/scripts/autocomplete';
import { throttle } from 'throttle-debounce';
export default defineComponent({ export default defineComponent({
props: { props: {
@ -46,6 +48,9 @@ export default defineComponent({
text: null, text: null,
file: null, file: null,
sending: false, sending: false,
typing: throttle(3000, () => {
os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
}),
faPaperPlane, faPhotoVideo, faLaughSquint faPaperPlane, faPhotoVideo, faLaughSquint
}; };
}, },
@ -147,11 +152,16 @@ export default defineComponent({
}, },
onKeypress(e) { onKeypress(e) {
this.typing();
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
this.send(); this.send();
} }
}, },
onCompositionUpdate() {
this.typing();
},
chooseFile(e) { chooseFile(e) {
selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
this.file = file; this.file = file;

View file

@ -16,6 +16,14 @@
</XList> </XList>
</div> </div>
<footer> <footer>
<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>
<transition name="fade"> <transition name="fade">
<div class="new-message" v-show="showIndicator"> <div class="new-message" v-show="showIndicator">
<button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button> <button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button>
@ -86,6 +94,7 @@ const Component = defineComponent({
connection: null, connection: null,
showIndicator: false, showIndicator: false,
timer: null, timer: null,
typers: [],
ilObserver: new IntersectionObserver( ilObserver: new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting) (entries) => entries.some((entry) => entry.isIntersecting)
&& !this.fetching && !this.fetching
@ -142,6 +151,9 @@ const Component = defineComponent({
this.connection.on('message', this.onMessage); this.connection.on('message', this.onMessage);
this.connection.on('read', this.onRead); this.connection.on('read', this.onRead);
this.connection.on('deleted', this.onDeleted); this.connection.on('deleted', this.onDeleted);
this.connection.on('typers', typers => {
this.typers = typers.filter(u => u.id !== this.$i.id);
});
document.addEventListener('visibilitychange', this.onVisibilitychange); document.addEventListener('visibilitychange', this.onVisibilitychange);
@ -397,6 +409,7 @@ export default Component;
> footer { > footer {
width: 100%; width: 100%;
position: relative;
> .new-message { > .new-message {
position: absolute; position: absolute;
@ -422,6 +435,25 @@ export default Component;
} }
} }
} }
> .typers {
position: absolute;
bottom: 100%;
padding: 0 8px 0 8px;
font-size: 0.9em;
color: var(--fgTransparentWeak);
> .users {
> .user + .user:before {
content: ", ";
font-weight: normal;
}
> .user:last-of-type:after {
content: " ";
}
}
}
} }
} }

View file

@ -12,6 +12,9 @@ export default class extends Channel {
private otherpartyId: string | null; private otherpartyId: string | null;
private otherparty?: User; private otherparty?: User;
private groupId: string | null; private groupId: string | null;
private subCh: string;
private typers: Record<User['id'], Date> = {};
private emitTypersIntervalId: ReturnType<typeof setInterval>;
@autobind @autobind
public async init(params: any) { 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.user!.id}-${this.otherpartyId}`
: `messagingStream:${this.groupId}`; : `messagingStream:${this.groupId}`;
// Subscribe messaging stream // 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); this.send(data);
}); }
} }
@autobind @autobind
@ -60,4 +77,28 @@ export default class extends Channel {
break; 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);
}
} }

View file

@ -12,7 +12,8 @@ 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'; import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '../../../services/stream';
import { UserGroup } from '../../../models/entities/user-group';
/** /**
* Main stream connection * Main stream connection
@ -94,7 +95,12 @@ 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; 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 @autobind
private async updateFollowing() { private async updateFollowing() {
const followings = await Followings.find({ const followings = await Followings.find({