summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2018-04-25 19:53:16 +0900
committersyuilo <syuilotan@yahoo.co.jp>2018-04-25 19:53:16 +0900
commitc2e053a208609d59188dce9e328c1ab9706aa35c (patch)
tree88e6b66523d246b0b4ab11ed6961e09f6fe0d23b /src
parentwip (diff)
downloadmisskey-c2e053a208609d59188dce9e328c1ab9706aa35c.tar.gz
misskey-c2e053a208609d59188dce9e328c1ab9706aa35c.tar.bz2
misskey-c2e053a208609d59188dce9e328c1ab9706aa35c.zip
wip
Diffstat (limited to 'src')
-rw-r--r--src/client/app/desktop/script.ts2
-rw-r--r--src/client/app/desktop/views/components/index.ts2
-rw-r--r--src/client/app/desktop/views/components/lists-window.vue2
-rw-r--r--src/client/app/desktop/views/components/notes.vue53
-rw-r--r--src/client/app/desktop/views/components/timeline.core.vue81
-rw-r--r--src/client/app/desktop/views/components/user-list-timeline.vue (renamed from src/client/app/desktop/views/components/list-timeline.vue)53
-rw-r--r--src/client/app/desktop/views/pages/user-list.vue (renamed from src/client/app/desktop/views/pages/list.vue)12
-rw-r--r--src/server/api/endpoints.ts29
-rw-r--r--src/server/api/endpoints/notes/user-list-timeline.ts179
-rw-r--r--src/server/api/endpoints/users/lists/show.ts23
-rw-r--r--src/server/api/endpoints/users/search_by_username.ts6
11 files changed, 363 insertions, 79 deletions
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
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
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
@@ -415,6 +415,27 @@ const endpoints: Endpoint[] = [
},
{
+ 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,
limit: {
@@ -504,6 +525,14 @@ const endpoints: Endpoint[] = [
}
},
{
+ name: 'notes/user-list-timeline',
+ withCredential: true,
+ limit: {
+ duration: ms('10minutes'),
+ max: 100
+ }
+ },
+ {
name: 'notes/mentions',
withCredential: true,
limit: {
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