This commit is contained in:
syuilo 2018-04-26 11:46:42 +09:00
parent a2a3dd55ad
commit bc8a0083e2
5 changed files with 197 additions and 129 deletions

View file

@ -144,6 +144,7 @@
"koa-multer": "1.0.2", "koa-multer": "1.0.2",
"koa-router": "7.4.0", "koa-router": "7.4.0",
"koa-send": "4.1.3", "koa-send": "4.1.3",
"koa-slow": "^2.1.0",
"kue": "0.11.6", "kue": "0.11.6",
"license-checker": "18.0.0", "license-checker": "18.0.0",
"loader-utils": "1.1.0", "loader-utils": "1.1.0",

View file

@ -1,7 +1,20 @@
<template> <template>
<div class="mk-notes"> <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 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"> <transition-group name="mk-notes" class="transition">
<template v-for="(note, i) in _notes"> <template v-for="(note, i) in _notes">
<mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
@ -11,8 +24,12 @@
</p> </p>
</template> </template>
</transition-group> </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> </footer>
</div> </div>
</template> </template>
@ -20,13 +37,26 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
const displayLimit = 30;
export default Vue.extend({ export default Vue.extend({
props: { props: {
notes: { more: {
type: Array, type: Function,
default: () => [] required: false
} }
}, },
data() {
return {
requestInitPromise: null as () => Promise<any[]>,
notes: [],
queue: [],
fetching: true,
moreFetching: false
};
},
computed: { computed: {
_notes(): any[] { _notes(): any[] {
return (this.notes as any).map(note => { 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: { methods: {
isScrollTop() {
return window.scrollY <= 8;
},
onNoteUpdated(i, note) { onNoteUpdated(i, note) {
Vue.set((this as any).notes, 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] [data-fa]
margin-right 8px margin-right 8px
> .newer-indicator
position -webkit-sticky
position sticky
z-index 100
height 3px
background $theme-color
> .init > .init
padding 64px 0 padding 64px 0
text-align center text-align center

View file

@ -1,19 +1,12 @@
<template> <template>
<div class="mk-timeline"> <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-friends-maker v-if="alone"/>
<mk-notes :notes="notes">
<div class="init" v-if="fetching"> <mk-notes ref="timeline" :more="existMore ? more : null">
%fa:spinner .pulse%%i18n:common.loading% <div slot="empty">
</div>
<div class="empty" v-if="!fetching && notes.length == 0">
%fa:R comments% %fa:R comments%
%i18n:@empty% %i18n:@empty%
</div> </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> </mk-notes>
</div> </div>
</template> </template>
@ -22,7 +15,6 @@
import Vue from 'vue'; import Vue from 'vue';
const fetchLimit = 10; const fetchLimit = 10;
const displayLimit = 30;
export default Vue.extend({ export default Vue.extend({
props: { props: {
@ -37,8 +29,6 @@ export default Vue.extend({
return { return {
fetching: true, fetching: true,
moreFetching: false, moreFetching: false,
notes: [],
queue: [],
existMore: false, existMore: false,
connection: null, connection: null,
connectionId: null connectionId: null
@ -48,10 +38,6 @@ export default Vue.extend({
computed: { computed: {
alone(): boolean { alone(): boolean {
return (this as any).os.i.followingCount == 0; 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('follow', this.onChangeFollowing);
this.connection.on('unfollow', this.onChangeFollowing); this.connection.on('unfollow', this.onChangeFollowing);
window.addEventListener('scroll', this.onScroll);
this.fetch(); this.fetch();
}, },
@ -73,102 +57,54 @@ export default Vue.extend({
this.connection.off('follow', this.onChangeFollowing); this.connection.off('follow', this.onChangeFollowing);
this.connection.off('unfollow', this.onChangeFollowing); this.connection.off('unfollow', this.onChangeFollowing);
(this as any).os.stream.dispose(this.connectionId); (this as any).os.stream.dispose(this.connectionId);
window.removeEventListener('scroll', this.onScroll);
}, },
methods: { methods: {
isScrollTop() {
return window.scrollY <= 8;
},
fetch(cb?) { fetch(cb?) {
this.queue = [];
this.fetching = true; this.fetching = true;
(this as any).api('notes/timeline', { (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
limit: fetchLimit + 1, (this as any).api('notes/timeline', {
untilDate: this.date ? (this.date as any).getTime() : undefined, limit: fetchLimit + 1,
includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes,
includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes
}).then(notes => { }).then(notes => {
if (notes.length == fetchLimit + 1) { if (notes.length == fetchLimit + 1) {
notes.pop(); notes.pop();
this.existMore = true; this.existMore = true;
} }
this.notes = notes; res(notes);
this.fetching = false; this.fetching = false;
this.$emit('loaded'); this.$emit('loaded');
if (cb) cb(); if (cb) cb();
}); }, rej);
}));
}, },
more() { more() {
this.moreFetching = true; this.moreFetching = true;
(this as any).api('notes/timeline', { (this as any).api('notes/timeline', {
limit: fetchLimit + 1, 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, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes,
includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes
}).then(notes => { }).then(notes => {
if (notes.length == fetchLimit + 1) { if (notes.length == fetchLimit + 1) {
notes.pop(); notes.pop();
this.existMore = true;
} else { } else {
this.existMore = false; this.existMore = false;
} }
this.notes = this.notes.concat(notes); notes.forEach(n => (this.$refs.timeline as any).append(n));
this.moreFetching = false; 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) { onNote(note) {
//#region // Prepend a note
const isMyNote = note.userId == (this as any).os.i.id; (this.$refs.timeline as any).prepend(note);
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);
}
}, },
onChangeFollowing() { onChangeFollowing() {
this.fetch(); this.fetch();
},
onScroll() {
if (this.isScrollTop()) {
this.releaseQueue();
}
} }
} }
}); });
@ -178,13 +114,6 @@ export default Vue.extend({
@import '~const.styl' @import '~const.styl'
.mk-timeline .mk-timeline
> .newer-indicator
position -webkit-sticky
position sticky
z-index 100
height 3px
background $theme-color
> .mk-friends-maker > .mk-friends-maker
margin-bottom 8px margin-bottom 8px
</style> </style>

View file

@ -1,17 +1,10 @@
<template> <template>
<div class="mk-user-timeline"> <div class="mk-user-timeline">
<mk-notes :notes="notes"> <mk-notes ref="timeline" :more="existMore ? more : null">
<div class="init" v-if="fetching"> <div slot="empty">
%fa:spinner .pulse%%i18n:common.loading%
</div>
<div class="empty" v-if="!fetching && notes.length == 0">
%fa:R comments% %fa:R comments%
{{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }} {{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }}
</div> </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> </mk-notes>
</div> </div>
</template> </template>
@ -19,49 +12,53 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
const limit = 10; const fetchLimit = 10;
export default Vue.extend({ export default Vue.extend({
props: ['user', 'withMedia'], props: ['user', 'withMedia'],
data() { data() {
return { return {
fetching: true, fetching: true,
notes: [],
existMore: false, existMore: false,
moreFetching: false moreFetching: false
}; };
}, },
mounted() { mounted() {
(this as any).api('users/notes', { this.fetch();
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');
});
}, },
methods: { 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() { more() {
this.moreFetching = true; this.moreFetching = true;
(this as any).api('users/notes', { (this as any).api('users/notes', {
userId: this.user.id, userId: this.user.id,
withMedia: this.withMedia, withMedia: this.withMedia,
limit: limit + 1, limit: fetchLimit + 1,
untilId: this.notes[this.notes.length - 1].id untilId: (this.$refs.timeline as any).tail().id
}).then(notes => { }).then(notes => {
if (notes.length == limit + 1) { if (notes.length == fetchLimit + 1) {
notes.pop(); notes.pop();
this.existMore = true;
} else { } else {
this.existMore = false; this.existMore = false;
} }
this.notes = this.notes.concat(notes); notes.forEach(n => (this.$refs.timeline as any).append(n));
this.moreFetching = false; this.moreFetching = false;
}); });
} }

View file

@ -11,6 +11,7 @@ import * as Router from 'koa-router';
import * as mount from 'koa-mount'; import * as mount from 'koa-mount';
import * as compress from 'koa-compress'; import * as compress from 'koa-compress';
import * as logger from 'koa-logger'; import * as logger from 'koa-logger';
const slow = require('koa-slow');
import activityPub from './activitypub'; import activityPub from './activitypub';
import webFinger from './webfinger'; import webFinger from './webfinger';
@ -23,6 +24,11 @@ app.proxy = true;
if (process.env.NODE_ENV != 'production') { if (process.env.NODE_ENV != 'production') {
// Logger // Logger
app.use(logger()); app.use(logger());
// Delay
app.use(slow({
delay: 1000
}));
} }
// Compress response // Compress response