summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2021-02-21 12:26:49 +0900
committersyuilo <syuilotan@yahoo.co.jp>2021-02-21 12:26:49 +0900
commit78a963fe334caae564424c6458a8565da957c8be (patch)
tree2751939ed79ccb39213b94a102dc90253b1b4ac6 /src
parentタイムラインを特定の日付にジャンプする機能 (diff)
downloadsharkey-78a963fe334caae564424c6458a8565da957c8be.tar.gz
sharkey-78a963fe334caae564424c6458a8565da957c8be.tar.bz2
sharkey-78a963fe334caae564424c6458a8565da957c8be.zip
Messagingの入力中インジケータを実装
Diffstat (limited to 'src')
-rw-r--r--src/client/pages/messaging/messaging-room.form.vue10
-rw-r--r--src/client/pages/messaging/messaging-room.vue32
-rw-r--r--src/server/api/stream/channels/messaging.ts47
-rw-r--r--src/server/api/stream/index.ts19
4 files changed, 104 insertions, 4 deletions
diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue
index e561cb3db5..258300dc52 100644
--- a/src/client/pages/messaging/messaging-room.form.vue
+++ b/src/client/pages/messaging/messaging-room.form.vue
@@ -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;
diff --git a/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue
index 7fdd0a201b..3921a081d1 100644
--- a/src/client/pages/messaging/messaging-room.vue
+++ b/src/client/pages/messaging/messaging-room.vue
@@ -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: " ";
+ }
+ }
+ }
}
}
diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts
index 8456871e6a..7279da3ece 100644
--- a/src/server/api/stream/channels/messaging.ts
+++ b/src/server/api/stream/channels/messaging.ts
@@ -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);
+ }
}
diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts
index b04bed0c06..c56a0a157b 100644
--- a/src/server/api/stream/index.ts
+++ b/src/server/api/stream/index.ts
@@ -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;
}
}
@@ -268,6 +274,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({
where: {