diff --git a/.eslintrc b/.eslintrc index 7a74d6ef9b..0943cb4b64 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,6 +14,7 @@ "vue/no-unused-vars": false, "vue/attributes-order": false, "vue/require-prop-types": false, + "vue/require-default-prop": false, "no-console": 0, "no-unused-vars": 0, "no-empty": 0 diff --git a/src/client/app/desktop/views/components/list-timeline.vue b/src/client/app/desktop/views/components/list-timeline.vue new file mode 100644 index 0000000000..61300f6f8f --- /dev/null +++ b/src/client/app/desktop/views/components/list-timeline.vue @@ -0,0 +1,75 @@ +<template> + <mk-notes ref="timeline" :more="more"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: ['list'], + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + + this.fetch(); + }, + beforeDestroy() { + this.connection.off('note', this.onNote); + this.connection.off('userAdded', this.onUserAdded); + this.connection.off('userRemoved', this.onUserRemoved); + this.connection.close(); + }, + methods: { + fetch() { + this.fetching = true; + + (this as any).api('notes/list-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; + } + (this.$refs.timeline as any).init(notes); + this.fetching = false; + this.$emit('loaded'); + }); + }, + more() { + this.moreFetching = true; + + (this as any).api('notes/list-timeline', { + limit: fetchLimit + 1, + 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(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/lists-window.vue b/src/client/app/desktop/views/components/lists-window.vue index 7d6a5def2c..7097e5ed4b 100644 --- a/src/client/app/desktop/views/components/lists-window.vue +++ b/src/client/app/desktop/views/components/lists-window.vue @@ -2,10 +2,8 @@ <mk-window ref="window" is-modal width="500px" height="550px" @closed="$destroy"> <span slot="header" :class="$style.header">%fa:list% リスト</span> - <button class="ui">リストを作成</button> - <a v-for="list in lists" :key="list.id"> - - </a> + <button class="ui" @click="add">リストを作成</button> + <router-link v-for="list in lists" :key="list.id" :to="`/i/lists/${list.id}`">{{ list.title }}</router-link> </mk-window> </template> @@ -25,6 +23,17 @@ export default Vue.extend({ }); }, methods: { + add() { + (this as any).apis.input({ + title: 'リスト名', + }).then(async title => { + const list = await (this as any).api('users/lists/create', { + title + }); + + this.$router.push(`i/lists/${ list.id }`); + }); + }, close() { (this as any).$refs.window.close(); } diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index 1a33a4240b..2822cb8c0f 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -9,8 +9,11 @@ </p> </template> </transition-group> - <footer> - <slot name="footer"></slot> + <footer v-if="loadMore"> + <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> @@ -19,16 +22,29 @@ import Vue from 'vue'; import XNote from './notes.note.vue'; +const displayLimit = 30; + export default Vue.extend({ components: { XNote }, + props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + notes: [], + queue: [], + fetching: false, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -40,12 +56,74 @@ export default Vue.extend({ }); } }, + + mounted() { + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + focus() { (this.$el as any).children[0].focus(); }, + onNoteUpdated(i, note) { Vue.set((this as any).notes, i, note); + }, + + init(notes) { + this.queue = []; + this.notes = notes; + }, + + prepend(note) { + if (this.isScrollTop()) { + 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)); + this.queue = []; + }, + + async loadMore() { + 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(); + } } } }); diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 719425c3c7..11c7adf158 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-home-timeline"> +<div class="mk-timeline-core"> <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"> @@ -8,12 +8,7 @@ <p class="empty" v-if="notes.length == 0 && !fetching"> %fa:R comments%%i18n:@empty% </p> - <mk-notes :notes="notes" ref="timeline"> - <button slot="footer" @click="more" :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> - </mk-notes> + <mk-notes :notes="notes" ref="timeline" :more="canFetchMore ? more : null"/> </div> </template> @@ -22,7 +17,6 @@ import Vue from 'vue'; import { url } from '../../../config'; const fetchLimit = 10; -const displayLimit = 30; export default Vue.extend({ props: { @@ -37,8 +31,6 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, - notes: [], - queue: [], connection: null, connectionId: null, date: null @@ -67,7 +59,7 @@ export default Vue.extend({ }, canFetchMore(): boolean { - return !this.moreFetching && !this.fetching && this.notes.length > 0 && this.existMore; + return !this.moreFetching && !this.fetching && this.existMore; } }, @@ -82,7 +74,6 @@ export default Vue.extend({ } document.addEventListener('keydown', this.onKeydown); - window.addEventListener('scroll', this.onScroll); this.fetch(); }, @@ -96,7 +87,6 @@ export default Vue.extend({ this.stream.dispose(this.connectionId); document.removeEventListener('keydown', this.onKeydown); - window.removeEventListener('scroll', this.onScroll); }, methods: { @@ -105,7 +95,6 @@ export default Vue.extend({ }, fetch(cb?) { - this.queue = []; this.fetching = true; (this as any).api(this.endpoint, { @@ -118,7 +107,7 @@ export default Vue.extend({ notes.pop(); this.existMore = true; } - this.notes = notes; + (this.$refs.timeline as any).init(notes); this.fetching = false; this.$emit('loaded'); if (cb) cb(); @@ -132,7 +121,7 @@ export default Vue.extend({ (this as any).api(this.endpoint, { 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 => { @@ -141,33 +130,11 @@ export default Vue.extend({ } 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, 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; @@ -186,11 +153,15 @@ export default Vue.extend({ } //#endregion - if (this.isScrollTop()) { - this.prependNote(note); - } else { - this.queue.unshift(note); + // サウンドを再生する + 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(); } + + // Prepend a note + (this.$refs.timeline as any).prepend(note); }, onChangeFollowing() { @@ -206,17 +177,6 @@ export default Vue.extend({ 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 @@ -231,7 +191,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-home-timeline +.mk-timeline-core > .newer-indicator position -webkit-sticky position sticky diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 61197d4017..93ee5aa195 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -45,6 +45,7 @@ <script lang="ts"> import Vue from 'vue'; +import MkListsWindow from './lists-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; @@ -83,6 +84,10 @@ export default Vue.extend({ this.close(); (this as any).os.new(MkDriveWindow); }, + list() { + this.close(); + (this as any).os.new(MkListsWindow); + }, settings() { this.close(); (this as any).os.new(MkSettingsWindow); diff --git a/src/client/app/desktop/views/pages/list.vue b/src/client/app/desktop/views/pages/list.vue new file mode 100644 index 0000000000..70130eae68 --- /dev/null +++ b/src/client/app/desktop/views/pages/list.vue @@ -0,0 +1,72 @@ +<template> +<mk-ui> + <header :class="$style.header"> + <h1>{{ list.title }}</h1> + </header> + <mk-list-timeline :list="list"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + fetching: true, + list: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + + (this as any).api('users/lists/show', { + id: this.$route.params.list + }).then(list => { + this.list = list; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + width 100% + max-width 600px + margin 0 auto + color #555 + +.notes + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +.loading + padding 64px 0 + +.empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style>