summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2021-02-20 20:20:05 +0900
committersyuilo <syuilotan@yahoo.co.jp>2021-02-20 20:20:05 +0900
commit25d37302a8bfda954c7ede1e9d355db587c82228 (patch)
tree8d2eff01cc738a84776976d36cabc8a143b40798 /src
parentImprove usability (diff)
downloadsharkey-25d37302a8bfda954c7ede1e9d355db587c82228.tar.gz
sharkey-25d37302a8bfda954c7ede1e9d355db587c82228.tar.bz2
sharkey-25d37302a8bfda954c7ede1e9d355db587c82228.zip
チャンネルで入力中ユーザーを表示するように、Chat UIでタイムラインでは投稿フォームを上に表示するように
Diffstat (limited to 'src')
-rw-r--r--src/client/components/post-form.vue8
-rw-r--r--src/client/ui/chat/date-separated-list.vue2
-rw-r--r--src/client/ui/chat/index.vue23
-rw-r--r--src/client/ui/chat/note.vue2
-rw-r--r--src/client/ui/chat/post-form.vue8
-rw-r--r--src/client/ui/chat/timeline.vue76
-rw-r--r--src/server/api/stream/channels/channel.ts39
-rw-r--r--src/server/api/stream/channels/games/reversi-game.ts2
-rw-r--r--src/server/api/stream/index.ts17
-rw-r--r--src/services/stream.ts6
10 files changed, 142 insertions, 41 deletions
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index fa9aeff8af..7849095ba8 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -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) {
diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue
index b209330656..65deb9e1c2 100644
--- a/src/client/ui/chat/date-separated-list.vue
+++ b/src/client/ui/chat/date-separated-list.vue
@@ -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',
diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue
index dd3c82d8ba..0ee32833a8 100644
--- a/src/client/ui/chat/index.vue
+++ b/src/client/ui/chat/index.vue
@@ -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 {
diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue
index 4e4a303c36..315f5c91e3 100644
--- a/src/client/ui/chat/note.vue
+++ b/src/client/ui/chat/note.vue
@@ -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;
diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue
index 38fe48cc62..b0a31b097d 100644
--- a/src/client/ui/chat/post-form.vue
+++ b/src/client/ui/chat/post-form.vue
@@ -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) {
diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue
index f96a48a776..12cb7af7d2 100644
--- a/src/client/ui/chat/timeline.vue
+++ b/src/client/ui/chat/timeline.vue
@@ -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;
+}
+
+.dbiokgaf.bottom {
+ padding: 0 16px 16px 16px;
+ position: relative;
- // TODO: これはノート追加アニメーションによるスクロール発生を抑えるために必要だが、position stickyが効かなくなるので、両者を両立させる良い方法を考える
- overflow: hidden;
+ > .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;
diff --git a/src/server/api/stream/channels/channel.ts b/src/server/api/stream/channels/channel.ts
index c24b3db937..aa570d1ef4 100644
--- a/src/server/api/stream/channels/channel.ts
+++ b/src/server/api/stream/channels/channel.ts
@@ -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
@@ -42,8 +47,40 @@ export default class extends Channel {
}
@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);
}
}
diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts
index ea62ab1e88..e1c2116ac6 100644
--- a/src/server/api/stream/channels/games/reversi-game.ts
+++ b/src/server/api/stream/channels/games/reversi-game.ts
@@ -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) {
diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts
index 5b975d07db..b04bed0c06 100644
--- a/src/server/api/stream/index.ts
+++ b/src/server/api/stream/index.ts
@@ -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;
}
}
@@ -259,6 +261,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({
where: {
diff --git a/src/services/stream.ts b/src/services/stream.ts
index ec43c6ff2c..d833d700fe 100644
--- a/src/services/stream.ts
+++ b/src/services/stream.ts
@@ -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;