diff options
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/client/app/mobile/views/components/notes.vue | 147 | ||||
| -rw-r--r-- | src/client/app/mobile/views/components/timeline.vue | 117 | ||||
| -rw-r--r-- | src/client/app/mobile/views/components/user-timeline.vue | 55 | ||||
| -rw-r--r-- | src/server/index.ts | 6 |
5 files changed, 197 insertions, 129 deletions
diff --git a/package.json b/package.json index 7d0adc3cb6..d37bbc040d 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "koa-multer": "1.0.2", "koa-router": "7.4.0", "koa-send": "4.1.3", + "koa-slow": "^2.1.0", "kue": "0.11.6", "license-checker": "18.0.0", "loader-utils": "1.1.0", diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 999ab566ac..137e15c6de 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -1,7 +1,20 @@ <template> <div class="mk-notes"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> + <slot name="head"></slot> - <slot></slot> + + <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> + + <div class="init" v-if="fetching"> + %fa:spinner .pulse%%i18n:common.loading% + </div> + + <div v-if="!fetching && requestInitPromise != null"> + <p>読み込みに失敗しました。</p> + <button @click="resolveInitPromise">リトライ</button> + </div> + <transition-group name="mk-notes" class="transition"> <template v-for="(note, i) in _notes"> <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> @@ -11,8 +24,12 @@ </p> </template> </transition-group> - <footer> - <slot name="tail"></slot> + + <footer v-if="more"> + <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">%i18n:@load-more%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> </footer> </div> </template> @@ -20,13 +37,26 @@ <script lang="ts"> import Vue from 'vue'; +const displayLimit = 30; + export default Vue.extend({ props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + requestInitPromise: null as () => Promise<any[]>, + notes: [], + queue: [], + fetching: true, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -38,9 +68,107 @@ export default Vue.extend({ }); } }, + + mounted() { + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + onNoteUpdated(i, note) { Vue.set((this as any).notes, i, note); + }, + + init(promiseGenerator: () => Promise<any[]>) { + this.requestInitPromise = promiseGenerator; + this.resolveInitPromise(); + }, + + resolveInitPromise() { + this.queue = []; + this.notes = []; + this.fetching = true; + + const promise = this.requestInitPromise(); + + promise.then(notes => { + this.notes = notes; + this.requestInitPromise = null; + this.fetching = false; + }, e => { + this.fetching = false; + }); + }, + + prepend(note, silent = false) { + //#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; + + if ((this as any).os.i.clientSettings.showMyRenotes === false) { + if (isMyNote && isPureRenote) { + return; + } + } + + if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { + if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { + return; + } + } + //#endregion + + if (this.isScrollTop()) { + // Prepend the note + this.notes.unshift(note); + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + } else { + this.queue.unshift(note); + } + }, + + append(note) { + this.notes.push(note); + }, + + tail() { + return this.notes[this.notes.length - 1]; + }, + + releaseQueue() { + this.queue.forEach(n => this.prepend(n, true)); + this.queue = []; + }, + + async loadMore() { + if (this.more == null) return; + if (this.moreFetching) return; + + this.moreFetching = true; + await this.more(); + this.moreFetching = false; + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + } + + if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.loadMore(); + } } } }); @@ -79,6 +207,13 @@ export default Vue.extend({ [data-fa] margin-right 8px + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color + > .init padding 64px 0 text-align center diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue index f56667bed5..561034917e 100644 --- a/src/client/app/mobile/views/components/timeline.vue +++ b/src/client/app/mobile/views/components/timeline.vue @@ -1,19 +1,12 @@ <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"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> + + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> %fa:R comments% %i18n:@empty% </div> - <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> </mk-notes> </div> </template> @@ -22,7 +15,6 @@ import Vue from 'vue'; const fetchLimit = 10; -const displayLimit = 30; export default Vue.extend({ props: { @@ -37,8 +29,6 @@ export default Vue.extend({ return { fetching: true, moreFetching: false, - notes: [], - queue: [], existMore: false, connection: null, connectionId: null @@ -48,10 +38,6 @@ 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; } }, @@ -63,8 +49,6 @@ export default Vue.extend({ this.connection.on('follow', this.onChangeFollowing); this.connection.on('unfollow', this.onChangeFollowing); - window.addEventListener('scroll', this.onScroll); - this.fetch(); }, @@ -73,102 +57,54 @@ 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: 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 == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/timeline', { + limit: fetchLimit + 1, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }, rej); + })); }, more() { this.moreFetching = true; (this as any).api('notes/timeline', { limit: fetchLimit + 1, - untilId: this.notes[this.notes.length - 1].id, + untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); - this.existMore = true; } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); }, - 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; - - if ((this as any).os.i.clientSettings.showMyRenotes === false) { - if (isMyNote && isPureRenote) { - return; - } - } - - if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { - if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { - return; - } - } - //#endregion - - if (this.isScrollTop()) { - this.prependNote(note); - } else { - this.queue.unshift(note); - } + // Prepend a note + (this.$refs.timeline as any).prepend(note); }, onChangeFollowing() { this.fetch(); - }, - - onScroll() { - if (this.isScrollTop()) { - this.releaseQueue(); - } } } }); @@ -178,13 +114,6 @@ export default Vue.extend({ @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/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index 40b3be035e..89ac4d2c66 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -1,17 +1,10 @@ <template> <div class="mk-user-timeline"> - <mk-notes :notes="notes"> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> %fa:R comments% {{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }} </div> - <button v-if="!fetching && existMore" @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> </mk-notes> </div> </template> @@ -19,49 +12,53 @@ <script lang="ts"> import Vue from 'vue'; -const limit = 10; +const fetchLimit = 10; export default Vue.extend({ props: ['user', 'withMedia'], data() { return { fetching: true, - notes: [], existMore: false, moreFetching: false }; }, mounted() { - (this as any).api('users/notes', { - userId: this.user.id, - withMedia: this.withMedia, - limit: limit + 1 - }).then(notes => { - if (notes.length == limit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - }); + this.fetch(); }, methods: { + fetch() { + this.fetching = true; + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + withMedia: this.withMedia, + limit: fetchLimit + 1 + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, more() { this.moreFetching = true; (this as any).api('users/notes', { userId: this.user.id, withMedia: this.withMedia, - limit: limit + 1, - untilId: this.notes[this.notes.length - 1].id + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id }).then(notes => { - if (notes.length == limit + 1) { + if (notes.length == fetchLimit + 1) { notes.pop(); - this.existMore = true; } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); } diff --git a/src/server/index.ts b/src/server/index.ts index 2b5a910507..594f40c22f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,7 @@ import * as Router from 'koa-router'; import * as mount from 'koa-mount'; import * as compress from 'koa-compress'; import * as logger from 'koa-logger'; +const slow = require('koa-slow'); import activityPub from './activitypub'; import webFinger from './webfinger'; @@ -23,6 +24,11 @@ app.proxy = true; if (process.env.NODE_ENV != 'production') { // Logger app.use(logger()); + + // Delay + app.use(slow({ + delay: 1000 + })); } // Compress response |