From c2e053a208609d59188dce9e328c1ab9706aa35c Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Wed, 25 Apr 2018 19:53:16 +0900 Subject: [PATCH] wip --- src/client/app/desktop/script.ts | 2 + .../app/desktop/views/components/index.ts | 2 + .../desktop/views/components/lists-window.vue | 2 +- .../app/desktop/views/components/notes.vue | 53 +++++- .../views/components/timeline.core.vue | 81 ++++---- ...st-timeline.vue => user-list-timeline.vue} | 53 ++++-- .../views/pages/{list.vue => user-list.vue} | 12 +- src/server/api/endpoints.ts | 29 +++ .../api/endpoints/notes/user-list-timeline.ts | 179 ++++++++++++++++++ src/server/api/endpoints/users/lists/show.ts | 23 +++ .../api/endpoints/users/search_by_username.ts | 6 +- 11 files changed, 363 insertions(+), 79 deletions(-) rename src/client/app/desktop/views/components/{list-timeline.vue => user-list-timeline.vue} (60%) rename src/client/app/desktop/views/pages/{list.vue => user-list.vue} (81%) create mode 100644 src/server/api/endpoints/notes/user-list-timeline.ts create mode 100644 src/server/api/endpoints/users/lists/show.ts diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 3b0ed48cd0..2658a86b95 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -28,6 +28,7 @@ import MkUser from './views/pages/user/user.vue'; import MkFavorites from './views/pages/favorites.vue'; import MkSelectDrive from './views/pages/selectdrive.vue'; import MkDrive from './views/pages/drive.vue'; +import MkUserList from './views/pages/user-list.vue'; import MkHomeCustomize from './views/pages/home-customize.vue'; import MkMessagingRoom from './views/pages/messaging-room.vue'; import MkNote from './views/pages/note.vue'; @@ -55,6 +56,7 @@ init(async (launch) => { { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/i/lists/:list', component: MkUserList }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/othello', component: MkOthello }, diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts index 4f61f43692..f58d0706df 100644 --- a/src/client/app/desktop/views/components/index.ts +++ b/src/client/app/desktop/views/components/index.ts @@ -28,6 +28,7 @@ import friendsMaker from './friends-maker.vue'; import followers from './followers.vue'; import following from './following.vue'; import usersList from './users-list.vue'; +import userListTimeline from './user-list-timeline.vue'; import widgetContainer from './widget-container.vue'; Vue.component('mk-ui', ui); @@ -58,4 +59,5 @@ Vue.component('mk-friends-maker', friendsMaker); Vue.component('mk-followers', followers); Vue.component('mk-following', following); Vue.component('mk-users-list', usersList); +Vue.component('mk-user-list-timeline', userListTimeline); Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/desktop/views/components/lists-window.vue b/src/client/app/desktop/views/components/lists-window.vue index 7097e5ed4b..30b1794a29 100644 --- a/src/client/app/desktop/views/components/lists-window.vue +++ b/src/client/app/desktop/views/components/lists-window.vue @@ -1,6 +1,6 @@ <template> <mk-window ref="window" is-modal width="500px" height="550px" @closed="$destroy"> - <span slot="header" :class="$style.header">%fa:list% リスト</span> + <span slot="header">%fa:list% リスト</span> <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> diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index 6965a18eda..ae36c899d5 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -1,5 +1,14 @@ <template> <div class="mk-notes"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> + + <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> + + <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"> <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> @@ -9,7 +18,8 @@ </p> </template> </transition-group> - <footer v-if="loadMore"> + + <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> @@ -40,9 +50,10 @@ export default Vue.extend({ data() { return { + requestInitPromise: null as () => Promise<any[]>, notes: [], queue: [], - fetching: false, + fetching: true, moreFetching: false }; }, @@ -80,9 +91,25 @@ export default Vue.extend({ Vue.set((this as any).notes, i, note); }, - init(notes) { + init(promiseGenerator: () => Promise<any[]>) { + this.requestInitPromise = promiseGenerator; + this.resolveInitPromise(); + }, + + resolveInitPromise() { this.queue = []; - this.notes = notes; + 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) { @@ -137,6 +164,9 @@ export default Vue.extend({ }, async loadMore() { + if (this.more == null) return; + if (this.moreFetching) return; + this.moreFetching = true; await this.more(); this.moreFetching = false; @@ -157,6 +187,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> +@import '~const.styl' + root(isDark) .transition .mk-notes-enter @@ -183,6 +215,13 @@ root(isDark) [data-fa] margin-right 8px + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color + > footer > * display block @@ -191,16 +230,16 @@ root(isDark) width 100% text-align center color #ccc - border-top solid 1px #eaeaea + border-top solid 1px isDark ? #1c2023 : #eaeaea border-bottom-left-radius 4px border-bottom-right-radius 4px > button &:hover - background #f5f5f5 + background isDark ? #2e3440 : #f5f5f5 &:active - background #eee + background isDark ? #21242b : #eee .mk-notes[data-darkmode] root(true) diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 93cc59b556..f5e0ee118e 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,14 +1,15 @@ <template> <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"> <mk-ellipsis-icon/> </div> - <p class="empty" v-if="notes.length == 0 && !fetching"> - %fa:R comments%%i18n:@empty% - </p> - <mk-notes :notes="notes" ref="timeline" :more="canFetchMore ? more : null"/> + + <mk-notes ref="timeline" :more="canFetchMore ? more : null"> + <p :class="$style.empty" slot="empty"> + %fa:R comments%%i18n:@empty% + </p> + </mk-notes> </div> </template> @@ -89,28 +90,26 @@ export default Vue.extend({ }, methods: { - isScrollTop() { - return window.scrollY <= 8; - }, - fetch(cb?) { this.fetching = true; - (this as any).api(this.endpoint, { - 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 == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } - (this.$refs.timeline as any).init(notes); - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api(this.endpoint, { + 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 == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }, rej); + })); }, more() { @@ -167,31 +166,27 @@ export default Vue.extend({ @import '~const.styl' .mk-timeline-core - > .newer-indicator - position -webkit-sticky - position sticky - z-index 100 - height 3px - background $theme-color - > .mk-friends-maker border-bottom solid 1px #eee > .fetching padding 64px 0 - > .empty - display block - margin 0 auto - padding 32px - max-width 400px - text-align center - color #999 +</style> - > [data-fa] - display block - margin-bottom 16px - font-size 3em - color #ccc +<style lang="stylus" module> +.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> diff --git a/src/client/app/desktop/views/components/list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue similarity index 60% rename from src/client/app/desktop/views/components/list-timeline.vue rename to src/client/app/desktop/views/components/user-list-timeline.vue index e946453f40..f71972ab78 100644 --- a/src/client/app/desktop/views/components/list-timeline.vue +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -1,5 +1,5 @@ <template> - <mk-notes ref="timeline" :more="more"/> + <mk-notes ref="timeline" :more="existMore ? more : null"/> </template> <script lang="ts"> @@ -19,42 +19,49 @@ export default Vue.extend({ }; }, watch: { - $route: 'fetch' + $route: 'init' }, mounted() { - this.fetch(); + this.init(); }, beforeDestroy() { this.connection.close(); }, methods: { - fetch() { + init() { if (this.connection) this.connection.close(); 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(); + }, + 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'); - }); + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + 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'); + }, rej); + })); }, more() { this.moreFetching = true; (this as any).api('notes/list-timeline', { + listId: this.list.id, limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, @@ -68,7 +75,17 @@ export default Vue.extend({ notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); - } + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + onUserAdded() { + this.fetch(); + }, + onUserRemoved() { + this.fetch(); + }, } }); </script> diff --git a/src/client/app/desktop/views/pages/list.vue b/src/client/app/desktop/views/pages/user-list.vue similarity index 81% rename from src/client/app/desktop/views/pages/list.vue rename to src/client/app/desktop/views/pages/user-list.vue index 70130eae68..1889f7dbe4 100644 --- a/src/client/app/desktop/views/pages/list.vue +++ b/src/client/app/desktop/views/pages/user-list.vue @@ -1,9 +1,11 @@ <template> <mk-ui> - <header :class="$style.header"> - <h1>{{ list.title }}</h1> - </header> - <mk-list-timeline :list="list"/> + <template v-if="!fetching"> + <header :class="$style.header"> + <h1>{{ list.title }}</h1> + </header> + <mk-user-list-timeline :list="list"/> + </template> </mk-ui> </template> @@ -28,7 +30,7 @@ export default Vue.extend({ this.fetching = true; (this as any).api('users/lists/show', { - id: this.$route.params.list + listId: this.$route.params.list }).then(list => { this.list = list; this.fetching = false; diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 3686918147..734b8273f1 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -414,6 +414,27 @@ const endpoints: Endpoint[] = [ name: 'users/get_frequently_replied_users' }, + { + name: 'users/lists/show', + withCredential: true, + kind: 'account-read' + }, + { + name: 'users/lists/create', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/push', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/list', + withCredential: true, + kind: 'account-read' + }, + { name: 'following/create', withCredential: true, @@ -503,6 +524,14 @@ const endpoints: Endpoint[] = [ max: 100 } }, + { + name: 'notes/user-list-timeline', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, { name: 'notes/mentions', withCredential: true, diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..bb94fa0ab9 --- /dev/null +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,179 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import UserList from '../../../../models/user-list'; + +/** + * Get timeline of a user list + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $(params.includeMyRenotes).optional.boolean().$; + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $(params.includeRenotedMyNotes).optional.boolean().$; + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + // Get 'listId' parameter + const [listId, listIdErr] = $(params.listId).type(ID).$; + if (listIdErr) throw 'invalid listId param'; + + const [list, mutedUserIds] = await Promise.all([ + // リストを取得 + // Fetch the list + UserList.findOne({ + _id: listId, + userId: user._id + }), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + if (list.userIds.length == 0) { + return []; + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const listQuery = list.userIds.map(u => ({ + userId: u, + + // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + })); + + const query = { + $and: [{ + // リストに入っている人のタイムラインへの投稿 + $or: listQuery, + + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..61e0f0463f --- /dev/null +++ b/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Show a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $(params.listId).type(ID).$; + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts index 41a12d5332..91d9ad1f3a 100644 --- a/src/server/api/endpoints/users/search_by_username.ts +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -1,15 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; import ID from '../../../../cafy-id'; +import $ from 'cafy'; import User, { pack } from '../../../../models/user'; /** * Search a user by username - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'query' parameter