mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-14 14:07:38 -07:00
Messagingの入力中インジケータを実装
This commit is contained in:
parent
c7a3ca86ea
commit
5b887e6cf3
4 changed files with 104 additions and 4 deletions
|
@ -7,6 +7,7 @@
|
|||
v-model="text"
|
||||
ref="text"
|
||||
@keypress="onKeypress"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@paste="onPaste"
|
||||
:placeholder="$ts.inputMessageHere"
|
||||
></textarea>
|
||||
|
@ -29,6 +30,7 @@ import { formatTimeString } from '../../../misc/format-time-string';
|
|||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { Autocomplete } from '@/scripts/autocomplete';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
|
@ -46,6 +48,9 @@ export default defineComponent({
|
|||
text: null,
|
||||
file: null,
|
||||
sending: false,
|
||||
typing: throttle(3000, () => {
|
||||
os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
|
||||
}),
|
||||
faPaperPlane, faPhotoVideo, faLaughSquint
|
||||
};
|
||||
},
|
||||
|
@ -147,11 +152,16 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
onKeypress(e) {
|
||||
this.typing();
|
||||
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
|
||||
this.send();
|
||||
}
|
||||
},
|
||||
|
||||
onCompositionUpdate() {
|
||||
this.typing();
|
||||
},
|
||||
|
||||
chooseFile(e) {
|
||||
selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
|
||||
this.file = file;
|
||||
|
|
|
@ -16,6 +16,14 @@
|
|||
</XList>
|
||||
</div>
|
||||
<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">
|
||||
<div class="new-message" v-show="showIndicator">
|
||||
<button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $ts.newMessageExists }}</button>
|
||||
|
@ -86,6 +94,7 @@ const Component = defineComponent({
|
|||
connection: null,
|
||||
showIndicator: false,
|
||||
timer: null,
|
||||
typers: [],
|
||||
ilObserver: new IntersectionObserver(
|
||||
(entries) => entries.some((entry) => entry.isIntersecting)
|
||||
&& !this.fetching
|
||||
|
@ -142,6 +151,9 @@ const Component = defineComponent({
|
|||
this.connection.on('message', this.onMessage);
|
||||
this.connection.on('read', this.onRead);
|
||||
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);
|
||||
|
||||
|
@ -397,6 +409,7 @@ export default Component;
|
|||
|
||||
> footer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
> .new-message {
|
||||
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: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ export default class extends Channel {
|
|||
private otherpartyId: string | null;
|
||||
private otherparty?: User;
|
||||
private groupId: string | null;
|
||||
private subCh: string;
|
||||
private typers: Record<User['id'], Date> = {};
|
||||
private emitTypersIntervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@autobind
|
||||
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.groupId}`;
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
|
@ -60,4 +77,28 @@ export default class extends Channel {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,8 @@ 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';
|
||||
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '../../../services/stream';
|
||||
import { UserGroup } from '../../../models/entities/user-group';
|
||||
|
||||
/**
|
||||
* Main stream connection
|
||||
|
@ -94,7 +95,12 @@ 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;
|
||||
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
|
||||
private async updateFollowing() {
|
||||
const followings = await Followings.find({
|
||||
|
|
Loading…
Reference in a new issue