diff --git a/package.json b/package.json index 9c2f78d21..c5962a3db 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "vue-router": "3.0.1", "vue-template-compiler": "2.5.16", "vuedraggable": "2.16.0", + "vuex": "^3.0.1", "web-push": "3.3.0", "webfinger.js": "2.6.6", "webpack": "4.6.0", diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index f66ae5788..719425c3c 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,5 +1,6 @@ <template> <div class="mk-home-timeline"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> <mk-friends-maker v-if="src == 'home' && alone"/> <div class="fetching" v-if="fetching"> <mk-ellipsis-icon/> @@ -20,6 +21,9 @@ import Vue from 'vue'; import { url } from '../../../config'; +const fetchLimit = 10; +const displayLimit = 30; + export default Vue.extend({ props: { src: { @@ -34,6 +38,7 @@ export default Vue.extend({ moreFetching: false, existMore: false, notes: [], + queue: [], connection: null, connectionId: null, date: null @@ -59,6 +64,10 @@ export default Vue.extend({ : this.src == 'local' ? 'notes/local-timeline' : 'notes/global-timeline'; + }, + + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.notes.length > 0 && this.existMore; } }, @@ -72,6 +81,9 @@ export default Vue.extend({ this.connection.on('unfollow', this.onChangeFollowing); } + document.addEventListener('keydown', this.onKeydown); + window.addEventListener('scroll', this.onScroll); + this.fetch(); }, @@ -82,19 +94,27 @@ export default Vue.extend({ this.connection.off('unfollow', this.onChangeFollowing); } this.stream.dispose(this.connectionId); + + document.removeEventListener('keydown', this.onKeydown); + window.removeEventListener('scroll', this.onScroll); }, methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + fetch(cb?) { + this.queue = []; this.fetching = true; (this as any).api(this.endpoint, { - limit: 11, + limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : undefined, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { - if (notes.length == 11) { + if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; } @@ -106,15 +126,17 @@ export default Vue.extend({ }, more() { - if (this.moreFetching || this.fetching || this.notes.length == 0 || !this.existMore) return; + if (!this.canFetchMore) return; + this.moreFetching = true; + (this as any).api(this.endpoint, { - limit: 11, + limit: fetchLimit + 1, untilId: this.notes[this.notes.length - 1].id, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { - if (notes.length == 11) { + if (notes.length == fetchLimit + 1) { notes.pop(); } else { this.existMore = false; @@ -124,7 +146,30 @@ export default Vue.extend({ }); }, + prependNote(note, silent = false) { + // サウンドを再生する + if ((this as any).os.isEnableSounds && !silent) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; + sound.play(); + } + + // Prepent a note + this.notes.unshift(note); + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + }, + + releaseQueue() { + this.queue.forEach(n => this.prependNote(n, true)); + this.queue = []; + }, + onNote(note) { + //#region 弾く const isMyNote = note.userId == (this as any).os.i.id; const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; @@ -139,18 +184,13 @@ export default Vue.extend({ return; } } + //#endregion - // サウンドを再生する - if ((this as any).os.isEnableSounds) { - const sound = new Audio(`${url}/assets/post.mp3`); - sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; - sound.play(); + if (this.isScrollTop()) { + this.prependNote(note); + } else { + this.queue.unshift(note); } - - this.notes.unshift(note); - - const isTop = window.scrollY > 8; - if (isTop) this.notes.pop(); }, onChangeFollowing() { @@ -164,13 +204,41 @@ export default Vue.extend({ warp(date) { this.date = date; this.fetch(); - } + }, + + onScroll() { + if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + } + + if (this.isScrollTop()) { + this.releaseQueue(); + } + }, + + onKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + this.focus(); + } + } + }, } }); </script> <style lang="stylus" scoped> +@import '~const.styl' + .mk-home-timeline + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color + > .mk-friends-maker border-bottom solid 1px #eee diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 12f928d15..8035510a1 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -27,35 +27,12 @@ export default Vue.extend({ }, mounted() { - document.addEventListener('keydown', this.onKeydown); - window.addEventListener('scroll', this.onScroll); - (this.$refs.tl as any).$once('loaded', () => { this.$emit('loaded'); }); }, - beforeDestroy() { - document.removeEventListener('keydown', this.onKeydown); - window.removeEventListener('scroll', this.onScroll); - }, - methods: { - onScroll() { - if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 8) (this.$refs.tl as any).more(); - } - }, - - onKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - (this.$refs.tl as any).focus(); - } - } - }, - warp(date) { (this.$refs.tl as any).warp(date); } diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 96c26367f..7ce85d4c3 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -43,6 +43,8 @@ export default Vue.extend({ XClock, }, mounted() { + this.$store.commit('setUiHeaderHeight', 48); + if ((this as any).os.isSignedIn) { const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000 const isHisasiburi = ago >= 3600; diff --git a/src/client/app/init.ts b/src/client/app/init.ts index a3ab2e8e3..461093488 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -3,6 +3,7 @@ */ import Vue from 'vue'; +import Vuex from 'vuex'; import VueRouter from 'vue-router'; import VModal from 'vue-js-modal'; import * as TreeView from 'vue-json-tree-view'; @@ -23,6 +24,7 @@ switch (lang) { default: elementLocale = ElementLocaleEn; break; } +Vue.use(Vuex); Vue.use(VueRouter); Vue.use(VModal); Vue.use(TreeView); @@ -39,6 +41,17 @@ require('./common/views/widgets'); // Register global filters require('./common/views/filters'); +const store = new Vuex.Store({ + state: { + uiHeaderHeight: 0 + }, + mutations: { + setUiHeaderHeight(state, height) { + state.uiHeaderHeight = height; + } + } +}); + Vue.mixin({ destroyed(this: any) { if (this.$el.parentNode) { @@ -145,6 +158,7 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) }); const app = new Vue({ + store, router, created() { this.$watch('os.i', i => { diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue index a6227996b..f56667bed 100644 --- a/src/client/app/mobile/views/components/timeline.vue +++ b/src/client/app/mobile/views/components/timeline.vue @@ -1,5 +1,6 @@ <template> <div class="mk-timeline"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> <mk-friends-maker v-if="alone"/> <mk-notes :notes="notes"> <div class="init" v-if="fetching"> @@ -9,7 +10,7 @@ %fa:R comments% %i18n:@empty% </div> - <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> + <button v-if="canFetchMore" @click="more" :disabled="moreFetching" slot="tail"> <span v-if="!moreFetching">%i18n:@load-more%</span> <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> </button> @@ -20,7 +21,8 @@ <script lang="ts"> import Vue from 'vue'; -const limit = 10; +const fetchLimit = 10; +const displayLimit = 30; export default Vue.extend({ props: { @@ -36,6 +38,7 @@ export default Vue.extend({ fetching: true, moreFetching: false, notes: [], + queue: [], existMore: false, connection: null, connectionId: null @@ -45,6 +48,10 @@ export default Vue.extend({ computed: { alone(): boolean { return (this as any).os.i.followingCount == 0; + }, + + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.notes.length > 0 && this.existMore; } }, @@ -56,6 +63,8 @@ export default Vue.extend({ this.connection.on('follow', this.onChangeFollowing); this.connection.on('unfollow', this.onChangeFollowing); + window.addEventListener('scroll', this.onScroll); + this.fetch(); }, @@ -64,18 +73,25 @@ export default Vue.extend({ this.connection.off('follow', this.onChangeFollowing); this.connection.off('unfollow', this.onChangeFollowing); (this as any).os.stream.dispose(this.connectionId); + + window.removeEventListener('scroll', this.onScroll); }, methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + fetch(cb?) { + this.queue = []; this.fetching = true; (this as any).api('notes/timeline', { - limit: limit + 1, + limit: fetchLimit + 1, untilDate: this.date ? (this.date as any).getTime() : undefined, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { - if (notes.length == limit + 1) { + if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; } @@ -89,12 +105,12 @@ export default Vue.extend({ more() { this.moreFetching = true; (this as any).api('notes/timeline', { - limit: limit + 1, + limit: fetchLimit + 1, untilId: this.notes[this.notes.length - 1].id, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { - if (notes.length == limit + 1) { + if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; } else { @@ -105,7 +121,23 @@ export default Vue.extend({ }); }, + prependNote(note) { + // Prepent a note + this.notes.unshift(note); + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + }, + + releaseQueue() { + this.queue.forEach(n => this.prependNote(n)); + this.queue = []; + }, + onNote(note) { + //#region 弾く const isMyNote = note.userId == (this as any).os.i.id; const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; @@ -120,21 +152,39 @@ export default Vue.extend({ return; } } + //#endregion - this.notes.unshift(note); - - const isTop = window.scrollY > 8; - if (isTop) this.notes.pop(); + if (this.isScrollTop()) { + this.prependNote(note); + } else { + this.queue.unshift(note); + } }, onChangeFollowing() { this.fetch(); + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + } } } }); </script> <style lang="stylus" scoped> -.mk-friends-maker - margin-bottom 8px +@import '~const.styl' + +.mk-timeline + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color + + > .mk-friends-maker + margin-bottom 8px </style> diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index 0ee67a15b..adb2535aa 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -32,6 +32,8 @@ export default Vue.extend({ }; }, mounted() { + this.$store.commit('setUiHeaderHeight', 48); + if ((this as any).os.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use();