From 03c824f89348f39447a2240980b80095eeb34d3a Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 10:08:15 +0900
Subject: [PATCH 01/25] wip

---
 src/models/user-list.ts | 40 ++++++++++++++++++++++++++++++++++++++++
 src/models/user.ts      | 15 +++++++++++++++
 2 files changed, 55 insertions(+)
 create mode 100644 src/models/user-list.ts

diff --git a/src/models/user-list.ts b/src/models/user-list.ts
new file mode 100644
index 0000000000..66e2afe213
--- /dev/null
+++ b/src/models/user-list.ts
@@ -0,0 +1,40 @@
+import * as mongo from 'mongodb';
+import db from '../db/mongodb';
+
+const UserList = db.get<IUserList>('userList');
+export default UserList;
+
+export interface IUserList {
+	_id: mongo.ObjectID;
+	createdAt: Date;
+	title: string;
+	userId: mongo.ObjectID;
+	userIds: mongo.ObjectID[];
+}
+
+/**
+ * UserListを物理削除します
+ */
+export async function deleteUserList(userList: string | mongo.ObjectID | IUserList) {
+	let u: IUserList;
+
+	// Populate
+	if (mongo.ObjectID.prototype.isPrototypeOf(userList)) {
+		u = await UserList.findOne({
+			_id: userList
+		});
+	} else if (typeof userList === 'string') {
+		u = await UserList.findOne({
+			_id: new mongo.ObjectID(userList)
+		});
+	} else {
+		u = userList as IUserList;
+	}
+
+	if (u == null) return;
+
+	// このUserListを削除
+	await UserList.remove({
+		_id: u._id
+	});
+}
diff --git a/src/models/user.ts b/src/models/user.ts
index ca1ca28937..44f41d22fa 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -20,6 +20,7 @@ import FollowingLog, { deleteFollowingLog } from './following-log';
 import FollowedLog, { deleteFollowedLog } from './followed-log';
 import SwSubscription, { deleteSwSubscription } from './sw-subscription';
 import Notification, { deleteNotification } from './notification';
+import UserList, { deleteUserList } from './user-list';
 
 const User = db.get<IUser>('users');
 
@@ -260,6 +261,20 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) {
 		await Notification.find({ notifierId: u._id })
 	).map(x => deleteNotification(x)));
 
+	// このユーザーのUserListをすべて削除
+	await Promise.all((
+		await UserList.find({ userId: u._id })
+	).map(x => deleteUserList(x)));
+
+	// このユーザーの入っているすべてのUserListからこのユーザーを削除
+	await Promise.all((
+		await UserList.find({ userIds: u._id })
+	).map(x =>
+		UserList.update({ _id: x._id }, {
+			$pull: { userIds: u._id }
+		})
+	));
+
 	// このユーザーを削除
 	await User.remove({
 		_id: u._id

From fd2c45cc021e3a3ab5c5ddc6ce332e0a56d5c604 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 14:12:19 +0900
Subject: [PATCH 02/25] Better japanese

---
 src/models/user.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/models/user.ts b/src/models/user.ts
index 44f41d22fa..82110a5475 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -266,7 +266,7 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) {
 		await UserList.find({ userId: u._id })
 	).map(x => deleteUserList(x)));
 
-	// このユーザーの入っているすべてのUserListからこのユーザーを削除
+	// このユーザーが入っているすべてのUserListからこのユーザーを削除
 	await Promise.all((
 		await UserList.find({ userIds: u._id })
 	).map(x =>

From 7602e8f9383be37c0f7d9359f862c366b7f2fa00 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 18:13:06 +0900
Subject: [PATCH 03/25] =?UTF-8?q?cafy=205.x=E3=81=AB=E7=A7=BB=E8=A1=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 package.json                                  |  2 +-
 src/cafy-id.ts                                | 29 +++++++++++++++++++
 src/models/note-reaction.ts                   |  2 +-
 src/models/user-list.ts                       | 27 +++++++++++++++++
 src/server/api/endpoints/aggregation/posts.ts |  3 --
 src/server/api/endpoints/aggregation/users.ts |  3 --
 .../endpoints/aggregation/users/activity.ts   |  7 ++---
 .../endpoints/aggregation/users/followers.ts  |  7 ++---
 .../endpoints/aggregation/users/following.ts  |  7 ++---
 .../api/endpoints/aggregation/users/post.ts   |  7 ++---
 .../endpoints/aggregation/users/reaction.ts   |  4 +--
 src/server/api/endpoints/app/create.ts        |  2 +-
 src/server/api/endpoints/app/show.ts          |  4 +--
 src/server/api/endpoints/auth/accept.ts       |  2 +-
 src/server/api/endpoints/channels.ts          |  6 ++--
 src/server/api/endpoints/channels/create.ts   |  4 ---
 src/server/api/endpoints/channels/notes.ts    | 12 +++-----
 src/server/api/endpoints/channels/show.ts     |  8 ++---
 src/server/api/endpoints/channels/unwatch.ts  |  8 ++---
 src/server/api/endpoints/channels/watch.ts    |  8 ++---
 src/server/api/endpoints/drive/files.ts       | 13 +++------
 .../api/endpoints/drive/files/create.ts       |  9 ++----
 src/server/api/endpoints/drive/files/find.ts  |  8 ++---
 src/server/api/endpoints/drive/files/show.ts  |  8 ++---
 .../api/endpoints/drive/files/update.ts       | 10 ++-----
 .../endpoints/drive/files/upload_from_url.ts  |  4 +--
 src/server/api/endpoints/drive/folders.ts     | 13 +++------
 .../api/endpoints/drive/folders/create.ts     |  8 ++---
 .../api/endpoints/drive/folders/find.ts       |  8 ++---
 .../api/endpoints/drive/folders/show.ts       |  8 ++---
 .../api/endpoints/drive/folders/update.ts     | 10 ++-----
 src/server/api/endpoints/drive/stream.ts      | 10 ++-----
 src/server/api/endpoints/following/create.ts  |  4 +--
 src/server/api/endpoints/following/delete.ts  |  4 +--
 src/server/api/endpoints/following/stalk.ts   |  5 ++--
 src/server/api/endpoints/following/unstalk.ts |  4 +--
 src/server/api/endpoints/i/authorized_apps.ts |  4 ---
 src/server/api/endpoints/i/change_password.ts |  4 ---
 src/server/api/endpoints/i/favorites.ts       |  6 ++--
 src/server/api/endpoints/i/notifications.ts   |  8 ++---
 src/server/api/endpoints/i/pin.ts             |  8 ++---
 .../api/endpoints/i/regenerate_token.ts       |  4 ---
 src/server/api/endpoints/i/signin_history.ts  | 10 ++-----
 src/server/api/endpoints/i/update.ts          |  6 ++--
 .../api/endpoints/i/update_client_setting.ts  |  4 ---
 src/server/api/endpoints/i/update_home.ts     |  2 +-
 .../api/endpoints/i/update_mobile_home.ts     |  2 +-
 src/server/api/endpoints/messaging/history.ts |  4 ---
 .../api/endpoints/messaging/messages.ts       |  8 ++---
 .../endpoints/messaging/messages/create.ts    | 10 ++-----
 src/server/api/endpoints/messaging/unread.ts  |  4 ---
 src/server/api/endpoints/mute/create.ts       |  8 ++---
 src/server/api/endpoints/mute/delete.ts       |  4 +--
 src/server/api/endpoints/mute/list.ts         |  8 ++---
 src/server/api/endpoints/my/apps.ts           |  4 ---
 src/server/api/endpoints/notes.ts             |  6 ++--
 src/server/api/endpoints/notes/context.ts     |  4 +--
 src/server/api/endpoints/notes/create.ts      | 23 ++++++---------
 .../api/endpoints/notes/favorites/create.ts   |  8 ++---
 .../api/endpoints/notes/favorites/delete.ts   |  8 ++---
 .../api/endpoints/notes/global-timeline.ts    |  6 ++--
 .../api/endpoints/notes/local-timeline.ts     |  6 ++--
 src/server/api/endpoints/notes/mentions.ts    |  6 ++--
 .../endpoints/notes/polls/recommendation.ts   |  4 ---
 src/server/api/endpoints/notes/polls/vote.ts  |  8 ++---
 src/server/api/endpoints/notes/reactions.ts   |  4 +--
 .../api/endpoints/notes/reactions/create.ts   |  4 +--
 .../api/endpoints/notes/reactions/delete.ts   |  9 ++----
 src/server/api/endpoints/notes/replies.ts     |  4 +--
 src/server/api/endpoints/notes/reposts.ts     |  8 ++---
 src/server/api/endpoints/notes/search.ts      | 10 +++----
 src/server/api/endpoints/notes/show.ts        |  4 +--
 src/server/api/endpoints/notes/timeline.ts    |  6 ++--
 src/server/api/endpoints/notes/trend.ts       |  2 +-
 .../notifications/get_unread_count.ts         |  4 ---
 .../notifications/mark_as_read_all.ts         |  4 ---
 src/server/api/endpoints/othello/games.ts     |  6 ++--
 .../api/endpoints/othello/games/show.ts       |  4 +--
 src/server/api/endpoints/othello/match.ts     |  4 +--
 src/server/api/endpoints/users.ts             |  4 ---
 src/server/api/endpoints/users/followers.ts   | 10 ++-----
 src/server/api/endpoints/users/following.ts   |  6 ++--
 .../users/get_frequently_replied_users.ts     |  4 +--
 src/server/api/endpoints/users/list/create.ts | 25 ++++++++++++++++
 src/server/api/endpoints/users/list/push.ts   |  0
 src/server/api/endpoints/users/notes.ts       |  8 ++---
 .../api/endpoints/users/recommendation.ts     |  2 +-
 src/server/api/endpoints/users/search.ts      |  2 +-
 .../api/endpoints/users/search_by_username.ts |  2 +-
 src/server/api/endpoints/users/show.ts        |  4 +--
 90 files changed, 255 insertions(+), 350 deletions(-)
 create mode 100644 src/cafy-id.ts
 create mode 100644 src/server/api/endpoints/users/list/create.ts
 create mode 100644 src/server/api/endpoints/users/list/push.ts

diff --git a/package.json b/package.json
index 0a3026e17e..ae90d21301 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
 		"autwh": "0.1.0",
 		"bcryptjs": "2.4.3",
 		"bootstrap-vue": "2.0.0-rc.6",
-		"cafy": "3.2.1",
+		"cafy": "5.1.0",
 		"chai": "4.1.2",
 		"chai-http": "4.0.0",
 		"chalk": "2.4.0",
diff --git a/src/cafy-id.ts b/src/cafy-id.ts
new file mode 100644
index 0000000000..1109d42d8f
--- /dev/null
+++ b/src/cafy-id.ts
@@ -0,0 +1,29 @@
+import * as mongo from 'mongodb';
+import { Query } from 'cafy';
+
+export const isAnId = x => mongo.ObjectID.isValid(x);
+export const isNotAnId = x => !isAnId(x);
+
+/**
+ * ID
+ */
+export default class ID extends Query<mongo.ObjectID> {
+	constructor(...args) {
+		super(...args);
+
+		this.transform = v => {
+			if (isAnId(v) && !mongo.ObjectID.prototype.isPrototypeOf(v)) {
+				return new mongo.ObjectID(v);
+			} else {
+				return v;
+			}
+		};
+
+		this.pushFirstTimeValidator(v => {
+			if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) {
+				return new Error('must-be-an-id');
+			}
+			return true;
+		});
+	}
+}
diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts
index 7891ebdf17..f78b0d9d01 100644
--- a/src/models/note-reaction.ts
+++ b/src/models/note-reaction.ts
@@ -1,5 +1,5 @@
 import * as mongo from 'mongodb';
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import deepcopy = require('deepcopy');
 import db from '../db/mongodb';
 import Reaction from './note-reaction';
diff --git a/src/models/user-list.ts b/src/models/user-list.ts
index 66e2afe213..7100fced7e 100644
--- a/src/models/user-list.ts
+++ b/src/models/user-list.ts
@@ -1,4 +1,5 @@
 import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
 import db from '../db/mongodb';
 
 const UserList = db.get<IUserList>('userList');
@@ -38,3 +39,29 @@ export async function deleteUserList(userList: string | mongo.ObjectID | IUserLi
 		_id: u._id
 	});
 }
+
+export const pack = (
+	userList: string | mongo.ObjectID | IUserList
+) => new Promise<any>(async (resolve, reject) => {
+	let _userList: any;
+
+	if (mongo.ObjectID.prototype.isPrototypeOf(userList)) {
+		_userList = await UserList.findOne({
+			_id: userList
+		});
+	} else if (typeof userList === 'string') {
+		_userList = await UserList.findOne({
+			_id: new mongo.ObjectID(userList)
+		});
+	} else {
+		_userList = deepcopy(userList);
+	}
+
+	if (!_userList) throw `invalid userList arg ${userList}`;
+
+	// Rename _id to id
+	_userList.id = _userList._id;
+	delete _userList._id;
+
+	resolve(_userList);
+});
diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts
index cc2a48b53d..17bead2808 100644
--- a/src/server/api/endpoints/aggregation/posts.ts
+++ b/src/server/api/endpoints/aggregation/posts.ts
@@ -6,9 +6,6 @@ import Note from '../../../../models/note';
 
 /**
  * Aggregate notes
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = params => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts
index 19776ed297..b0a7632f24 100644
--- a/src/server/api/endpoints/aggregation/users.ts
+++ b/src/server/api/endpoints/aggregation/users.ts
@@ -6,9 +6,6 @@ import User from '../../../../models/user';
 
 /**
  * Aggregate users
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = params => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts
index 318cce77a5..d36e07a441 100644
--- a/src/server/api/endpoints/aggregation/users/activity.ts
+++ b/src/server/api/endpoints/aggregation/users/activity.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import User from '../../../../../models/user';
 import Note from '../../../../../models/note';
 
@@ -9,9 +9,6 @@ import Note from '../../../../../models/note';
 
 /**
  * Aggregate activity of a user
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = (params) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
@@ -19,7 +16,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts
index 7ccb2a3066..a6dd29e735 100644
--- a/src/server/api/endpoints/aggregation/users/followers.ts
+++ b/src/server/api/endpoints/aggregation/users/followers.ts
@@ -1,19 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import User from '../../../../../models/user';
 import FollowedLog from '../../../../../models/followed-log';
 
 /**
  * Aggregate followers of a user
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = (params) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts
index 45e246495b..7336f392fe 100644
--- a/src/server/api/endpoints/aggregation/users/following.ts
+++ b/src/server/api/endpoints/aggregation/users/following.ts
@@ -1,19 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import User from '../../../../../models/user';
 import FollowingLog from '../../../../../models/following-log';
 
 /**
  * Aggregate following of a user
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = (params) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts
index e6170d83e2..c5a5e5ffca 100644
--- a/src/server/api/endpoints/aggregation/users/post.ts
+++ b/src/server/api/endpoints/aggregation/users/post.ts
@@ -1,19 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import User from '../../../../../models/user';
 import Note from '../../../../../models/note';
 
 /**
  * Aggregate note of a user
- *
- * @param {any} params
- * @return {Promise<any>}
  */
 module.exports = (params) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts
index 881c7ea693..f1664823cd 100644
--- a/src/server/api/endpoints/aggregation/users/reaction.ts
+++ b/src/server/api/endpoints/aggregation/users/reaction.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import User from '../../../../../models/user';
 import Reaction from '../../../../../models/note-reaction';
 
@@ -13,7 +13,7 @@ import Reaction from '../../../../../models/note-reaction';
  */
 module.exports = (params) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts
index 4a55d33f2d..f403429261 100644
--- a/src/server/api/endpoints/app/create.ts
+++ b/src/server/api/endpoints/app/create.ts
@@ -79,7 +79,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => {
 	if (descriptionErr) return rej('invalid description param');
 
 	// Get 'permission' parameter
-	const [permission, permissionErr] = $(params.permission).array('string').unique().$;
+	const [permission, permissionErr] = $(params.permission).array($().string()).unique().$;
 	if (permissionErr) return rej('invalid permission param');
 
 	// Get 'callbackUrl' parameter
diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts
index 99a2093b68..92a03b9838 100644
--- a/src/server/api/endpoints/app/show.ts
+++ b/src/server/api/endpoints/app/show.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import App, { pack } from '../../../../models/app';
 
 /**
@@ -41,7 +41,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
 	const isSecure = user != null && app == null;
 
 	// Get 'appId' parameter
-	const [appId, appIdErr] = $(params.appId).optional.id().$;
+	const [appId, appIdErr] = $(params.appId).optional.type(ID).$;
 	if (appIdErr) return rej('invalid appId param');
 
 	// Get 'nameId' parameter
diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts
index b6297d663d..e0073b31e6 100644
--- a/src/server/api/endpoints/auth/accept.ts
+++ b/src/server/api/endpoints/auth/accept.ts
@@ -3,7 +3,7 @@
  */
 import rndstr from 'rndstr';
 const crypto = require('crypto');
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import App from '../../../../models/app';
 import AuthSess from '../../../../models/auth-session';
 import AccessToken from '../../../../models/access-token';
diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts
index 582e6ba43b..b68107ed7d 100644
--- a/src/server/api/endpoints/channels.ts
+++ b/src/server/api/endpoints/channels.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../cafy-id';
 import Channel, { pack } from '../../../models/channel';
 
 /**
@@ -17,11 +17,11 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts
index 0f0f558c8a..a737fcb152 100644
--- a/src/server/api/endpoints/channels/create.ts
+++ b/src/server/api/endpoints/channels/create.ts
@@ -8,10 +8,6 @@ import { pack } from '../../../../models/channel';
 
 /**
  * Create a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'title' parameter
diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts
index d636aa0d10..73a69c6d2a 100644
--- a/src/server/api/endpoints/channels/notes.ts
+++ b/src/server/api/endpoints/channels/notes.ts
@@ -1,16 +1,12 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import { default as Channel, IChannel } from '../../../../models/channel';
 import Note, { pack } from '../../../../models/note';
 
 /**
  * Show a notes of a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
@@ -18,11 +14,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
@@ -31,7 +27,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	}
 
 	// Get 'channelId' parameter
-	const [channelId, channelIdErr] = $(params.channelId).id().$;
+	const [channelId, channelIdErr] = $(params.channelId).type(ID).$;
 	if (channelIdErr) return rej('invalid channelId param');
 
 	// Fetch channel
diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts
index 3ce9ce4745..3f468937ed 100644
--- a/src/server/api/endpoints/channels/show.ts
+++ b/src/server/api/endpoints/channels/show.ts
@@ -1,19 +1,15 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Channel, { IChannel, pack } from '../../../../models/channel';
 
 /**
  * Show a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'channelId' parameter
-	const [channelId, channelIdErr] = $(params.channelId).id().$;
+	const [channelId, channelIdErr] = $(params.channelId).type(ID).$;
 	if (channelIdErr) return rej('invalid channelId param');
 
 	// Fetch channel
diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts
index 8220b90b68..6ada3c9e1b 100644
--- a/src/server/api/endpoints/channels/unwatch.ts
+++ b/src/server/api/endpoints/channels/unwatch.ts
@@ -1,20 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Channel from '../../../../models/channel';
 import Watching from '../../../../models/channel-watching';
 
 /**
  * Unwatch a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'channelId' parameter
-	const [channelId, channelIdErr] = $(params.channelId).id().$;
+	const [channelId, channelIdErr] = $(params.channelId).type(ID).$;
 	if (channelIdErr) return rej('invalid channelId param');
 
 	//#region Fetch channel
diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts
index 6906282a54..7880c34652 100644
--- a/src/server/api/endpoints/channels/watch.ts
+++ b/src/server/api/endpoints/channels/watch.ts
@@ -1,20 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Channel from '../../../../models/channel';
 import Watching from '../../../../models/channel-watching';
 
 /**
  * Watch a channel
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'channelId' parameter
-	const [channelId, channelIdErr] = $(params.channelId).id().$;
+	const [channelId, channelIdErr] = $(params.channelId).type(ID).$;
 	if (channelIdErr) return rej('invalid channelId param');
 
 	//#region Fetch channel
diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts
index 63d69d145a..7f78ef9daa 100644
--- a/src/server/api/endpoints/drive/files.ts
+++ b/src/server/api/endpoints/drive/files.ts
@@ -1,16 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import DriveFile, { pack } from '../../../../models/drive-file';
 
 /**
  * Get drive files
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
  */
 module.exports = async (params, user, app) => {
 	// Get 'limit' parameter
@@ -18,11 +13,11 @@ module.exports = async (params, user, app) => {
 	if (limitErr) throw 'invalid limit param';
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) throw 'invalid sinceId param';
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) throw 'invalid untilId param';
 
 	// Check if both of sinceId and untilId is specified
@@ -31,7 +26,7 @@ module.exports = async (params, user, app) => {
 	}
 
 	// Get 'folderId' parameter
-	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) throw 'invalid folderId param';
 
 	// Get 'type' parameter
diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts
index df0bd0a0d3..3d5048732d 100644
--- a/src/server/api/endpoints/drive/files/create.ts
+++ b/src/server/api/endpoints/drive/files/create.ts
@@ -1,17 +1,12 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import { validateFileName, pack } from '../../../../../models/drive-file';
 import create from '../../../../../services/drive/add-file';
 
 /**
  * Create a file
- *
- * @param {any} file
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (file, params, user): Promise<any> => {
 	if (file == null) {
@@ -34,7 +29,7 @@ module.exports = async (file, params, user): Promise<any> => {
 	}
 
 	// Get 'folderId' parameter
-	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) throw 'invalid folderId param';
 
 	try {
diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts
index 0ab6e5d3e3..5d49577983 100644
--- a/src/server/api/endpoints/drive/files/find.ts
+++ b/src/server/api/endpoints/drive/files/find.ts
@@ -1,15 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFile, { pack } from '../../../../../models/drive-file';
 
 /**
  * Find a file(s)
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'name' parameter
@@ -17,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (nameErr) return rej('invalid name param');
 
 	// Get 'folderId' parameter
-	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) return rej('invalid folderId param');
 
 	// Issue query
diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts
index 3398f24541..93c3a63031 100644
--- a/src/server/api/endpoints/drive/files/show.ts
+++ b/src/server/api/endpoints/drive/files/show.ts
@@ -1,19 +1,15 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFile, { pack } from '../../../../../models/drive-file';
 
 /**
  * Show a file
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => {
 	// Get 'fileId' parameter
-	const [fileId, fileIdErr] = $(params.fileId).id().$;
+	const [fileId, fileIdErr] = $(params.fileId).type(ID).$;
 	if (fileIdErr) throw 'invalid fileId param';
 
 	// Fetch file
diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts
index c783ad8b3b..3ac157b530 100644
--- a/src/server/api/endpoints/drive/files/update.ts
+++ b/src/server/api/endpoints/drive/files/update.ts
@@ -1,21 +1,17 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFolder from '../../../../../models/drive-folder';
 import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file';
 import { publishDriveStream } from '../../../../../publishers/stream';
 
 /**
  * Update a file
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'fileId' parameter
-	const [fileId, fileIdErr] = $(params.fileId).id().$;
+	const [fileId, fileIdErr] = $(params.fileId).type(ID).$;
 	if (fileIdErr) return rej('invalid fileId param');
 
 	// Fetch file
@@ -35,7 +31,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (name) file.filename = name;
 
 	// Get 'folderId' parameter
-	const [folderId, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) return rej('invalid folderId param');
 
 	if (folderId !== undefined) {
diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts
index 8a426c0efc..cfae1ae192 100644
--- a/src/server/api/endpoints/drive/files/upload_from_url.ts
+++ b/src/server/api/endpoints/drive/files/upload_from_url.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import { pack } from '../../../../../models/drive-file';
 import uploadFromUrl from '../../../../../services/drive/upload-from-url';
 
@@ -15,7 +15,7 @@ module.exports = async (params, user): Promise<any> => {
 	if (urlErr) throw 'invalid url param';
 
 	// Get 'folderId' parameter
-	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) throw 'invalid folderId param';
 
 	return pack(await uploadFromUrl(url, user, folderId));
diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts
index 489e47912e..cba33c4286 100644
--- a/src/server/api/endpoints/drive/folders.ts
+++ b/src/server/api/endpoints/drive/folders.ts
@@ -1,16 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import DriveFolder, { pack } from '../../../../models/drive-folder';
 
 /**
  * Get drive folders
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
  */
 module.exports = (params, user, app) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
@@ -18,11 +13,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
@@ -31,7 +26,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
 	}
 
 	// Get 'folderId' parameter
-	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$;
+	const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$;
 	if (folderIdErr) return rej('invalid folderId param');
 
 	// Construct query
diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts
index f34d0019d7..65425537a2 100644
--- a/src/server/api/endpoints/drive/folders/create.ts
+++ b/src/server/api/endpoints/drive/folders/create.ts
@@ -1,16 +1,12 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder';
 import { publishDriveStream } from '../../../../../publishers/stream';
 
 /**
  * Create drive folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'name' parameter
@@ -18,7 +14,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (nameErr) return rej('invalid name param');
 
 	// Get 'parentId' parameter
-	const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+	const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$;
 	if (parentIdErr) return rej('invalid parentId param');
 
 	// If the parent folder is specified
diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts
index 04dc38f87f..d6277f1978 100644
--- a/src/server/api/endpoints/drive/folders/find.ts
+++ b/src/server/api/endpoints/drive/folders/find.ts
@@ -1,15 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFolder, { pack } from '../../../../../models/drive-folder';
 
 /**
  * Find a folder(s)
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'name' parameter
@@ -17,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (nameErr) return rej('invalid name param');
 
 	// Get 'parentId' parameter
-	const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+	const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$;
 	if (parentIdErr) return rej('invalid parentId param');
 
 	// Issue query
diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts
index b432f5a50a..c703209fef 100644
--- a/src/server/api/endpoints/drive/folders/show.ts
+++ b/src/server/api/endpoints/drive/folders/show.ts
@@ -1,19 +1,15 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFolder, { pack } from '../../../../../models/drive-folder';
 
 /**
  * Show a folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'folderId' parameter
-	const [folderId, folderIdErr] = $(params.folderId).id().$;
+	const [folderId, folderIdErr] = $(params.folderId).type(ID).$;
 	if (folderIdErr) return rej('invalid folderId param');
 
 	// Get folder
diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts
index dd7e8f5c86..d8da67fac8 100644
--- a/src/server/api/endpoints/drive/folders/update.ts
+++ b/src/server/api/endpoints/drive/folders/update.ts
@@ -1,20 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder';
 import { publishDriveStream } from '../../../../../publishers/stream';
 
 /**
  * Update a folder
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'folderId' parameter
-	const [folderId, folderIdErr] = $(params.folderId).id().$;
+	const [folderId, folderIdErr] = $(params.folderId).type(ID).$;
 	if (folderIdErr) return rej('invalid folderId param');
 
 	// Fetch folder
@@ -34,7 +30,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (name) folder.name = name;
 
 	// Get 'parentId' parameter
-	const [parentId, parentIdErr] = $(params.parentId).optional.nullable.id().$;
+	const [parentId, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$;
 	if (parentIdErr) return rej('invalid parentId param');
 	if (parentId !== undefined) {
 		if (parentId === null) {
diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts
index 02313aa37b..00d89582b6 100644
--- a/src/server/api/endpoints/drive/stream.ts
+++ b/src/server/api/endpoints/drive/stream.ts
@@ -1,15 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import DriveFile, { pack } from '../../../../models/drive-file';
 
 /**
  * Get drive stream
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
@@ -17,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts
index 27e5eb31db..43f902852e 100644
--- a/src/server/api/endpoints/following/create.ts
+++ b/src/server/api/endpoints/following/create.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Following from '../../../../models/following';
 import create from '../../../../services/following/create';
@@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	const follower = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// 自分自身
diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts
index ca0703ca22..99722ccf91 100644
--- a/src/server/api/endpoints/following/delete.ts
+++ b/src/server/api/endpoints/following/delete.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Following from '../../../../models/following';
 import deleteFollowing from '../../../../services/following/delete';
@@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	const follower = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Check if the followee is yourself
diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts
index fc8be4924d..1dfbc4df98 100644
--- a/src/server/api/endpoints/following/stalk.ts
+++ b/src/server/api/endpoints/following/stalk.ts
@@ -1,6 +1,5 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Following from '../../../../models/following';
-import { isLocalUser } from '../../../../models/user';
 
 /**
  * Stalk a user
@@ -9,7 +8,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	const follower = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Fetch following
diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts
index d7593bcd00..0d91ffeac8 100644
--- a/src/server/api/endpoints/following/unstalk.ts
+++ b/src/server/api/endpoints/following/unstalk.ts
@@ -1,4 +1,4 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Following from '../../../../models/following';
 
 /**
@@ -8,7 +8,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	const follower = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Fetch following
diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts
index 82fd2d2516..fd12b3dec0 100644
--- a/src/server/api/endpoints/i/authorized_apps.ts
+++ b/src/server/api/endpoints/i/authorized_apps.ts
@@ -7,10 +7,6 @@ import { pack } from '../../../../models/app';
 
 /**
  * Get authorized apps of my account
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts
index 57415083f1..a24e9f0be1 100644
--- a/src/server/api/endpoints/i/change_password.ts
+++ b/src/server/api/endpoints/i/change_password.ts
@@ -7,10 +7,6 @@ import User from '../../../../models/user';
 
 /**
  * Change password
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'currentPasword' parameter
diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts
index f390ef9ec7..a2c472ad17 100644
--- a/src/server/api/endpoints/i/favorites.ts
+++ b/src/server/api/endpoints/i/favorites.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Favorite, { pack } from '../../../../models/favorite';
 
 /**
@@ -13,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index 69a8910898..14ade7b023 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Notification from '../../../../models/notification';
 import Mute from '../../../../models/mute';
 import { pack } from '../../../../models/notification';
@@ -22,7 +22,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (markAsReadErr) return rej('invalid markAsRead param');
 
 	// Get 'type' parameter
-	const [type, typeErr] = $(params.type).optional.array('string').unique().$;
+	const [type, typeErr] = $(params.type).optional.array($().string()).unique().$;
 	if (typeErr) return rej('invalid type param');
 
 	// Get 'limit' parameter
@@ -30,11 +30,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts
index 909a6fdbde..761e41bbea 100644
--- a/src/server/api/endpoints/i/pin.ts
+++ b/src/server/api/endpoints/i/pin.ts
@@ -1,21 +1,17 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Note from '../../../../models/note';
 import { pack } from '../../../../models/user';
 
 /**
  * Pin note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Fetch pinee
diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts
index f9e92c1797..945ddbdee4 100644
--- a/src/server/api/endpoints/i/regenerate_token.ts
+++ b/src/server/api/endpoints/i/regenerate_token.ts
@@ -9,10 +9,6 @@ import generateUserToken from '../../common/generate-native-user-token';
 
 /**
  * Regenerate native token
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'password' parameter
diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts
index 931b9e2252..77beca9fd6 100644
--- a/src/server/api/endpoints/i/signin_history.ts
+++ b/src/server/api/endpoints/i/signin_history.ts
@@ -1,15 +1,11 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Signin, { pack } from '../../../../models/signin';
 
 /**
  * Get signin history of my account
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
@@ -17,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index f3c9d777b5..7505e73387 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user';
 import event from '../../../../publishers/stream';
 
@@ -32,12 +32,12 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => {
 	if (birthday !== undefined) user.profile.birthday = birthday;
 
 	// Get 'avatarId' parameter
-	const [avatarId, avatarIdErr] = $(params.avatarId).optional.id().$;
+	const [avatarId, avatarIdErr] = $(params.avatarId).optional.type(ID).$;
 	if (avatarIdErr) return rej('invalid avatarId param');
 	if (avatarId) user.avatarId = avatarId;
 
 	// Get 'bannerId' parameter
-	const [bannerId, bannerIdErr] = $(params.bannerId).optional.id().$;
+	const [bannerId, bannerIdErr] = $(params.bannerId).optional.type(ID).$;
 	if (bannerIdErr) return rej('invalid bannerId param');
 	if (bannerId) user.bannerId = bannerId;
 
diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts
index b0d5db5ec2..f753c8bcc4 100644
--- a/src/server/api/endpoints/i/update_client_setting.ts
+++ b/src/server/api/endpoints/i/update_client_setting.ts
@@ -7,10 +7,6 @@ import event from '../../../../publishers/stream';
 
 /**
  * Update myself
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'name' parameter
diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts
index ce7661ede0..4b8ba25069 100644
--- a/src/server/api/endpoints/i/update_home.ts
+++ b/src/server/api/endpoints/i/update_home.ts
@@ -8,7 +8,7 @@ import event from '../../../../publishers/stream';
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'home' parameter
 	const [home, homeErr] = $(params.home).optional.array().each(
-		$().strict.object()
+		$().object(true)
 			.have('name', $().string())
 			.have('id', $().string())
 			.have('place', $().string())
diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts
index b710e2f330..c3ecea7178 100644
--- a/src/server/api/endpoints/i/update_mobile_home.ts
+++ b/src/server/api/endpoints/i/update_mobile_home.ts
@@ -8,7 +8,7 @@ import event from '../../../../publishers/stream';
 module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'home' parameter
 	const [home, homeErr] = $(params.home).optional.array().each(
-		$().strict.object()
+		$().object(true)
 			.have('name', $().string())
 			.have('id', $().string())
 			.have('data', $().object())).$;
diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts
index e42d34f21a..654bf5c198 100644
--- a/src/server/api/endpoints/messaging/history.ts
+++ b/src/server/api/endpoints/messaging/history.ts
@@ -8,10 +8,6 @@ import { pack } from '../../../../models/messaging-message';
 
 /**
  * Show messaging history
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts
index 092eab0562..f28699cb88 100644
--- a/src/server/api/endpoints/messaging/messages.ts
+++ b/src/server/api/endpoints/messaging/messages.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Message from '../../../../models/messaging-message';
 import User from '../../../../models/user';
 import { pack } from '../../../../models/messaging-message';
@@ -16,7 +16,7 @@ import read from '../../common/read-messaging-message';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [recipientId, recipientIdErr] = $(params.userId).id().$;
+	const [recipientId, recipientIdErr] = $(params.userId).type(ID).$;
 	if (recipientIdErr) return rej('invalid userId param');
 
 	// Fetch recipient
@@ -41,11 +41,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts
index 0483b602b2..cce326be6e 100644
--- a/src/server/api/endpoints/messaging/messages/create.ts
+++ b/src/server/api/endpoints/messaging/messages/create.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Message from '../../../../../models/messaging-message';
 import { isValidText } from '../../../../../models/messaging-message';
 import History from '../../../../../models/messaging-history';
@@ -16,14 +16,10 @@ import config from '../../../../../config';
 
 /**
  * Create a message
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [recipientId, recipientIdErr] = $(params.userId).id().$;
+	const [recipientId, recipientIdErr] = $(params.userId).type(ID).$;
 	if (recipientIdErr) return rej('invalid userId param');
 
 	// Myself
@@ -49,7 +45,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (textErr) return rej('invalid text');
 
 	// Get 'fileId' parameter
-	const [fileId, fileIdErr] = $(params.fileId).optional.id().$;
+	const [fileId, fileIdErr] = $(params.fileId).optional.type(ID).$;
 	if (fileIdErr) return rej('invalid fileId param');
 
 	let file = null;
diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts
index 30d59dd8bd..1d83af501d 100644
--- a/src/server/api/endpoints/messaging/unread.ts
+++ b/src/server/api/endpoints/messaging/unread.ts
@@ -6,10 +6,6 @@ import Mute from '../../../../models/mute';
 
 /**
  * Get count of unread messages
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	const mute = await Mute.find({
diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts
index 26ae612cab..0d59ecc118 100644
--- a/src/server/api/endpoints/mute/create.ts
+++ b/src/server/api/endpoints/mute/create.ts
@@ -1,22 +1,18 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Mute from '../../../../models/mute';
 
 /**
  * Mute a user
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	const muter = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// 自分自身
diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts
index 6f617416c8..3a37de9a21 100644
--- a/src/server/api/endpoints/mute/delete.ts
+++ b/src/server/api/endpoints/mute/delete.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Mute from '../../../../models/mute';
 
@@ -12,7 +12,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	const muter = user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Check if the mutee is yourself
diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts
index 0b8262d6c5..f35bf7d168 100644
--- a/src/server/api/endpoints/mute/list.ts
+++ b/src/server/api/endpoints/mute/list.ts
@@ -1,17 +1,13 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Mute from '../../../../models/mute';
 import { pack } from '../../../../models/user';
 import { getFriendIds } from '../../common/get-friends';
 
 /**
  * Get muted users of a user
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'iknow' parameter
@@ -23,7 +19,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'cursor' parameter
-	const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+	const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$;
 	if (cursorErr) return rej('invalid cursor param');
 
 	// Construct query
diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts
index 2a3f8bcd7a..eb7ece70e9 100644
--- a/src/server/api/endpoints/my/apps.ts
+++ b/src/server/api/endpoints/my/apps.ts
@@ -6,10 +6,6 @@ import App, { pack } from '../../../../models/app';
 
 /**
  * Get my apps
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts
index a70ac0588f..bf4d5bc66f 100644
--- a/src/server/api/endpoints/notes.ts
+++ b/src/server/api/endpoints/notes.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../cafy-id';
 import Note, { pack } from '../../../models/note';
 
 /**
@@ -33,11 +33,11 @@ module.exports = (params) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts
index 2caf742d26..309fc26447 100644
--- a/src/server/api/endpoints/notes/context.ts
+++ b/src/server/api/endpoints/notes/context.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { pack } from '../../../../models/note';
 
 /**
@@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index ea1f41aae2..1824a16c24 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 import Channel, { IChannel } from '../../../../models/channel';
@@ -11,11 +11,6 @@ import { IApp } from '../../../../models/app';
 
 /**
  * Create a note
- *
- * @param {any} params
- * @param {any} user
- * @param {any} app
- * @return {Promise<any>}
  */
 module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => {
 	// Get 'visibility' parameter
@@ -35,11 +30,11 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	if (viaMobileErr) return rej('invalid viaMobile');
 
 	// Get 'tags' parameter
-	const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
+	const [tags = [], tagsErr] = $(params.tags).optional.array($().string().range(1, 32)).unique().$;
 	if (tagsErr) return rej('invalid tags');
 
 	// Get 'geo' parameter
-	const [geo, geoErr] = $(params.geo).optional.nullable.strict.object()
+	const [geo, geoErr] = $(params.geo).optional.nullable.object(true)
 		.have('coordinates', $().array().length(2)
 			.item(0, $().number().range(-180, 180))
 			.item(1, $().number().range(-90, 90)))
@@ -52,7 +47,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	if (geoErr) return rej('invalid geo');
 
 	// Get 'mediaIds' parameter
-	const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$;
+	const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array($().type(ID)).unique().range(1, 4).$;
 	if (mediaIdsErr) return rej('invalid mediaIds');
 
 	let files = [];
@@ -79,7 +74,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	}
 
 	// Get 'renoteId' parameter
-	const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$;
+	const [renoteId, renoteIdErr] = $(params.renoteId).optional.type(ID).$;
 	if (renoteIdErr) return rej('invalid renoteId');
 
 	let renote: INote = null;
@@ -100,7 +95,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	}
 
 	// Get 'replyId' parameter
-	const [replyId, replyIdErr] = $(params.replyId).optional.id().$;
+	const [replyId, replyIdErr] = $(params.replyId).optional.type(ID).$;
 	if (replyIdErr) return rej('invalid replyId');
 
 	let reply: INote = null;
@@ -121,7 +116,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	}
 
 	// Get 'channelId' parameter
-	const [channelId, channelIdErr] = $(params.channelId).optional.id().$;
+	const [channelId, channelIdErr] = $(params.channelId).optional.type(ID).$;
 	if (channelIdErr) return rej('invalid channelId');
 
 	let channel: IChannel = null;
@@ -162,8 +157,8 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res
 	}
 
 	// Get 'poll' parameter
-	const [poll, pollErr] = $(params.poll).optional.strict.object()
-		.have('choices', $().array('string')
+	const [poll, pollErr] = $(params.poll).optional.object(true)
+		.have('choices', $().array($().string())
 			.unique()
 			.range(2, 10)
 			.each(c => c.length > 0 && c.length < 50))
diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts
index c8e7f52426..e4c4adb9bb 100644
--- a/src/server/api/endpoints/notes/favorites/create.ts
+++ b/src/server/api/endpoints/notes/favorites/create.ts
@@ -1,20 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Favorite from '../../../../../models/favorite';
 import Note from '../../../../../models/note';
 
 /**
  * Favorite a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get favoritee
diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts
index 92aceb343b..3c4d9a1111 100644
--- a/src/server/api/endpoints/notes/favorites/delete.ts
+++ b/src/server/api/endpoints/notes/favorites/delete.ts
@@ -1,20 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Favorite from '../../../../../models/favorite';
 import Note from '../../../../../models/note';
 
 /**
  * Unfavorite a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get favoritee
diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts
index 07e138ec54..e2a94d8a3e 100644
--- a/src/server/api/endpoints/notes/global-timeline.ts
+++ b/src/server/api/endpoints/notes/global-timeline.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
 import { pack } from '../../../../models/note';
@@ -15,11 +15,11 @@ module.exports = async (params, user, app) => {
 	if (limitErr) throw 'invalid limit param';
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) throw 'invalid sinceId param';
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) throw 'invalid untilId param';
 
 	// Get 'sinceDate' parameter
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index d63528c3cd..dda83311ac 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
 import { pack } from '../../../../models/note';
@@ -15,11 +15,11 @@ module.exports = async (params, user, app) => {
 	if (limitErr) throw 'invalid limit param';
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) throw 'invalid sinceId param';
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) throw 'invalid untilId param';
 
 	// Get 'sinceDate' parameter
diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts
index 2d95606b3f..815cf271a2 100644
--- a/src/server/api/endpoints/notes/mentions.ts
+++ b/src/server/api/endpoints/notes/mentions.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import { getFriendIds } from '../../common/get-friends';
 import { pack } from '../../../../models/note';
@@ -24,11 +24,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts
index cb530ea2cf..24b0a4c803 100644
--- a/src/server/api/endpoints/notes/polls/recommendation.ts
+++ b/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -7,10 +7,6 @@ import Note, { pack } from '../../../../../models/note';
 
 /**
  * Get recommended polls
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts
index 03d94da60d..2669c39085 100644
--- a/src/server/api/endpoints/notes/polls/vote.ts
+++ b/src/server/api/endpoints/notes/polls/vote.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Vote from '../../../../../models/poll-vote';
 import Note from '../../../../../models/note';
 import Watching from '../../../../../models/note-watching';
@@ -11,14 +11,10 @@ import notify from '../../../../../publishers/notify';
 
 /**
  * Vote poll of a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get votee
diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts
index bbff97bb0a..68ffbacd46 100644
--- a/src/server/api/endpoints/notes/reactions.ts
+++ b/src/server/api/endpoints/notes/reactions.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import Reaction, { pack } from '../../../../models/note-reaction';
 
@@ -14,7 +14,7 @@ import Reaction, { pack } from '../../../../models/note-reaction';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts
index 9e217cc3e0..1c21252604 100644
--- a/src/server/api/endpoints/notes/reactions/create.ts
+++ b/src/server/api/endpoints/notes/reactions/create.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Note from '../../../../../models/note';
 import create from '../../../../../services/note/reaction/create';
 import { validateReaction } from '../../../../../models/note-reaction';
@@ -11,7 +11,7 @@ import { validateReaction } from '../../../../../models/note-reaction';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get 'reaction' parameter
diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts
index b5d738b8ff..be3c1b214d 100644
--- a/src/server/api/endpoints/notes/reactions/delete.ts
+++ b/src/server/api/endpoints/notes/reactions/delete.ts
@@ -1,21 +1,16 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import Reaction from '../../../../../models/note-reaction';
 import Note from '../../../../../models/note';
-// import event from '../../../publishers/stream';
 
 /**
  * Unreact to a note
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Fetch unreactee
diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts
index 88d9ff329a..31f1bb941a 100644
--- a/src/server/api/endpoints/notes/replies.ts
+++ b/src/server/api/endpoints/notes/replies.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { pack } from '../../../../models/note';
 
 /**
@@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts
index 9dfc2c3cb5..fe98931380 100644
--- a/src/server/api/endpoints/notes/reposts.ts
+++ b/src/server/api/endpoints/notes/reposts.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { pack } from '../../../../models/note';
 
 /**
@@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get 'limit' parameter
@@ -21,11 +21,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts
index 3ff3fbbafa..021f620aa2 100644
--- a/src/server/api/endpoints/notes/search.ts
+++ b/src/server/api/endpoints/notes/search.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 const escapeRegexp = require('escape-regexp');
 import Note from '../../../../models/note';
 import User from '../../../../models/user';
@@ -22,19 +22,19 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (textError) return rej('invalid text param');
 
 	// Get 'includeUserIds' parameter
-	const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$;
+	const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array($().type(ID)).$;
 	if (includeUserIdsErr) return rej('invalid includeUserIds param');
 
 	// Get 'excludeUserIds' parameter
-	const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$;
+	const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array($().type(ID)).$;
 	if (excludeUserIdsErr) return rej('invalid excludeUserIds param');
 
 	// Get 'includeUserUsernames' parameter
-	const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$;
+	const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array($().string()).$;
 	if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param');
 
 	// Get 'excludeUserUsernames' parameter
-	const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$;
+	const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array($().string()).$;
 	if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param');
 
 	// Get 'following' parameter
diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts
index 67cdc3038b..266e0687e9 100644
--- a/src/server/api/endpoints/notes/show.ts
+++ b/src/server/api/endpoints/notes/show.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { pack } from '../../../../models/note';
 
 /**
@@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note';
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $(params.noteId).id().$;
+	const [noteId, noteIdErr] = $(params.noteId).type(ID).$;
 	if (noteIdErr) return rej('invalid noteId param');
 
 	// Get note
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index de30afea57..476d64158c 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
 import ChannelWatching from '../../../../models/channel-watching';
@@ -17,11 +17,11 @@ module.exports = async (params, user, app) => {
 	if (limitErr) throw 'invalid limit param';
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) throw 'invalid sinceId param';
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) throw 'invalid untilId param';
 
 	// Get 'sinceDate' parameter
diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts
index 48ecd5b843..6c220fc922 100644
--- a/src/server/api/endpoints/notes/trend.ts
+++ b/src/server/api/endpoints/notes/trend.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 const ms = require('ms');
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note, { pack } from '../../../../models/note';
 
 /**
diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts
index 283ecd63b1..600a80d194 100644
--- a/src/server/api/endpoints/notifications/get_unread_count.ts
+++ b/src/server/api/endpoints/notifications/get_unread_count.ts
@@ -6,10 +6,6 @@ import Mute from '../../../../models/mute';
 
 /**
  * Get count of unread notifications
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	const mute = await Mute.find({
diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts
index 01c9145837..dce3cb4663 100644
--- a/src/server/api/endpoints/notifications/mark_as_read_all.ts
+++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts
@@ -6,10 +6,6 @@ import event from '../../../../publishers/stream';
 
 /**
  * Mark as read all notifications
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
  */
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Update documents
diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts
index d05c1c2585..3b23b60637 100644
--- a/src/server/api/endpoints/othello/games.ts
+++ b/src/server/api/endpoints/othello/games.ts
@@ -1,4 +1,4 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import OthelloGame, { pack } from '../../../../models/othello-game';
 
 module.exports = (params, user) => new Promise(async (res, rej) => {
@@ -11,11 +11,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Check if both of sinceId and untilId is specified
diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts
index dd886936d4..d76c6556a2 100644
--- a/src/server/api/endpoints/othello/games/show.ts
+++ b/src/server/api/endpoints/othello/games/show.ts
@@ -1,10 +1,10 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import OthelloGame, { pack } from '../../../../../models/othello-game';
 import Othello from '../../../../../othello/core';
 
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'gameId' parameter
-	const [gameId, gameIdErr] = $(params.gameId).id().$;
+	const [gameId, gameIdErr] = $(params.gameId).type(ID).$;
 	if (gameIdErr) return rej('invalid gameId param');
 
 	const game = await OthelloGame.findOne({ _id: gameId });
diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts
index d9274f8f9c..b73b64437b 100644
--- a/src/server/api/endpoints/othello/match.ts
+++ b/src/server/api/endpoints/othello/match.ts
@@ -1,4 +1,4 @@
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Matching, { pack as packMatching } from '../../../../models/othello-matching';
 import OthelloGame, { pack as packGame } from '../../../../models/othello-game';
 import User from '../../../../models/user';
@@ -7,7 +7,7 @@ import { eighteight } from '../../../../othello/maps';
 
 module.exports = (params, user) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [childId, childIdErr] = $(params.userId).id().$;
+	const [childId, childIdErr] = $(params.userId).type(ID).$;
 	if (childIdErr) return rej('invalid userId param');
 
 	// Myself
diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts
index ae33e8af0c..5b389d452f 100644
--- a/src/server/api/endpoints/users.ts
+++ b/src/server/api/endpoints/users.ts
@@ -6,10 +6,6 @@ import User, { pack } from '../../../models/user';
 
 /**
  * Lists all users
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts
index 5f03326be8..940b5ed9bc 100644
--- a/src/server/api/endpoints/users/followers.ts
+++ b/src/server/api/endpoints/users/followers.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Following from '../../../../models/following';
 import { pack } from '../../../../models/user';
@@ -9,14 +9,10 @@ import { getFriendIds } from '../../common/get-friends';
 
 /**
  * Get followers of a user
- *
- * @param {any} params
- * @param {any} me
- * @return {Promise<any>}
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Get 'iknow' parameter
@@ -28,7 +24,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'cursor' parameter
-	const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+	const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$;
 	if (cursorErr) return rej('invalid cursor param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts
index 9fb135b24d..63a73a2e27 100644
--- a/src/server/api/endpoints/users/following.ts
+++ b/src/server/api/endpoints/users/following.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User from '../../../../models/user';
 import Following from '../../../../models/following';
 import { pack } from '../../../../models/user';
@@ -16,7 +16,7 @@ import { getFriendIds } from '../../common/get-friends';
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Get 'iknow' parameter
@@ -28,7 +28,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'cursor' parameter
-	const [cursor = null, cursorErr] = $(params.cursor).optional.id().$;
+	const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$;
 	if (cursorErr) return rej('invalid cursor param');
 
 	// Lookup user
diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts
index 7a98f44e98..4c00620a52 100644
--- a/src/server/api/endpoints/users/get_frequently_replied_users.ts
+++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts
@@ -1,13 +1,13 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import Note from '../../../../models/note';
 import User, { pack } from '../../../../models/user';
 
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).id().$;
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Get 'limit' parameter
diff --git a/src/server/api/endpoints/users/list/create.ts b/src/server/api/endpoints/users/list/create.ts
new file mode 100644
index 0000000000..6ae510f52b
--- /dev/null
+++ b/src/server/api/endpoints/users/list/create.ts
@@ -0,0 +1,25 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Create a user list
+ */
+module.exports = async (params, user) => new Promise(async (res, rej) => {
+	// Get 'title' parameter
+	const [title, titleErr] = $(params.title).string().range(1, 100).$;
+	if (titleErr) return rej('invalid title param');
+
+	// insert
+	const userList = await UserList.insert({
+		createdAt: new Date(),
+		userId: user._id,
+		title: title,
+		userIds: []
+	});
+
+	// Response
+	res(await pack(userList));
+});
diff --git a/src/server/api/endpoints/users/list/push.ts b/src/server/api/endpoints/users/list/push.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts
index bd4247c79c..dafa18bcc9 100644
--- a/src/server/api/endpoints/users/notes.ts
+++ b/src/server/api/endpoints/users/notes.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import getHostLower from '../../common/get-host-lower';
 import Note, { pack } from '../../../../models/note';
 import User from '../../../../models/user';
@@ -11,7 +11,7 @@ import User from '../../../../models/user';
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).optional.id().$;
+	const [userId, userIdErr] = $(params.userId).optional.type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Get 'username' parameter
@@ -43,11 +43,11 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (limitErr) return rej('invalid limit param');
 
 	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$;
+	const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$;
 	if (sinceIdErr) return rej('invalid sinceId param');
 
 	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $(params.untilId).optional.id().$;
+	const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$;
 	if (untilIdErr) return rej('invalid untilId param');
 
 	// Get 'sinceDate' parameter
diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts
index f72bb04bf1..1e8ef83432 100644
--- a/src/server/api/endpoints/users/recommendation.ts
+++ b/src/server/api/endpoints/users/recommendation.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 const ms = require('ms');
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User, { pack } from '../../../../models/user';
 import { getFriendIds } from '../../common/get-friends';
 import Mute from '../../../../models/mute';
diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts
index da30f47c2a..faf9b901d1 100644
--- a/src/server/api/endpoints/users/search.ts
+++ b/src/server/api/endpoints/users/search.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 import * as mongo from 'mongodb';
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User, { pack } from '../../../../models/user';
 import config from '../../../../config';
 const escapeRegexp = require('escape-regexp');
diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts
index 5f6ececff9..41a12d5332 100644
--- a/src/server/api/endpoints/users/search_by_username.ts
+++ b/src/server/api/endpoints/users/search_by_username.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User, { pack } from '../../../../models/user';
 
 /**
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index 7e7f5dc488..64adb5963b 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -1,7 +1,7 @@
 /**
  * Module dependencies
  */
-import $ from 'cafy';
+import $ from 'cafy'; import ID from '../../../../cafy-id';
 import User, { pack } from '../../../../models/user';
 import resolveRemoteUser from '../../../../remote/resolve-user';
 
@@ -14,7 +14,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	let user;
 
 	// Get 'userId' parameter
-	const [userId, userIdErr] = $(params.userId).optional.id().$;
+	const [userId, userIdErr] = $(params.userId).optional.type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
 	// Get 'username' parameter

From e14ea1fe61a55954866f39bfdaa365a040bf3963 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 20:58:29 +0900
Subject: [PATCH 04/25] wip

---
 src/server/api/endpoints/users/list/push.ts | 48 +++++++++++++++++++++
 1 file changed, 48 insertions(+)

diff --git a/src/server/api/endpoints/users/list/push.ts b/src/server/api/endpoints/users/list/push.ts
index e69de29bb2..f21234775d 100644
--- a/src/server/api/endpoints/users/list/push.ts
+++ b/src/server/api/endpoints/users/list/push.ts
@@ -0,0 +1,48 @@
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
+import UserList from '../../../../../models/user-list';
+import User from '../../../../../models/user';
+
+/**
+ * Add a user to 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');
+	}
+
+	// Get 'userId' parameter
+	const [userId, userIdErr] = $(params.userId).type(ID).$;
+	if (userIdErr) return rej('invalid userId param');
+
+	// Fetch the user
+	const user = await User.findOne({
+		_id: userId
+	});
+
+	if (user == null) {
+		return rej('user not found');
+	}
+
+	if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) {
+		return rej('the user already added');
+	}
+
+	// Push the user
+	await UserList.update({ _id: userList._id }, {
+		$push: {
+			userIds: user._id
+		}
+	});
+
+	res();
+});

From 070cd52c21683eed44c38525bb8cc5235a78b41d Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 21:24:06 +0900
Subject: [PATCH 05/25] wip

---
 locales/en.yml                                                | 1 +
 locales/fr.yml                                                | 1 +
 locales/ja.yml                                                | 1 +
 src/client/app/desktop/views/components/ui.header.account.vue | 3 +++
 4 files changed, 6 insertions(+)

diff --git a/locales/en.yml b/locales/en.yml
index 0a05393227..4ef12432be 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue:
   profile: "Your profile"
   drive: "Drive"
   favorites: "Favorites"
+  lists: "Lists"
   customize: "Customize"
   settings: "Settings"
   signout: "Sign out"
diff --git a/locales/fr.yml b/locales/fr.yml
index e640c4883c..80b1ed13da 100644
--- a/locales/fr.yml
+++ b/locales/fr.yml
@@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue:
   profile: "Votre profil"
   drive: "Drive"
   favorites: "Favorites"
+  lists: "リスト"
   customize: "Modifications"
   settings: "Réglages"
   signout: "Déconnexion"
diff --git a/locales/ja.yml b/locales/ja.yml
index 3d023281cd..e13348c407 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue:
   profile: "プロフィール"
   drive: "ドライブ"
   favorites: "お気に入り"
+  lists: "リスト"
   customize: "カスタマイズ"
   settings: "設定"
   signout: "サインアウト"
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 2d4d23933c..54da97071a 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -16,6 +16,9 @@
 				<li>
 					<router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link>
 				</li>
+				<li @click="list">
+					<p>%fa:cog%<span>%i18n:@lists%</span>%fa:angle-right%</p>
+				</li>
 			</ul>
 			<ul>
 				<li>

From 1a13c7e0b1cbc03064cc1710cc350875a77b24e0 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 24 Apr 2018 23:34:18 +0900
Subject: [PATCH 06/25] wip

---
 package.json                                               | 2 +-
 src/cafy-id.ts                                             | 2 +-
 .../app/desktop/views/components/ui.header.account.vue     | 2 +-
 src/models/user.ts                                         | 7 ++++---
 src/server/api/endpoints/auth/accept.ts                    | 2 +-
 src/server/api/endpoints/notes/trend.ts                    | 2 +-
 src/server/api/endpoints/users/recommendation.ts           | 2 +-
 src/server/api/endpoints/users/search.ts                   | 2 +-
 8 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/package.json b/package.json
index ae90d21301..7d0adc3cb6 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
 		"autwh": "0.1.0",
 		"bcryptjs": "2.4.3",
 		"bootstrap-vue": "2.0.0-rc.6",
-		"cafy": "5.1.0",
+		"cafy": "6.0.0",
 		"chai": "4.1.2",
 		"chai-http": "4.0.0",
 		"chalk": "2.4.0",
diff --git a/src/cafy-id.ts b/src/cafy-id.ts
index 1109d42d8f..3faf5cd996 100644
--- a/src/cafy-id.ts
+++ b/src/cafy-id.ts
@@ -19,7 +19,7 @@ export default class ID extends Query<mongo.ObjectID> {
 			}
 		};
 
-		this.pushFirstTimeValidator(v => {
+		this.pushValidator(v => {
 			if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) {
 				return new Error('must-be-an-id');
 			}
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 54da97071a..61197d4017 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -17,7 +17,7 @@
 					<router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link>
 				</li>
 				<li @click="list">
-					<p>%fa:cog%<span>%i18n:@lists%</span>%fa:angle-right%</p>
+					<p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p>
 				</li>
 			</ul>
 			<ul>
diff --git a/src/models/user.ts b/src/models/user.ts
index 82110a5475..0621b6e736 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -1,5 +1,6 @@
 import * as mongo from 'mongodb';
 import deepcopy = require('deepcopy');
+import sequential = require('promise-sequential');
 import rap from '@prezzemolo/rap';
 import db from '../db/mongodb';
 import Note, { pack as packNote, deleteNote } from './note';
@@ -167,9 +168,9 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) {
 	).map(x => deleteAccessToken(x)));
 
 	// このユーザーのNoteをすべて削除
-	await Promise.all((
-		await Note.find({ userId: u._id })
-	).map(x => deleteNote(x)));
+	//await sequential((
+	//	await Note.find({ userId: u._id })
+	//).map(x => () => deleteNote(x)));
 
 	// このユーザーのNoteReactionをすべて削除
 	await Promise.all((
diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts
index e0073b31e6..b6297d663d 100644
--- a/src/server/api/endpoints/auth/accept.ts
+++ b/src/server/api/endpoints/auth/accept.ts
@@ -3,7 +3,7 @@
  */
 import rndstr from 'rndstr';
 const crypto = require('crypto');
-import $ from 'cafy'; import ID from '../../../../cafy-id';
+import $ from 'cafy';
 import App from '../../../../models/app';
 import AuthSess from '../../../../models/auth-session';
 import AccessToken from '../../../../models/access-token';
diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts
index 6c220fc922..48ecd5b843 100644
--- a/src/server/api/endpoints/notes/trend.ts
+++ b/src/server/api/endpoints/notes/trend.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 const ms = require('ms');
-import $ from 'cafy'; import ID from '../../../../cafy-id';
+import $ from 'cafy';
 import Note, { pack } from '../../../../models/note';
 
 /**
diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts
index 1e8ef83432..f72bb04bf1 100644
--- a/src/server/api/endpoints/users/recommendation.ts
+++ b/src/server/api/endpoints/users/recommendation.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 const ms = require('ms');
-import $ from 'cafy'; import ID from '../../../../cafy-id';
+import $ from 'cafy';
 import User, { pack } from '../../../../models/user';
 import { getFriendIds } from '../../common/get-friends';
 import Mute from '../../../../models/mute';
diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts
index faf9b901d1..da30f47c2a 100644
--- a/src/server/api/endpoints/users/search.ts
+++ b/src/server/api/endpoints/users/search.ts
@@ -2,7 +2,7 @@
  * Module dependencies
  */
 import * as mongo from 'mongodb';
-import $ from 'cafy'; import ID from '../../../../cafy-id';
+import $ from 'cafy';
 import User, { pack } from '../../../../models/user';
 import config from '../../../../config';
 const escapeRegexp = require('escape-regexp');

From 1ba5dfd79c66edd871f922d21861557e6152cc6c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 06:34:50 +0900
Subject: [PATCH 07/25] wip

---
 .../desktop/views/components/lists-window.vue | 37 +++++++++++++++++++
 .../endpoints/users/{list => lists}/create.ts |  0
 src/server/api/endpoints/users/lists/list.ts  | 13 +++++++
 .../endpoints/users/{list => lists}/push.ts   |  0
 4 files changed, 50 insertions(+)
 create mode 100644 src/client/app/desktop/views/components/lists-window.vue
 rename src/server/api/endpoints/users/{list => lists}/create.ts (100%)
 create mode 100644 src/server/api/endpoints/users/lists/list.ts
 rename src/server/api/endpoints/users/{list => lists}/push.ts (100%)

diff --git a/src/client/app/desktop/views/components/lists-window.vue b/src/client/app/desktop/views/components/lists-window.vue
new file mode 100644
index 0000000000..7d6a5def2c
--- /dev/null
+++ b/src/client/app/desktop/views/components/lists-window.vue
@@ -0,0 +1,37 @@
+<template>
+<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>
+</mk-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+	data() {
+		return {
+			fetching: true,
+			lists: []
+		};
+	},
+	mounted() {
+		(this as any).api('users/lists/list').then(lists => {
+			this.fetching = false;
+			this.lists = lists;
+		});
+	},
+	methods: {
+		close() {
+			(this as any).$refs.window.close();
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+
+</style>
diff --git a/src/server/api/endpoints/users/list/create.ts b/src/server/api/endpoints/users/lists/create.ts
similarity index 100%
rename from src/server/api/endpoints/users/list/create.ts
rename to src/server/api/endpoints/users/lists/create.ts
diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts
new file mode 100644
index 0000000000..d19339a1f5
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/list.ts
@@ -0,0 +1,13 @@
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Add a user to a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+	// Fetch lists
+	const userLists = await UserList.find({
+		userId: me._id,
+	});
+
+	res(await Promise.all(userLists.map(x => pack(x))));
+});
diff --git a/src/server/api/endpoints/users/list/push.ts b/src/server/api/endpoints/users/lists/push.ts
similarity index 100%
rename from src/server/api/endpoints/users/list/push.ts
rename to src/server/api/endpoints/users/lists/push.ts

From c7f80182c251237d34fa17d67baf055ae3632b04 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 12:36:54 +0900
Subject: [PATCH 08/25] wip

---
 .eslintrc                                     |  1 +
 .../views/components/list-timeline.vue        | 75 ++++++++++++++++
 .../desktop/views/components/lists-window.vue | 17 +++-
 .../app/desktop/views/components/notes.vue    | 88 +++++++++++++++++--
 .../views/components/timeline.core.vue        | 70 ++++-----------
 .../views/components/ui.header.account.vue    |  5 ++
 src/client/app/desktop/views/pages/list.vue   | 72 +++++++++++++++
 7 files changed, 264 insertions(+), 64 deletions(-)
 create mode 100644 src/client/app/desktop/views/components/list-timeline.vue
 create mode 100644 src/client/app/desktop/views/pages/list.vue

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>

From 4c9b4cb80d221bf2316cf0c7133813c30ffb377b Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 13:48:02 +0900
Subject: [PATCH 09/25] wip

---
 .../app/common/scripts/streaming/user-list.ts | 17 ++++++++++
 .../views/components/list-timeline.vue        | 15 +++++----
 .../app/desktop/views/components/notes.vue    | 31 +++++++++++++++++--
 .../views/components/timeline.core.vue        | 25 ---------------
 4 files changed, 53 insertions(+), 35 deletions(-)
 create mode 100644 src/client/app/common/scripts/streaming/user-list.ts

diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts
new file mode 100644
index 0000000000..30a52b98dd
--- /dev/null
+++ b/src/client/app/common/scripts/streaming/user-list.ts
@@ -0,0 +1,17 @@
+import Stream from './stream';
+import MiOS from '../../mios';
+
+export class UserListStream extends Stream {
+	constructor(os: MiOS, me, listId) {
+		super(os, 'user-list', {
+			i: me.token,
+			listId
+		});
+
+		(this as any).on('_connected_', () => {
+			this.send({
+				i: me.token
+			});
+		});
+	}
+}
diff --git a/src/client/app/desktop/views/components/list-timeline.vue b/src/client/app/desktop/views/components/list-timeline.vue
index 61300f6f8f..e946453f40 100644
--- a/src/client/app/desktop/views/components/list-timeline.vue
+++ b/src/client/app/desktop/views/components/list-timeline.vue
@@ -4,6 +4,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { UserListStream } from '../../../common/scripts/streaming/user-list';
 
 const fetchLimit = 10;
 
@@ -21,21 +22,19 @@ export default Vue.extend({
 		$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() {
+			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.fetching = true;
 
 			(this as any).api('notes/list-timeline', {
diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index 2822cb8c0f..6965a18eda 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -20,6 +20,8 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { url } from '../../../config';
+
 import XNote from './notes.note.vue';
 
 const displayLimit = 30;
@@ -83,10 +85,35 @@ export default Vue.extend({
 			this.notes = notes;
 		},
 
-		prepend(note) {
+		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 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();
+				}
+
 				// オーバーフローしたら古い投稿は捨てる
 				if (this.notes.length >= displayLimit) {
 					this.notes = this.notes.slice(0, displayLimit);
@@ -105,7 +132,7 @@ export default Vue.extend({
 		},
 
 		releaseQueue() {
-			this.queue.forEach(n => this.prepend(n));
+			this.queue.forEach(n => this.prepend(n, true));
 			this.queue = [];
 		},
 
diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue
index 11c7adf158..93cc59b556 100644
--- a/src/client/app/desktop/views/components/timeline.core.vue
+++ b/src/client/app/desktop/views/components/timeline.core.vue
@@ -14,7 +14,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { url } from '../../../config';
 
 const fetchLimit = 10;
 
@@ -136,30 +135,6 @@ export default Vue.extend({
 		},
 
 		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 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);
 		},

From 3d9ac6387e4f698c15bdca0d8dbd1f0a2b02161f Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 18:04:16 +0900
Subject: [PATCH 10/25] wip

---
 src/publishers/stream.ts                     |   5 +
 src/server/api/endpoints/users/lists/push.ts |   5 +-
 src/services/note/create.ts                  | 106 +++++++++++--------
 3 files changed, 69 insertions(+), 47 deletions(-)

diff --git a/src/publishers/stream.ts b/src/publishers/stream.ts
index 2ecbfa0dd8..dcc03e39f1 100644
--- a/src/publishers/stream.ts
+++ b/src/publishers/stream.ts
@@ -25,6 +25,10 @@ class MisskeyEvent {
 		this.publish(`note-stream:${noteId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	public publishUserListStream(listId: ID, type: string, value?: any): void {
+		this.publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void {
 		this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
 	}
@@ -69,6 +73,7 @@ export default ev.publishUserStream.bind(ev);
 export const publishLocalTimelineStream = ev.publishLocalTimelineStream.bind(ev);
 export const publishGlobalTimelineStream = ev.publishGlobalTimelineStream.bind(ev);
 export const publishDriveStream = ev.publishDriveStream.bind(ev);
+export const publishUserListStream = ev.publishUserListStream.bind(ev);
 export const publishNoteStream = ev.publishNoteStream.bind(ev);
 export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
 export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev);
diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts
index f21234775d..467c08efd4 100644
--- a/src/server/api/endpoints/users/lists/push.ts
+++ b/src/server/api/endpoints/users/lists/push.ts
@@ -1,6 +1,7 @@
 import $ from 'cafy'; import ID from '../../../../../cafy-id';
 import UserList from '../../../../../models/user-list';
-import User from '../../../../../models/user';
+import User, { pack as packUser } from '../../../../../models/user';
+import { publishUserListStream } from '../../../../../publishers/stream';
 
 /**
  * Add a user to a user list
@@ -45,4 +46,6 @@ module.exports = async (params, me) => new Promise(async (res, rej) => {
 	});
 
 	res();
+
+	publishUserListStream(userList._id, 'userAdded', await packUser(user));
 });
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index e5ad96898f..4808edfda4 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -1,6 +1,6 @@
 import Note, { pack, INote } from '../../models/note';
 import User, { isLocalUser, IUser, isRemoteUser } from '../../models/user';
-import stream, { publishLocalTimelineStream, publishGlobalTimelineStream } from '../../publishers/stream';
+import stream, { publishLocalTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../publishers/stream';
 import Following from '../../models/following';
 import { deliver } from '../../queue';
 import renderNote from '../../remote/activitypub/renderer/note';
@@ -16,6 +16,7 @@ import pushSw from '../../publishers/push-sw';
 import event from '../../publishers/stream';
 import parse from '../../text/parse';
 import { IApp } from '../../models/app';
+import UserList from '../../models/user-list';
 
 export default async (user: IUser, data: {
 	createdAt?: Date;
@@ -110,60 +111,73 @@ export default async (user: IUser, data: {
 
 	// タイムラインへの投稿
 	if (note.channelId == null) {
-		if (isLocalUser(user)) {
-			// Publish event to myself's stream
-			stream(note.userId, 'note', noteObj);
-
-			// Publish note to local timeline stream
-			publishLocalTimelineStream(noteObj);
-		}
-
-		// Publish note to global timeline stream
-		publishGlobalTimelineStream(noteObj);
-
-		// Fetch all followers
-		const followers = await Following.find({
-			followeeId: note.userId
-		});
-
 		if (!silent) {
-			const render = async () => {
-				const content = data.renote && data.text == null
-					? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote))
-					: renderCreate(await renderNote(note));
-				return packAp(content);
-			};
+			if (isLocalUser(user)) {
+				// Publish event to myself's stream
+				stream(note.userId, 'note', noteObj);
 
-			// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
-			if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) {
-				deliver(user, await render(), data.reply._user.inbox);
+				// Publish note to local timeline stream
+				publishLocalTimelineStream(noteObj);
 			}
 
-			// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
-			if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) {
-				deliver(user, await render(), data.renote._user.inbox);
-			}
+			// Publish note to global timeline stream
+			publishGlobalTimelineStream(noteObj);
 
-			Promise.all(followers.map(async following => {
-				const follower = following._follower;
+			// フォロワーに配信
+			Following.find({
+				followeeId: note.userId
+			}).then(followers => {
+				followers.map(async following => {
+					const follower = following._follower;
 
-				if (isLocalUser(follower)) {
-					// ストーキングしていない場合
-					if (!following.stalk) {
-						// この投稿が返信ならスキップ
-						if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return;
+					if (isLocalUser(follower)) {
+						// ストーキングしていない場合
+						if (!following.stalk) {
+							// この投稿が返信ならスキップ
+							if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return;
+						}
+
+						// Publish event to followers stream
+						stream(following.followerId, 'note', noteObj);
+					} else {
+						//#region AP配送
+						// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
+						if (isLocalUser(user)) {
+							deliver(user, await render(), follower.inbox);
+						}
+						//#endergion
 					}
+				});
+			});
 
-					// Publish event to followers stream
-					stream(following.followerId, 'note', noteObj);
-				} else {
-					// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
-					if (isLocalUser(user)) {
-						deliver(user, await render(), follower.inbox);
-					}
-				}
-			}));
+			// リストに配信
+			UserList.find({
+				userIds: note.userId
+			}).then(lists => {
+				lists.forEach(list => {
+					publishUserListStream(list._id, 'note', noteObj);
+				});
+			});
 		}
+
+		//#region AP配送
+		const render = async () => {
+			const content = data.renote && data.text == null
+				? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote))
+				: renderCreate(await renderNote(note));
+			return packAp(content);
+		};
+
+		// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
+		if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) {
+			deliver(user, await render(), data.reply._user.inbox);
+		}
+
+		// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
+		if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) {
+			deliver(user, await render(), data.renote._user.inbox);
+		}
+		//#endergion
 	}
 
 	// チャンネルへの投稿

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 11/25] 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

From 76c8e05e3a7390a4525b2b5edd9fd68ffdcd7e63 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 20:20:02 +0900
Subject: [PATCH 12/25] wip

---
 .../app/desktop/views/pages/user-list.vue     | 56 ++++++++-----------
 1 file changed, 24 insertions(+), 32 deletions(-)

diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue
index 1889f7dbe4..a01da97f6e 100644
--- a/src/client/app/desktop/views/pages/user-list.vue
+++ b/src/client/app/desktop/views/pages/user-list.vue
@@ -1,11 +1,13 @@
 <template>
 <mk-ui>
-	<template v-if="!fetching">
-		<header :class="$style.header">
-			<h1>{{ list.title }}</h1>
-		</header>
-		<mk-user-list-timeline :list="list"/>
-	</template>
+	<div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c">
+		<div>
+			<div><h1>{{ list.title }}</h1></div>
+		</div>
+		<main>
+			<mk-user-list-timeline :list="list"/>
+		</main>
+	</div>
 </mk-ui>
 </template>
 
@@ -40,35 +42,25 @@ export default Vue.extend({
 });
 </script>
 
-<style lang="stylus" module>
-.header
-	width 100%
-	max-width 600px
+<style lang="stylus" scoped>
+[data-id="02010e15-cc48-4245-8636-16078a9b623c"]
+	display flex
+	justify-content center
 	margin 0 auto
-	color #555
+	max-width 1200px
 
-.notes
-	max-width 600px
-	margin 0 auto
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
-	overflow hidden
+	> main
+	> div > div
+		> *:not(:last-child)
+			margin-bottom 16px
 
-.loading
-	padding 64px 0
+	> main
+		padding 16px
+		width calc(100% - 275px * 2)
 
-.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
+	> div
+		width 275px
+		margin 0
+		padding 16px 0 16px 16px
 
 </style>

From 7ea42af0ba9f3678985270f382279560609623be Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 22:37:08 +0900
Subject: [PATCH 13/25] wip

---
 .../app/desktop/views/components/notes.vue    |   8 +-
 .../views/components/ui.header.account.vue    |   4 +-
 .../views/components/user-list-timeline.vue   |   2 +
 ...lists-window.vue => user-lists-window.vue} |   0
 .../desktop/views/pages/user-list.users.vue   | 131 ++++++++++++++++++
 .../app/desktop/views/pages/user-list.vue     |   5 +
 src/server/api/endpoints/users/show.ts        |  58 +++++---
 7 files changed, 179 insertions(+), 29 deletions(-)
 rename src/client/app/desktop/views/components/{lists-window.vue => user-lists-window.vue} (100%)
 create mode 100644 src/client/app/desktop/views/pages/user-list.users.vue

diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index ae36c899d5..01e1f5c2f0 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -223,18 +223,18 @@ root(isDark)
 		background $theme-color
 
 	> footer
-		> *
+		> button
 			display block
 			margin 0
 			padding 16px
 			width 100%
 			text-align center
 			color #ccc
+			background isDark ? #282C37 : #fff
 			border-top solid 1px isDark ? #1c2023 : #eaeaea
-			border-bottom-left-radius 4px
-			border-bottom-right-radius 4px
+			border-bottom-left-radius 6px
+			border-bottom-right-radius 6px
 
-		> button
 			&:hover
 				background isDark ? #2e3440 : #f5f5f5
 
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 93ee5aa195..b91448af49 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -45,7 +45,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import MkListsWindow from './lists-window.vue';
+import MkUserListsWindow from './user-lists-window.vue';
 import MkSettingsWindow from './settings-window.vue';
 import MkDriveWindow from './drive-window.vue';
 import contains from '../../../common/scripts/contains';
@@ -86,7 +86,7 @@ export default Vue.extend({
 		},
 		list() {
 			this.close();
-			(this as any).os.new(MkListsWindow);
+			(this as any).os.new(MkUserListsWindow);
 		},
 		settings() {
 			this.close();
diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue
index f71972ab78..8a1814f99c 100644
--- a/src/client/app/desktop/views/components/user-list-timeline.vue
+++ b/src/client/app/desktop/views/components/user-list-timeline.vue
@@ -1,5 +1,7 @@
 <template>
+<div>
 	<mk-notes ref="timeline" :more="existMore ? more : null"/>
+</div>
 </template>
 
 <script lang="ts">
diff --git a/src/client/app/desktop/views/components/lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
similarity index 100%
rename from src/client/app/desktop/views/components/lists-window.vue
rename to src/client/app/desktop/views/components/user-lists-window.vue
diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue
new file mode 100644
index 0000000000..63070ed609
--- /dev/null
+++ b/src/client/app/desktop/views/pages/user-list.users.vue
@@ -0,0 +1,131 @@
+<template>
+<div>
+	<mk-widget-container>
+		<template slot="header">%fa:users% ユーザー</template>
+		<button slot="func" title="ユーザーを追加" @click="add">%fa:plus%</button>
+
+		<div data-id="d0b63759-a822-4556-a5ce-373ab966e08a">
+			<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw% %i18n:common.loading%<mk-ellipsis/></p>
+			<template v-else-if="users.length != 0">
+				<div class="user" v-for="_user in users">
+					<router-link class="avatar-anchor" :to="_user | userPage">
+						<img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/>
+					</router-link>
+					<div class="body">
+						<router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link>
+						<p class="username">@{{ _user | acct }}</p>
+					</div>
+				</div>
+			</template>
+			<p class="empty" v-else>%i18n:@no-one%</p>
+		</div>
+	</mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		list: {
+			type: Object,
+			required: true
+		}
+	},
+	data() {
+		return {
+			fetching: true,
+			users: []
+		};
+	},
+	mounted() {
+		(this as any).api('users/show', {
+			userIds: this.list.userIds
+		}).then(users => {
+			this.users = users;
+			this.fetching = false;
+		});
+	},
+	methods: {
+		add() {
+			(this as any).apis.input({
+				title: 'ユーザー名',
+			}).then(async username => {
+				const user = await (this as any).api('users/show', {
+					username
+				});
+
+				(this as any).api('users/lists/push', {
+					listId: this.list.id,
+					userId: user.id
+				});
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+	> .user
+		padding 16px
+		border-bottom solid 1px isDark ? #1c2023 : #eee
+
+		&:last-child
+			border-bottom none
+
+		&:after
+			content ""
+			display block
+			clear both
+
+		> .avatar-anchor
+			display block
+			float left
+			margin 0 12px 0 0
+
+			> .avatar
+				display block
+				width 42px
+				height 42px
+				margin 0
+				border-radius 8px
+				vertical-align bottom
+
+		> .body
+			float left
+			width calc(100% - 54px)
+
+			> .name
+				margin 0
+				font-size 16px
+				line-height 24px
+				color isDark ? #fff : #555
+
+			> .username
+				display block
+				margin 0
+				font-size 15px
+				line-height 16px
+				color isDark ? #606984 : #ccc
+
+	> .empty
+		margin 0
+		padding 16px
+		text-align center
+		color #aaa
+
+	> .fetching
+		margin 0
+		padding 16px
+		text-align center
+		color #aaa
+
+[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"][data-darkmode]
+	root(true)
+
+[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"]:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue
index a01da97f6e..2241b84e5e 100644
--- a/src/client/app/desktop/views/pages/user-list.vue
+++ b/src/client/app/desktop/views/pages/user-list.vue
@@ -3,6 +3,7 @@
 	<div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c">
 		<div>
 			<div><h1>{{ list.title }}</h1></div>
+			<x-users :list="list"/>
 		</div>
 		<main>
 			<mk-user-list-timeline :list="list"/>
@@ -13,8 +14,12 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import XUsers from './user-list.users.vue';
 
 export default Vue.extend({
+	components: {
+		XUsers
+	},
 	data() {
 		return {
 			fetching: true,
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index 64adb5963b..141565ece6 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -8,7 +8,7 @@ import resolveRemoteUser from '../../../../remote/resolve-user';
 const cursorOption = { fields: { data: false } };
 
 /**
- * Show a user
+ * Show user(s)
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
 	let user;
@@ -17,6 +17,10 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	const [userId, userIdErr] = $(params.userId).optional.type(ID).$;
 	if (userIdErr) return rej('invalid userId param');
 
+	// Get 'userIds' parameter
+	const [userIds, userIdsErr] = $(params.userIds).optional.array($().type(ID)).$;
+	if (userIdsErr) return rej('invalid userIds param');
+
 	// Get 'username' parameter
 	const [username, usernameErr] = $(params.username).optional.string().$;
 	if (usernameErr) return rej('invalid username param');
@@ -25,32 +29,40 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	const [host, hostErr] = $(params.host).nullable.optional.string().$;
 	if (hostErr) return rej('invalid host param');
 
-	if (userId === undefined && typeof username !== 'string') {
-		return rej('userId or pair of username and host is required');
-	}
+	if (userIds) {
+		const users = await User.find({
+			_id: {
+				$in: userIds
+			}
+		});
 
-	// Lookup user
-	if (typeof host === 'string') {
-		try {
-			user = await resolveRemoteUser(username, host, cursorOption);
-		} catch (e) {
-			console.warn(`failed to resolve remote user: ${e}`);
-			return rej('failed to resolve remote user');
-		}
+		res(await Promise.all(users.map(u => pack(u, me, {
+			detail: true
+		}))));
 	} else {
-		const q = userId !== undefined
-			? { _id: userId }
-			: { usernameLower: username.toLowerCase(), host: null };
+		// Lookup user
+		if (typeof host === 'string') {
+			try {
+				user = await resolveRemoteUser(username, host, cursorOption);
+			} catch (e) {
+				console.warn(`failed to resolve remote user: ${e}`);
+				return rej('failed to resolve remote user');
+			}
+		} else {
+			const q = userId !== undefined
+				? { _id: userId }
+				: { usernameLower: username.toLowerCase(), host: null };
 
-		user = await User.findOne(q, cursorOption);
+			user = await User.findOne(q, cursorOption);
 
-		if (user === null) {
-			return rej('user not found');
+			if (user === null) {
+				return rej('user not found');
+			}
 		}
-	}
 
-	// Send response
-	res(await pack(user, me, {
-		detail: true
-	}));
+		// Send response
+		res(await pack(user, me, {
+			detail: true
+		}));
+	}
 });

From f801b050575f3f55082e7b3993ba9441d4d1219f Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 22:50:05 +0900
Subject: [PATCH 14/25] wip

---
 .../app/desktop/views/components/timeline.vue | 23 +++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index 8035510a14..a798e5d7b4 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -4,6 +4,8 @@
 		<span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
 		<span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
 		<span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+
+		<button @click="list" title="リスト">%fa:list%</button>
 	</header>
 	<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
 	<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
@@ -35,6 +37,10 @@ export default Vue.extend({
 	methods: {
 		warp(date) {
 			(this.$refs.tl as any).warp(date);
+		},
+
+		list() {
+
 		}
 	}
 });
@@ -55,6 +61,23 @@ root(isDark)
 		border-radius 6px 6px 0 0
 		box-shadow 0 1px rgba(0, 0, 0, 0.08)
 
+		> button
+			position absolute
+			z-index 2
+			top 0
+			right 0
+			padding 0
+			width 42px
+			font-size 0.9em
+			line-height 42px
+			color isDark ? #9baec8 : #ccc
+
+			&:hover
+				color isDark ? #b2c1d5 : #aaa
+
+			&:active
+				color isDark ? #b2c1d5 : #999
+
 		> span
 			display inline-block
 			padding 0 10px

From 80cfa0f95d7b1e49fd4380e0d662ff892789925f Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 22:50:59 +0900
Subject: [PATCH 15/25] wip

---
 src/client/app/desktop/views/components/widget-container.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue
index c3fac1399d..926d7702b9 100644
--- a/src/client/app/desktop/views/components/widget-container.vue
+++ b/src/client/app/desktop/views/components/widget-container.vue
@@ -58,7 +58,7 @@ root(isDark)
 			box-shadow 0 1px rgba(0, 0, 0, 0.07)
 
 			> [data-fa]
-				margin-right 4px
+				margin-right 6px
 
 			&:empty
 				display none

From 0616d29fd5db7238278cf5118e7fd1bd1c402ea4 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 25 Apr 2018 23:08:40 +0900
Subject: [PATCH 16/25] wip

---
 src/client/app/common/mios.ts                    |  1 +
 .../app/desktop/views/components/timeline.vue    | 16 +++++++++++-----
 .../views/components/ui.header.account.vue       |  5 ++++-
 .../views/components/user-lists-window.vue       |  7 +++++--
 4 files changed, 21 insertions(+), 8 deletions(-)

diff --git a/src/client/app/common/mios.ts b/src/client/app/common/mios.ts
index 463f763888..4e471cf96f 100644
--- a/src/client/app/common/mios.ts
+++ b/src/client/app/common/mios.ts
@@ -88,6 +88,7 @@ export default class MiOS extends EventEmitter {
 			propsData: props
 		}).$mount();
 		document.body.appendChild(w.$el);
+		return w;
 	}
 
 	/**
diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index a798e5d7b4..be714d7bf9 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -4,18 +4,20 @@
 		<span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
 		<span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
 		<span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
-
-		<button @click="list" title="リスト">%fa:list%</button>
+		<span :data-is-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
+		<button @click="chooseList" title="リスト">%fa:list%</button>
 	</header>
 	<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
 	<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
 	<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
+	<mk-user-list-timeline v-if="src == 'list'" ref="tl" key="list" :list="list"/>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import XCore from './timeline.core.vue';
+import MkUserListsWindow from './user-lists-window.vue';
 
 export default Vue.extend({
 	components: {
@@ -24,7 +26,8 @@ export default Vue.extend({
 
 	data() {
 		return {
-			src: 'home'
+			src: 'home',
+			list: null
 		};
 	},
 
@@ -39,8 +42,11 @@ export default Vue.extend({
 			(this.$refs.tl as any).warp(date);
 		},
 
-		list() {
-
+		chooseList() {
+			const w = (this as any).os.new(MkUserListsWindow);
+			w.$once('choosen', list => {
+				this.list = list;
+			});
 		}
 	}
 });
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 b91448af49..5148c5b967 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -86,7 +86,10 @@ export default Vue.extend({
 		},
 		list() {
 			this.close();
-			(this as any).os.new(MkUserListsWindow);
+			const w = (this as any).os.new(MkUserListsWindow);
+			w.$once('choosen', list => {
+				this.$router.push(`i/lists/${ list.id }`);
+			});
 		},
 		settings() {
 			this.close();
diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
index 30b1794a29..3342b60bf7 100644
--- a/src/client/app/desktop/views/components/user-lists-window.vue
+++ b/src/client/app/desktop/views/components/user-lists-window.vue
@@ -3,7 +3,7 @@
 	<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>
+	<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a>
 </mk-window>
 </template>
 
@@ -31,9 +31,12 @@ export default Vue.extend({
 					title
 				});
 
-				this.$router.push(`i/lists/${ list.id }`);
+				this.$emit('choosen', list);
 			});
 		},
+		choice(list) {
+			this.$emit('choosen', list);
+		},
 		close() {
 			(this as any).$refs.window.close();
 		}

From 3972e98f74cd8f31da013ed4e4e16b0d65435647 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 11:02:15 +0900
Subject: [PATCH 17/25] wip

---
 src/server/api/stream/user-list.ts | 14 ++++++++++++++
 src/server/api/streaming.ts        |  2 ++
 2 files changed, 16 insertions(+)
 create mode 100644 src/server/api/stream/user-list.ts

diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts
new file mode 100644
index 0000000000..ba03b97860
--- /dev/null
+++ b/src/server/api/stream/user-list.ts
@@ -0,0 +1,14 @@
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+import { ParsedUrlQuery } from 'querystring';
+
+export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void {
+	const q = request.resourceURL.query as ParsedUrlQuery;
+	const listId = q.listId as string;
+
+	// Subscribe stream
+	subscriber.subscribe(`misskey:user-list-stream:${listId}`);
+	subscriber.on('message', (_, data) => {
+		connection.send(data);
+	});
+}
diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts
index ce13253649..e4884ed7c4 100644
--- a/src/server/api/streaming.ts
+++ b/src/server/api/streaming.ts
@@ -6,6 +6,7 @@ import config from '../../config';
 import homeStream from './stream/home';
 import localTimelineStream from './stream/local-timeline';
 import globalTimelineStream from './stream/global-timeline';
+import userListStream from './stream/user-list';
 import driveStream from './stream/drive';
 import messagingStream from './stream/messaging';
 import messagingIndexStream from './stream/messaging-index';
@@ -70,6 +71,7 @@ module.exports = (server: http.Server) => {
 			request.resourceURL.pathname === '/' ? homeStream :
 			request.resourceURL.pathname === '/local-timeline' ? localTimelineStream :
 			request.resourceURL.pathname === '/global-timeline' ? globalTimelineStream :
+			request.resourceURL.pathname === '/user-list' ? userListStream :
 			request.resourceURL.pathname === '/drive' ? driveStream :
 			request.resourceURL.pathname === '/messaging' ? messagingStream :
 			request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream :

From a2a3dd55adade8c54f3d562bf30b4af1ce15ce45 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 11:19:57 +0900
Subject: [PATCH 18/25] wip

---
 .../app/desktop/views/components/timeline.vue |  2 +
 .../views/components/user-lists-window.vue    | 26 +++++++-
 .../views/pages/user/user.timeline.vue        | 65 +++++++++----------
 3 files changed, 57 insertions(+), 36 deletions(-)

diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index be714d7bf9..d50b41b846 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -46,6 +46,8 @@ export default Vue.extend({
 			const w = (this as any).os.new(MkUserListsWindow);
 			w.$once('choosen', list => {
 				this.list = list;
+				this.src = 'list';
+				w.close();
 			});
 		}
 	}
diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
index 3342b60bf7..d082610132 100644
--- a/src/client/app/desktop/views/components/user-lists-window.vue
+++ b/src/client/app/desktop/views/components/user-lists-window.vue
@@ -1,9 +1,11 @@
 <template>
-<mk-window ref="window" is-modal width="500px" height="550px" @closed="$destroy">
+<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy">
 	<span slot="header">%fa:list% リスト</span>
 
-	<button class="ui" @click="add">リストを作成</button>
-	<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a>
+	<div data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82" :data-darkmode="_darkmode_">
+		<button class="ui" @click="add">リストを作成</button>
+		<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a>
+	</div>
 </mk-window>
 </template>
 
@@ -46,4 +48,22 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 
+root(isDark)
+	padding 16px
+
+	> button
+		margin-bottom 16px
+
+	> a
+		display block
+		padding 16px
+		border solid 1px isDark ? #1c2023 : #eee
+		border-radius 4px
+
+[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"][data-darkmode]
+	root(true)
+
+[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"]:not([data-darkmode])
+	root(false)
+
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue
index 55d6072a9d..754be8c04f 100644
--- a/src/client/app/desktop/views/pages/user/user.timeline.vue
+++ b/src/client/app/desktop/views/pages/user/user.timeline.vue
@@ -8,35 +8,29 @@
 	<div class="loading" v-if="fetching">
 		<mk-ellipsis-icon/>
 	</div>
-	<p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p>
-	<mk-notes ref="timeline" :notes="notes">
-		<div slot="footer">
-			<template v-if="!moreFetching">%fa:moon%</template>
-			<template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
-		</div>
+	<mk-notes ref="timeline" :more="existMore ? more : null">
+		<p class="empty" slot="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p>
 	</mk-notes>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+
+const fetchLimit = 10;
+
 export default Vue.extend({
 	props: ['user'],
 	data() {
 		return {
 			fetching: true,
 			moreFetching: false,
+			existMore: false,
 			mode: 'default',
 			unreadCount: 0,
-			notes: [],
 			date: null
 		};
 	},
-	computed: {
-		empty(): boolean {
-			return this.notes.length == 0;
-		}
-	},
 	watch: {
 		mode() {
 			this.fetch();
@@ -44,13 +38,11 @@ export default Vue.extend({
 	},
 	mounted() {
 		document.addEventListener('keydown', this.onDocumentKeydown);
-		window.addEventListener('scroll', this.onScroll);
 
 		this.fetch(() => this.$emit('loaded'));
 	},
 	beforeDestroy() {
 		document.removeEventListener('keydown', this.onDocumentKeydown);
-		window.removeEventListener('scroll', this.onScroll);
 	},
 	methods: {
 		onDocumentKeydown(e) {
@@ -61,36 +53,43 @@ export default Vue.extend({
 			}
 		},
 		fetch(cb?) {
-			(this as any).api('users/notes', {
-				userId: this.user.id,
-				untilDate: this.date ? this.date.getTime() : undefined,
-				includeReplies: this.mode == 'with-replies',
-				withMedia: this.mode == 'with-media'
-			}).then(notes => {
-				this.notes = notes;
-				this.fetching = false;
-				if (cb) cb();
-			});
+			this.fetching = true;
+			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+				(this as any).api('users/notes', {
+					userId: this.user.id,
+					limit: fetchLimit + 1,
+					untilDate: this.date ? this.date.getTime() : undefined,
+					includeReplies: this.mode == 'with-replies',
+					withMedia: this.mode == 'with-media'
+				}).then(notes => {
+					if (notes.length == fetchLimit + 1) {
+						notes.pop();
+						this.existMore = true;
+					}
+					res(notes);
+					this.fetching = false;
+					if (cb) cb();
+				}, rej);
+			}));
 		},
 		more() {
-			if (this.moreFetching || this.fetching || this.notes.length == 0) return;
 			this.moreFetching = true;
 			(this as any).api('users/notes', {
 				userId: this.user.id,
+				limit: fetchLimit + 1,
 				includeReplies: this.mode == 'with-replies',
 				withMedia: this.mode == 'with-media',
-				untilId: this.notes[this.notes.length - 1].id
+				untilId: (this.$refs.timeline as any).tail().id
 			}).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;
-				this.notes = this.notes.concat(notes);
 			});
 		},
-		onScroll() {
-			const current = window.scrollY + window.innerHeight;
-			if (current > document.body.offsetHeight - 16/*遊び*/) {
-				this.more();
-			}
-		},
 		warp(date) {
 			this.date = date;
 			this.fetch();

From bc8a0083e262c724ef4d803a4f2a9f076377341c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 11:46:42 +0900
Subject: [PATCH 19/25] wip

---
 package.json                                  |   1 +
 .../app/mobile/views/components/notes.vue     | 147 +++++++++++++++++-
 .../app/mobile/views/components/timeline.vue  | 117 +++-----------
 .../mobile/views/components/user-timeline.vue |  55 ++++---
 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

From 6ab0c386cb5eca185d5ed2558ef54ee0225153b9 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 14:01:41 +0900
Subject: [PATCH 20/25] wip

---
 .../views/components/timeline.core.vue        |  21 +-
 .../app/mobile/views/components/index.ts      |   2 -
 .../app/mobile/views/components/timeline.vue  | 119 --------
 .../app/mobile/views/pages/dashboard.vue      | 196 ++++++++++++++
 .../app/mobile/views/pages/home.timeline.vue  | 166 ++++++++++++
 src/client/app/mobile/views/pages/home.vue    | 254 ++++--------------
 6 files changed, 432 insertions(+), 326 deletions(-)
 delete mode 100644 src/client/app/mobile/views/components/timeline.vue
 create mode 100644 src/client/app/mobile/views/pages/dashboard.vue
 create mode 100644 src/client/app/mobile/views/pages/home.timeline.vue

diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue
index f5e0ee118e..a137a57070 100644
--- a/src/client/app/desktop/views/components/timeline.core.vue
+++ b/src/client/app/desktop/views/components/timeline.core.vue
@@ -15,6 +15,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
 
 const fetchLimit = 10;
 
@@ -33,6 +34,7 @@ export default Vue.extend({
 			existMore: false,
 			connection: null,
 			connectionId: null,
+			unreadCount: 0,
 			date: null
 		};
 	},
@@ -74,6 +76,7 @@ export default Vue.extend({
 		}
 
 		document.addEventListener('keydown', this.onKeydown);
+		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
 
 		this.fetch();
 	},
@@ -87,10 +90,11 @@ export default Vue.extend({
 		this.stream.dispose(this.connectionId);
 
 		document.removeEventListener('keydown', this.onKeydown);
+		document.removeEventListener('visibilitychange', this.onVisibilitychange);
 	},
 
 	methods: {
-		fetch(cb?) {
+		fetch() {
 			this.fetching = true;
 
 			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
@@ -107,7 +111,6 @@ export default Vue.extend({
 					res(notes);
 					this.fetching = false;
 					this.$emit('loaded');
-					if (cb) cb();
 				}, rej);
 			}));
 		},
@@ -134,6 +137,11 @@ export default Vue.extend({
 		},
 
 		onNote(note) {
+			if (document.hidden && note.userId !== (this as any).os.i.id) {
+				this.unreadCount++;
+				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+			}
+
 			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
@@ -151,13 +159,20 @@ export default Vue.extend({
 			this.fetch();
 		},
 
+		onVisibilitychange() {
+			if (!document.hidden) {
+				this.unreadCount = 0;
+				document.title = 'Misskey';
+			}
+		},
+
 		onKeydown(e) {
 			if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
 				if (e.which == 84) { // t
 					this.focus();
 				}
 			}
-		},
+		}
 	}
 });
 </script>
diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts
index 9346700304..9a0a52d106 100644
--- a/src/client/app/mobile/views/components/index.ts
+++ b/src/client/app/mobile/views/components/index.ts
@@ -1,7 +1,6 @@
 import Vue from 'vue';
 
 import ui from './ui.vue';
-import timeline from './timeline.vue';
 import note from './note.vue';
 import notes from './notes.vue';
 import mediaImage from './media-image.vue';
@@ -24,7 +23,6 @@ import activity from './activity.vue';
 import widgetContainer from './widget-container.vue';
 
 Vue.component('mk-ui', ui);
-Vue.component('mk-timeline', timeline);
 Vue.component('mk-note', note);
 Vue.component('mk-notes', notes);
 Vue.component('mk-media-image', mediaImage);
diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue
deleted file mode 100644
index 561034917e..0000000000
--- a/src/client/app/mobile/views/components/timeline.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-<template>
-<div class="mk-timeline">
-	<mk-friends-maker v-if="alone"/>
-
-	<mk-notes ref="timeline" :more="existMore ? more : null">
-		<div slot="empty">
-			%fa:R comments%
-			%i18n:@empty%
-		</div>
-	</mk-notes>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-const fetchLimit = 10;
-
-export default Vue.extend({
-	props: {
-		date: {
-			type: Date,
-			required: false,
-			default: null
-		}
-	},
-
-	data() {
-		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null,
-			connectionId: null
-		};
-	},
-
-	computed: {
-		alone(): boolean {
-			return (this as any).os.i.followingCount == 0;
-		}
-	},
-
-	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
-
-		this.connection.on('note', this.onNote);
-		this.connection.on('follow', this.onChangeFollowing);
-		this.connection.on('unfollow', this.onChangeFollowing);
-
-		this.fetch();
-	},
-
-	beforeDestroy() {
-		this.connection.off('note', this.onNote);
-		this.connection.off('follow', this.onChangeFollowing);
-		this.connection.off('unfollow', this.onChangeFollowing);
-		(this as any).os.stream.dispose(this.connectionId);
-	},
-
-	methods: {
-		fetch(cb?) {
-			this.fetching = true;
-			(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.$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;
-			});
-		},
-
-		onNote(note) {
-			// Prepend a note
-			(this.$refs.timeline as any).prepend(note);
-		},
-
-		onChangeFollowing() {
-			this.fetch();
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-@import '~const.styl'
-
-.mk-timeline
-	> .mk-friends-maker
-		margin-bottom 8px
-</style>
diff --git a/src/client/app/mobile/views/pages/dashboard.vue b/src/client/app/mobile/views/pages/dashboard.vue
new file mode 100644
index 0000000000..14779da650
--- /dev/null
+++ b/src/client/app/mobile/views/pages/dashboard.vue
@@ -0,0 +1,196 @@
+<template>
+<mk-ui>
+	<span slot="header">%fa:home%ダッシュボード</span>
+	<template slot="func">
+		<button @click="customizing = !customizing">%fa:cog%</button>
+	</template>
+	<main>
+		<template v-if="customizing">
+			<header>
+				<select v-model="widgetAdderSelected">
+					<option value="profile">プロフィール</option>
+					<option value="calendar">カレンダー</option>
+					<option value="activity">アクティビティ</option>
+					<option value="rss">RSSリーダー</option>
+					<option value="photo-stream">フォトストリーム</option>
+					<option value="slideshow">スライドショー</option>
+					<option value="version">バージョン</option>
+					<option value="access-log">アクセスログ</option>
+					<option value="server">サーバー情報</option>
+					<option value="donation">寄付のお願い</option>
+					<option value="nav">ナビゲーション</option>
+					<option value="tips">ヒント</option>
+				</select>
+				<button @click="addWidget">追加</button>
+				<p><a @click="hint">カスタマイズのヒント</a></p>
+			</header>
+			<x-draggable
+				:list="widgets"
+				:options="{ handle: '.handle', animation: 150 }"
+				@sort="onWidgetSort"
+			>
+				<div v-for="widget in widgets" class="customize-container" :key="widget.id">
+					<header>
+						<span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
+					</header>
+					<div @click="widgetFunc(widget.id)">
+						<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/>
+					</div>
+				</div>
+			</x-draggable>
+		</template>
+		<template v-else>
+			<component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
+		</template>
+	</main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import * as uuid from 'uuid';
+
+export default Vue.extend({
+	components: {
+		XDraggable
+	},
+	data() {
+		return {
+			showNav: false,
+			widgets: [],
+			customizing: false,
+			widgetAdderSelected: null
+		};
+	},
+	created() {
+		if ((this as any).os.i.clientSettings.mobileHome == null) {
+			Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{
+				name: 'calendar',
+				id: 'a', data: {}
+			}, {
+				name: 'activity',
+				id: 'b', data: {}
+			}, {
+				name: 'rss',
+				id: 'c', data: {}
+			}, {
+				name: 'photo-stream',
+				id: 'd', data: {}
+			}, {
+				name: 'donation',
+				id: 'e', data: {}
+			}, {
+				name: 'nav',
+				id: 'f', data: {}
+			}, {
+				name: 'version',
+				id: 'g', data: {}
+			}]);
+			this.widgets = (this as any).os.i.clientSettings.mobileHome;
+			this.saveHome();
+		} else {
+			this.widgets = (this as any).os.i.clientSettings.mobileHome;
+		}
+
+		this.$watch('os.i.clientSettings', i => {
+			this.widgets = (this as any).os.i.clientSettings.mobileHome;
+		}, {
+			deep: true
+		});
+	},
+
+	mounted() {
+		document.title = 'Misskey';
+		document.documentElement.style.background = '#313a42';
+	},
+
+	methods: {
+		onHomeUpdated(data) {
+			if (data.home) {
+				(this as any).os.i.clientSettings.mobileHome = data.home;
+				this.widgets = data.home;
+			} else {
+				const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id);
+				if (w != null) {
+					w.data = data.data;
+					this.$refs[w.id][0].preventSave = true;
+					this.$refs[w.id][0].props = w.data;
+					this.widgets = (this as any).os.i.clientSettings.mobileHome;
+				}
+			}
+		},
+		hint() {
+			alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。');
+		},
+		widgetFunc(id) {
+			const w = this.$refs[id][0];
+			if (w.func) w.func();
+		},
+		onWidgetSort() {
+			this.saveHome();
+		},
+		addWidget() {
+			const widget = {
+				name: this.widgetAdderSelected,
+				id: uuid(),
+				data: {}
+			};
+
+			this.widgets.unshift(widget);
+			this.saveHome();
+		},
+		removeWidget(widget) {
+			this.widgets = this.widgets.filter(w => w.id != widget.id);
+			this.saveHome();
+		},
+		saveHome() {
+			(this as any).os.i.clientSettings.mobileHome = this.widgets;
+			(this as any).api('i/update_mobile_home', {
+				home: this.widgets
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+main
+	margin 0 auto
+	max-width 500px
+
+	@media (min-width 500px)
+		padding 8px
+
+	> header
+		padding 8px
+		background #fff
+
+	.widget
+		margin 8px
+
+	.customize-container
+		margin 8px
+		background #fff
+
+		> header
+			line-height 32px
+			background #eee
+
+			> .handle
+				padding 0 8px
+
+			> .remove
+				position absolute
+				top 0
+				right 0
+				padding 0 8px
+				line-height 32px
+
+		> div
+			padding 8px
+
+			> *
+				pointer-events none
+
+</style>
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
new file mode 100644
index 0000000000..42ed454d22
--- /dev/null
+++ b/src/client/app/mobile/views/pages/home.timeline.vue
@@ -0,0 +1,166 @@
+<template>
+<div>
+	<mk-friends-maker v-if="src == 'home' && alone" style="margin-bottom:8px"/>
+
+	<mk-notes ref="timeline" :more="existMore ? more : null">
+		<div slot="empty">
+			%fa:R comments%
+			%i18n:@empty%
+		</div>
+	</mk-notes>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	props: {
+		src: {
+			type: String,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null,
+			connectionId: null,
+			unreadCount: 0,
+			date: null
+		};
+	},
+
+	computed: {
+		alone(): boolean {
+			return (this as any).os.i.followingCount == 0;
+		},
+
+		stream(): any {
+			return this.src == 'home'
+				? (this as any).os.stream
+				: this.src == 'local'
+					? (this as any).os.streams.localTimelineStream
+					: (this as any).os.streams.globalTimelineStream;
+		},
+
+		endpoint(): string {
+			return this.src == 'home'
+				? 'notes/timeline'
+				: this.src == 'local'
+					? 'notes/local-timeline'
+					: 'notes/global-timeline';
+		},
+
+		canFetchMore(): boolean {
+			return !this.moreFetching && !this.fetching && this.existMore;
+		}
+	},
+
+	mounted() {
+		this.connection = this.stream.getConnection();
+		this.connectionId = this.stream.use();
+
+		this.connection.on('note', this.onNote);
+		if (this.src == 'home') {
+			this.connection.on('follow', this.onChangeFollowing);
+			this.connection.on('unfollow', this.onChangeFollowing);
+		}
+
+		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+
+		this.fetch();
+	},
+
+	beforeDestroy() {
+		this.connection.off('note', this.onNote);
+		if (this.src == 'home') {
+			this.connection.off('follow', this.onChangeFollowing);
+			this.connection.off('unfollow', this.onChangeFollowing);
+		}
+		this.stream.dispose(this.connectionId);
+
+		document.removeEventListener('visibilitychange', this.onVisibilitychange);
+	},
+
+	methods: {
+		fetch() {
+			this.fetching = true;
+
+			(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');
+				}, rej);
+			}));
+		},
+
+		more() {
+			if (!this.canFetchMore) return;
+
+			this.moreFetching = true;
+
+			(this as any).api(this.endpoint, {
+				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;
+			});
+		},
+
+		onNote(note) {
+			if (document.hidden && note.userId !== (this as any).os.i.id) {
+				this.unreadCount++;
+				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+			}
+
+			// Prepend a note
+			(this.$refs.timeline as any).prepend(note);
+		},
+
+		onChangeFollowing() {
+			this.fetch();
+		},
+
+		focus() {
+			(this.$refs.timeline as any).focus();
+		},
+
+		warp(date) {
+			this.date = date;
+			this.fetch();
+		},
+
+		onVisibilitychange() {
+			if (!document.hidden) {
+				this.unreadCount = 0;
+				document.title = 'Misskey';
+			}
+		}
+	}
+});
+</script>
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index 3d94dd7ce6..3b152b3952 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -1,59 +1,37 @@
 <template>
 <mk-ui>
-	<span slot="header" @click="showTl = !showTl">
-		<template v-if="showTl">%fa:home%%i18n:@timeline%</template>
-		<template v-else>%fa:home%ウィジェット</template>
+	<span slot="header" @click="showNav = true">
+		<span>
+			<span v-if="src == 'home'">%fa:home%ホーム</span>
+			<span v-if="src == 'local'">%fa:R comments%ローカル</span>
+			<span v-if="src == 'global'">%fa:globe%グローバル</span>
+			<span v-if="src == 'list'">%fa:list%{{ list.title }}</span>
+		</span>
 		<span style="margin-left:8px">
-			<template v-if="showTl">%fa:angle-down%</template>
+			<template v-if="!showNav">%fa:angle-down%</template>
 			<template v-else>%fa:angle-up%</template>
 		</span>
 	</span>
+
 	<template slot="func">
-		<button @click="fn" v-if="showTl">%fa:pencil-alt%</button>
-		<button @click="customizing = !customizing" v-else>%fa:cog%</button>
+		<button @click="fn">%fa:pencil-alt%</button>
 	</template>
+
 	<main>
-		<div class="tl">
-			<mk-timeline @loaded="onLoaded" v-show="showTl"/>
+		<div class="nav" v-if="showNav">
+			<div class="bg" @click="showNav = false"></div>
+			<div class="body">
+				<span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
+				<span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
+				<span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+			</div>
 		</div>
-		<div class="widgets" v-show="!showTl">
-			<template v-if="customizing">
-				<header>
-					<select v-model="widgetAdderSelected">
-						<option value="profile">プロフィール</option>
-						<option value="calendar">カレンダー</option>
-						<option value="activity">アクティビティ</option>
-						<option value="rss">RSSリーダー</option>
-						<option value="photo-stream">フォトストリーム</option>
-						<option value="slideshow">スライドショー</option>
-						<option value="version">バージョン</option>
-						<option value="access-log">アクセスログ</option>
-						<option value="server">サーバー情報</option>
-						<option value="donation">寄付のお願い</option>
-						<option value="nav">ナビゲーション</option>
-						<option value="tips">ヒント</option>
-					</select>
-					<button @click="addWidget">追加</button>
-					<p><a @click="hint">カスタマイズのヒント</a></p>
-				</header>
-				<x-draggable
-					:list="widgets"
-					:options="{ handle: '.handle', animation: 150 }"
-					@sort="onWidgetSort"
-				>
-					<div v-for="widget in widgets" class="customize-container" :key="widget.id">
-						<header>
-							<span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
-						</header>
-						<div @click="widgetFunc(widget.id)">
-							<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/>
-						</div>
-					</div>
-				</x-draggable>
-			</template>
-			<template v-else>
-				<component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
-			</template>
+
+		<div class="tl">
+			<x-tl v-if="src == 'home'" ref="tl" key="home" src="home" @loaded="onLoaded"/>
+			<x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/>
+			<x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/>
+			<mk-user-list-timeline v-if="src == 'list'" ref="tl" key="list" :list="list"/>
 		</div>
 	</main>
 </mk-ui>
@@ -61,144 +39,38 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import * as XDraggable from 'vuedraggable';
-import * as uuid from 'uuid';
 import Progress from '../../../common/scripts/loading';
-import getNoteSummary from '../../../../../renderers/get-note-summary';
+import XTl from './home.timeline.vue';
 
 export default Vue.extend({
 	components: {
-		XDraggable
+		XTl
 	},
+
 	data() {
 		return {
-			connection: null,
-			connectionId: null,
-			unreadCount: 0,
-			showTl: true,
-			widgets: [],
-			customizing: false,
-			widgetAdderSelected: null
+			src: 'home',
+			list: null,
+			showNav: false
 		};
 	},
-	created() {
-		if ((this as any).os.i.clientSettings.mobileHome == null) {
-			Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{
-				name: 'calendar',
-				id: 'a', data: {}
-			}, {
-				name: 'activity',
-				id: 'b', data: {}
-			}, {
-				name: 'rss',
-				id: 'c', data: {}
-			}, {
-				name: 'photo-stream',
-				id: 'd', data: {}
-			}, {
-				name: 'donation',
-				id: 'e', data: {}
-			}, {
-				name: 'nav',
-				id: 'f', data: {}
-			}, {
-				name: 'version',
-				id: 'g', data: {}
-			}]);
-			this.widgets = (this as any).os.i.clientSettings.mobileHome;
-			this.saveHome();
-		} else {
-			this.widgets = (this as any).os.i.clientSettings.mobileHome;
-		}
 
-		this.$watch('os.i.clientSettings', i => {
-			this.widgets = (this as any).os.i.clientSettings.mobileHome;
-		}, {
-			deep: true
-		});
-	},
 	mounted() {
 		document.title = 'Misskey';
 		document.documentElement.style.background = '#313a42';
 
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
-
-		this.connection.on('note', this.onStreamNote);
-		this.connection.on('mobile_home_updated', this.onHomeUpdated);
-		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
-
 		Progress.start();
 	},
-	beforeDestroy() {
-		this.connection.off('note', this.onStreamNote);
-		this.connection.off('mobile_home_updated', this.onHomeUpdated);
-		(this as any).os.stream.dispose(this.connectionId);
-		document.removeEventListener('visibilitychange', this.onVisibilitychange);
-	},
+
 	methods: {
 		fn() {
 			(this as any).apis.post();
 		},
+
 		onLoaded() {
 			Progress.done();
 		},
-		onStreamNote(note) {
-			if (document.hidden && note.userId !== (this as any).os.i.id) {
-				this.unreadCount++;
-				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
-			}
-		},
-		onVisibilitychange() {
-			if (!document.hidden) {
-				this.unreadCount = 0;
-				document.title = 'Misskey';
-			}
-		},
-		onHomeUpdated(data) {
-			if (data.home) {
-				(this as any).os.i.clientSettings.mobileHome = data.home;
-				this.widgets = data.home;
-			} else {
-				const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id);
-				if (w != null) {
-					w.data = data.data;
-					this.$refs[w.id][0].preventSave = true;
-					this.$refs[w.id][0].props = w.data;
-					this.widgets = (this as any).os.i.clientSettings.mobileHome;
-				}
-			}
-		},
-		hint() {
-			alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。');
-		},
-		widgetFunc(id) {
-			const w = this.$refs[id][0];
-			if (w.func) w.func();
-		},
-		onWidgetSort() {
-			this.saveHome();
-		},
-		addWidget() {
-			const widget = {
-				name: this.widgetAdderSelected,
-				id: uuid(),
-				data: {}
-			};
 
-			this.widgets.unshift(widget);
-			this.saveHome();
-		},
-		removeWidget(widget) {
-			this.widgets = this.widgets.filter(w => w.id != widget.id);
-			this.saveHome();
-		},
-		saveHome() {
-			(this as any).os.i.clientSettings.mobileHome = this.widgets;
-			(this as any).api('i/update_mobile_home', {
-				home: this.widgets
-			});
-		},
 		warp() {
 
 		}
@@ -208,52 +80,30 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 main
+	> .nav
+		> .bg
+			position fixed
+			z-index 10000
+			top 0
+			left 0
+			width 100%
+			height 100%
+			background rgba(#000, 0.5)
+
+		> .body
+			position fixed
+			z-index 10001
+			top 48px
+			left 0
+			background #fff
+			border-radius 8px
 
 	> .tl
-		> .mk-timeline
-			max-width 600px
-			margin 0 auto
-			padding 8px
-
-			@media (min-width 500px)
-				padding 16px
-
-	> .widgets
+		max-width 600px
 		margin 0 auto
-		max-width 500px
+		padding 8px
 
 		@media (min-width 500px)
-			padding 8px
-
-		> header
-			padding 8px
-			background #fff
-
-		.widget
-			margin 8px
-
-		.customize-container
-			margin 8px
-			background #fff
-
-			> header
-				line-height 32px
-				background #eee
-
-				> .handle
-					padding 0 8px
-
-				> .remove
-					position absolute
-					top 0
-					right 0
-					padding 0 8px
-					line-height 32px
-
-			> div
-				padding 8px
-
-				> *
-					pointer-events none
+			padding 16px
 
 </style>

From 03d09e9d240712a54453de063741cfbe7612709c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 14:38:37 +0900
Subject: [PATCH 21/25] wip

---
 .../app/desktop/views/components/mentions.vue |  6 +-
 .../app/desktop/views/components/notes.vue    | 22 +++++
 .../app/desktop/views/components/timeline.vue | 12 +--
 .../views/components/user-list-timeline.vue   |  2 +-
 .../desktop/views/components/users-list.vue   |  6 +-
 .../views/pages/user/user.timeline.vue        |  8 +-
 .../app/mobile/views/components/index.ts      |  2 +
 .../app/mobile/views/components/notes.vue     | 22 +++++
 .../views/components/user-list-timeline.vue   | 93 +++++++++++++++++++
 .../mobile/views/components/users-list.vue    |  6 +-
 .../app/mobile/views/pages/home.timeline.vue  | 17 ----
 src/client/app/mobile/views/pages/home.vue    | 76 +++++++++++++--
 src/client/app/mobile/views/pages/user.vue    |  8 +-
 13 files changed, 233 insertions(+), 47 deletions(-)
 create mode 100644 src/client/app/mobile/views/components/user-list-timeline.vue

diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue
index fc3a7af75d..53d08a0eca 100644
--- a/src/client/app/desktop/views/components/mentions.vue
+++ b/src/client/app/desktop/views/components/mentions.vue
@@ -1,8 +1,8 @@
 <template>
 <div class="mk-mentions">
 	<header>
-		<span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span>
-		<span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span>
+		<span :data-active="mode == 'all'" @click="mode = 'all'">すべて</span>
+		<span :data-active="mode == 'following'" @click="mode = 'following'">フォロー中</span>
 	</header>
 	<div class="fetching" v-if="fetching">
 		<mk-ellipsis-icon/>
@@ -98,7 +98,7 @@ export default Vue.extend({
 			font-size 18px
 			color #555
 
-			&:not([data-is-active])
+			&:not([data-active])
 				color $theme-color
 				cursor pointer
 
diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index 01e1f5c2f0..fa7a782b7b 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -31,6 +31,7 @@
 <script lang="ts">
 import Vue from 'vue';
 import { url } from '../../../config';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
 
 import XNote from './notes.note.vue';
 
@@ -53,6 +54,7 @@ export default Vue.extend({
 			requestInitPromise: null as () => Promise<any[]>,
 			notes: [],
 			queue: [],
+			unreadCount: 0,
 			fetching: true,
 			moreFetching: false
 		};
@@ -71,10 +73,12 @@ export default Vue.extend({
 	},
 
 	mounted() {
+		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
 		window.addEventListener('scroll', this.onScroll);
 	},
 
 	beforeDestroy() {
+		document.removeEventListener('visibilitychange', this.onVisibilitychange);
 		window.removeEventListener('scroll', this.onScroll);
 	},
 
@@ -130,6 +134,12 @@ export default Vue.extend({
 			}
 			//#endregion
 
+			// 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+			if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) {
+				this.unreadCount++;
+				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+			}
+
 			if (this.isScrollTop()) {
 				// Prepend the note
 				this.notes.unshift(note);
@@ -172,9 +182,21 @@ export default Vue.extend({
 			this.moreFetching = false;
 		},
 
+		clearNotification() {
+			this.unreadCount = 0;
+			document.title = 'Misskey';
+		},
+
+		onVisibilitychange() {
+			if (!document.hidden) {
+				this.clearNotification();
+			}
+		},
+
 		onScroll() {
 			if (this.isScrollTop()) {
 				this.releaseQueue();
+				this.clearNotification();
 			}
 
 			if ((this as any).os.i.clientSettings.fetchOnScroll !== false) {
diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index d50b41b846..f5f13cbd56 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -1,10 +1,10 @@
 <template>
 <div class="mk-timeline">
 	<header>
-		<span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
-		<span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
-		<span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
-		<span :data-is-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
+		<span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
+		<span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
+		<span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+		<span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
 		<button @click="chooseList" title="リスト">%fa:list%</button>
 	</header>
 	<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
@@ -93,7 +93,7 @@ root(isDark)
 			font-size 12px
 			user-select none
 
-			&[data-is-active]
+			&[data-active]
 				color $theme-color
 				cursor default
 				font-weight bold
@@ -108,7 +108,7 @@ root(isDark)
 					height 2px
 					background $theme-color
 
-			&:not([data-is-active])
+			&:not([data-active])
 				color isDark ? #9aa2a7 : #6f7477
 				cursor pointer
 
diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue
index 8a1814f99c..ee983a969c 100644
--- a/src/client/app/desktop/views/components/user-list-timeline.vue
+++ b/src/client/app/desktop/views/components/user-list-timeline.vue
@@ -87,7 +87,7 @@ export default Vue.extend({
 		},
 		onUserRemoved() {
 			this.fetch();
-		},
+		}
 	}
 });
 </script>
diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue
index a08e76f573..e8f4c94d42 100644
--- a/src/client/app/desktop/views/components/users-list.vue
+++ b/src/client/app/desktop/views/components/users-list.vue
@@ -2,8 +2,8 @@
 <div class="mk-users-list">
 	<nav>
 		<div>
-			<span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span>
-			<span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span>
+			<span :data-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span>
+			<span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span>
 		</div>
 	</nav>
 	<div class="users" v-if="!fetching && users.length != 0">
@@ -98,7 +98,7 @@ export default Vue.extend({
 				*
 					pointer-events none
 
-				&[data-is-active]
+				&[data-active]
 					font-weight bold
 					color $theme-color
 					border-color $theme-color
diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue
index 754be8c04f..9c9840c190 100644
--- a/src/client/app/desktop/views/pages/user/user.timeline.vue
+++ b/src/client/app/desktop/views/pages/user/user.timeline.vue
@@ -1,9 +1,9 @@
 <template>
 <div class="timeline">
 	<header>
-		<span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span>
-		<span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span>
-		<span :data-is-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span>
+		<span :data-active="mode == 'default'" @click="mode = 'default'">投稿</span>
+		<span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span>
+		<span :data-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span>
 	</header>
 	<div class="loading" v-if="fetching">
 		<mk-ellipsis-icon/>
@@ -114,7 +114,7 @@ export default Vue.extend({
 			font-size 18px
 			color #555
 
-			&:not([data-is-active])
+			&:not([data-active])
 				color $theme-color
 				cursor pointer
 
diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts
index 9a0a52d106..5ed8427b05 100644
--- a/src/client/app/mobile/views/components/index.ts
+++ b/src/client/app/mobile/views/components/index.ts
@@ -19,6 +19,7 @@ import notificationPreview from './notification-preview.vue';
 import usersList from './users-list.vue';
 import userPreview from './user-preview.vue';
 import userTimeline from './user-timeline.vue';
+import userListTimeline from './user-list-timeline.vue';
 import activity from './activity.vue';
 import widgetContainer from './widget-container.vue';
 
@@ -41,5 +42,6 @@ Vue.component('mk-notification-preview', notificationPreview);
 Vue.component('mk-users-list', usersList);
 Vue.component('mk-user-preview', userPreview);
 Vue.component('mk-user-timeline', userTimeline);
+Vue.component('mk-user-list-timeline', userListTimeline);
 Vue.component('mk-activity', activity);
 Vue.component('mk-widget-container', widgetContainer);
diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue
index 137e15c6de..703b51d678 100644
--- a/src/client/app/mobile/views/components/notes.vue
+++ b/src/client/app/mobile/views/components/notes.vue
@@ -36,6 +36,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
 
 const displayLimit = 30;
 
@@ -52,6 +53,7 @@ export default Vue.extend({
 			requestInitPromise: null as () => Promise<any[]>,
 			notes: [],
 			queue: [],
+			unreadCount: 0,
 			fetching: true,
 			moreFetching: false
 		};
@@ -70,10 +72,12 @@ export default Vue.extend({
 	},
 
 	mounted() {
+		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
 		window.addEventListener('scroll', this.onScroll);
 	},
 
 	beforeDestroy() {
+		document.removeEventListener('visibilitychange', this.onVisibilitychange);
 		window.removeEventListener('scroll', this.onScroll);
 	},
 
@@ -125,6 +129,12 @@ export default Vue.extend({
 			}
 			//#endregion
 
+			// 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+			if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) {
+				this.unreadCount++;
+				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
+			}
+
 			if (this.isScrollTop()) {
 				// Prepend the note
 				this.notes.unshift(note);
@@ -160,9 +170,21 @@ export default Vue.extend({
 			this.moreFetching = false;
 		},
 
+		clearNotification() {
+			this.unreadCount = 0;
+			document.title = 'Misskey';
+		},
+
+		onVisibilitychange() {
+			if (!document.hidden) {
+				this.clearNotification();
+			}
+		},
+
 		onScroll() {
 			if (this.isScrollTop()) {
 				this.releaseQueue();
+				this.clearNotification();
 			}
 
 			if ((this as any).os.i.clientSettings.fetchOnScroll !== false) {
diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue
new file mode 100644
index 0000000000..ee983a969c
--- /dev/null
+++ b/src/client/app/mobile/views/components/user-list-timeline.vue
@@ -0,0 +1,93 @@
+<template>
+<div>
+	<mk-notes ref="timeline" :more="existMore ? more : null"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { UserListStream } from '../../../common/scripts/streaming/user-list';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	props: ['list'],
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null
+		};
+	},
+	watch: {
+		$route: 'init'
+	},
+	mounted() {
+		this.init();
+	},
+	beforeDestroy() {
+		this.connection.close();
+	},
+	methods: {
+		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.$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,
+				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;
+			});
+		},
+		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/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue
index 8fa7a9cbe6..67a38a8955 100644
--- a/src/client/app/mobile/views/components/users-list.vue
+++ b/src/client/app/mobile/views/components/users-list.vue
@@ -1,8 +1,8 @@
 <template>
 <div class="mk-users-list">
 	<nav>
-		<span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span>
-		<span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span>
+		<span :data-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span>
+		<span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span>
 	</nav>
 	<div class="users" v-if="!fetching && users.length != 0">
 		<mk-user-preview v-for="u in users" :user="u" :key="u.id"/>
@@ -85,7 +85,7 @@ export default Vue.extend({
 			color #657786
 			border-bottom solid 2px transparent
 
-			&[data-is-active]
+			&[data-active]
 				font-weight bold
 				color $theme-color
 				border-color $theme-color
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
index 42ed454d22..5f4bd6dcd8 100644
--- a/src/client/app/mobile/views/pages/home.timeline.vue
+++ b/src/client/app/mobile/views/pages/home.timeline.vue
@@ -13,7 +13,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import getNoteSummary from '../../../../../renderers/get-note-summary';
 
 const fetchLimit = 10;
 
@@ -73,8 +72,6 @@ export default Vue.extend({
 			this.connection.on('unfollow', this.onChangeFollowing);
 		}
 
-		document.addEventListener('visibilitychange', this.onVisibilitychange, false);
-
 		this.fetch();
 	},
 
@@ -85,8 +82,6 @@ export default Vue.extend({
 			this.connection.off('unfollow', this.onChangeFollowing);
 		}
 		this.stream.dispose(this.connectionId);
-
-		document.removeEventListener('visibilitychange', this.onVisibilitychange);
 	},
 
 	methods: {
@@ -133,11 +128,6 @@ export default Vue.extend({
 		},
 
 		onNote(note) {
-			if (document.hidden && note.userId !== (this as any).os.i.id) {
-				this.unreadCount++;
-				document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`;
-			}
-
 			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
@@ -153,13 +143,6 @@ export default Vue.extend({
 		warp(date) {
 			this.date = date;
 			this.fetch();
-		},
-
-		onVisibilitychange() {
-			if (!document.hidden) {
-				this.unreadCount = 0;
-				document.title = 'Misskey';
-			}
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index 3b152b3952..92d34fa83b 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -5,7 +5,7 @@
 			<span v-if="src == 'home'">%fa:home%ホーム</span>
 			<span v-if="src == 'local'">%fa:R comments%ローカル</span>
 			<span v-if="src == 'global'">%fa:globe%グローバル</span>
-			<span v-if="src == 'list'">%fa:list%{{ list.title }}</span>
+			<span v-if="src.startsWith('list')">%fa:list%{{ list.title }}</span>
 		</span>
 		<span style="margin-left:8px">
 			<template v-if="!showNav">%fa:angle-down%</template>
@@ -21,9 +21,14 @@
 		<div class="nav" v-if="showNav">
 			<div class="bg" @click="showNav = false"></div>
 			<div class="body">
-				<span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
-				<span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
-				<span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+				<div>
+					<span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span>
+					<span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span>
+					<span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span>
+					<template v-if="lists">
+						<span v-for="l in lists" :data-active="src == 'list:' + l.id" @click="src = 'list:' + l.id; list = l" :key="l.id">%fa:list% {{ l.title }}</span>
+					</template>
+				</div>
 			</div>
 		</div>
 
@@ -31,7 +36,7 @@
 			<x-tl v-if="src == 'home'" ref="tl" key="home" src="home" @loaded="onLoaded"/>
 			<x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/>
 			<x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/>
-			<mk-user-list-timeline v-if="src == 'list'" ref="tl" key="list" :list="list"/>
+			<mk-user-list-timeline v-if="src.startsWith('list:')" ref="tl" key="list" :list="list"/>
 		</div>
 	</main>
 </mk-ui>
@@ -51,10 +56,25 @@ export default Vue.extend({
 		return {
 			src: 'home',
 			list: null,
+			lists: null,
 			showNav: false
 		};
 	},
 
+	watch: {
+		src() {
+			this.showNav = false;
+		},
+
+		showNav(v) {
+			if (v && this.lists === null) {
+				(this as any).api('users/lists/list').then(lists => {
+					this.lists = lists;
+				});
+			}
+		}
+	},
+
 	mounted() {
 		document.title = 'Misskey';
 		document.documentElement.style.background = '#313a42';
@@ -79,6 +99,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
+@import '~const.styl'
+
 main
 	> .nav
 		> .bg
@@ -93,10 +115,52 @@ main
 		> .body
 			position fixed
 			z-index 10001
-			top 48px
+			top 56px
 			left 0
+			right 0
+			width 300px
+			margin 0 auto
 			background #fff
 			border-radius 8px
+			box-shadow 0 0 16px rgba(0, 0, 0, 0.1)
+
+			$balloon-size = 16px
+
+			&:before
+				content ""
+				display block
+				position absolute
+				top -($balloon-size * 2)
+				left s('calc(50% - %s)', $balloon-size)
+				border-top solid $balloon-size transparent
+				border-left solid $balloon-size transparent
+				border-right solid $balloon-size transparent
+				border-bottom solid $balloon-size $border-color
+
+			&:after
+				content ""
+				display block
+				position absolute
+				top -($balloon-size * 2) + 1.5px
+				left s('calc(50% - %s)', $balloon-size)
+				border-top solid $balloon-size transparent
+				border-left solid $balloon-size transparent
+				border-right solid $balloon-size transparent
+				border-bottom solid $balloon-size #fff
+
+			> div
+				padding 8px 0
+
+				> *
+					display block
+					padding 8px 16px
+
+					&[data-active]
+						color $theme-color-foreground
+						background $theme-color
+
+					&:not([data-active]):hover
+						background #eee
 
 	> .tl
 		max-width 600px
diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue
index 3ff9057f73..73b8e24315 100644
--- a/src/client/app/mobile/views/pages/user.vue
+++ b/src/client/app/mobile/views/pages/user.vue
@@ -45,9 +45,9 @@
 		</header>
 		<nav>
 			<div class="nav-container">
-				<a :data-is-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a>
-				<a :data-is-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a>
-				<a :data-is-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a>
+				<a :data-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a>
+				<a :data-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a>
+				<a :data-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a>
 			</div>
 		</nav>
 		<div class="body">
@@ -256,7 +256,7 @@ main
 				color #657786
 				border-bottom solid 2px transparent
 
-				&[data-is-active]
+				&[data-active]
 					font-weight bold
 					color $theme-color
 					border-color $theme-color

From f7c02819be17f0963a250e4dc5359242edafe558 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 14:44:23 +0900
Subject: [PATCH 22/25] Fix lint

---
 src/client/app/desktop/api/update-banner.ts | 2 +-
 src/models/sw-subscription.ts               | 1 -
 test/api.ts                                 | 1 -
 tslint.json                                 | 1 +
 4 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts
index bc3f783e35..feb1c33103 100644
--- a/src/client/app/desktop/api/update-banner.ts
+++ b/src/client/app/desktop/api/update-banner.ts
@@ -95,7 +95,7 @@ export default (os: OS) => {
 				multiple: false,
 				title: '%fa:image%バナーにする画像を選択'
 			});
-		
+
 		return selectedFile
 			.then(cropImage)
 			.then(setBanner)
diff --git a/src/models/sw-subscription.ts b/src/models/sw-subscription.ts
index 621ac8a9b6..a38edd3a50 100644
--- a/src/models/sw-subscription.ts
+++ b/src/models/sw-subscription.ts
@@ -38,4 +38,3 @@ export async function deleteSwSubscription(swSubscription: string | mongo.Object
 		_id: s._id
 	});
 }
-
diff --git a/test/api.ts b/test/api.ts
index 87bbb8ee16..b85511d802 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -15,7 +15,6 @@ process.on('unhandledRejection', console.dir);
 const fs = require('fs');
 const _chai = require('chai');
 const chaiHttp = require('chai-http');
-const should = _chai.should();
 
 _chai.use(chaiHttp);
 
diff --git a/tslint.json b/tslint.json
index d3f96000b9..ae0df46b96 100644
--- a/tslint.json
+++ b/tslint.json
@@ -5,6 +5,7 @@
 	],
 	"jsRules": {},
 	"rules": {
+		"align": false,
 		"indent": ["tab"],
 		"quotemark": ["single"],
 		"no-var-requires": false,

From 65c8b80fab9514199c22cf95d1f37bee2859a2f7 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 15:30:39 +0900
Subject: [PATCH 23/25] Fix test

---
 test/api.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/api.ts b/test/api.ts
index b85511d802..d5517f9235 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -18,7 +18,7 @@ const chaiHttp = require('chai-http');
 
 _chai.use(chaiHttp);
 
-const server = require('../built/server/api');
+const server = require('../built/server/api').callback();
 const db = require('../built/db/mongodb').default;
 
 const async = fn => (done) => {

From 013851749fdadc460f8835c41e0f2a8a9706d326 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 16:02:04 +0900
Subject: [PATCH 24/25] :v:

---
 test/api.ts | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/test/api.ts b/test/api.ts
index d5517f9235..d8c163e920 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -55,16 +55,6 @@ describe('API', () => {
 		db.get('authSessions').drop()
 	]));
 
-	it('greet server', done => {
-		_chai.request(server)
-			.get('/')
-			.end((err, res) => {
-				res.should.have.status(200);
-				res.text.should.be.equal('YEE HAW');
-				done();
-			});
-	});
-
 	describe('signup', () => {
 		it('不正なユーザー名でアカウントが作成できない', async(async () => {
 			const res = await request('/signup', {

From bb204b2ef70a3035fb3449869e90d1d53ac50c60 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 26 Apr 2018 16:10:01 +0900
Subject: [PATCH 25/25] wip

---
 .../views/components/follow-button.vue        |  6 ++-
 .../desktop/views/pages/user/user.profile.vue | 51 +++++++++++++++----
 2 files changed, 46 insertions(+), 11 deletions(-)

diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue
index 30e8cab76f..60c6129f61 100644
--- a/src/client/app/desktop/views/components/follow-button.vue
+++ b/src/client/app/desktop/views/components/follow-button.vue
@@ -19,6 +19,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+
 export default Vue.extend({
 	props: {
 		user: {
@@ -30,6 +31,7 @@ export default Vue.extend({
 			default: 'compact'
 		}
 	},
+
 	data() {
 		return {
 			wait: false,
@@ -37,6 +39,7 @@ export default Vue.extend({
 			connectionId: null
 		};
 	},
+
 	mounted() {
 		this.connection = (this as any).os.stream.getConnection();
 		this.connectionId = (this as any).os.stream.use();
@@ -44,13 +47,14 @@ export default Vue.extend({
 		this.connection.on('follow', this.onFollow);
 		this.connection.on('unfollow', this.onUnfollow);
 	},
+
 	beforeDestroy() {
 		this.connection.off('follow', this.onFollow);
 		this.connection.off('unfollow', this.onUnfollow);
 		(this as any).os.stream.dispose(this.connectionId);
 	},
-	methods: {
 
+	methods: {
 		onFollow(user) {
 			if (user.id == this.user.id) {
 				this.user.isFollowing = user.isFollowing;
diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue
index 774f300a38..64acbd86b3 100644
--- a/src/client/app/desktop/views/pages/user/user.profile.vue
+++ b/src/client/app/desktop/views/pages/user/user.profile.vue
@@ -3,15 +3,18 @@
 	<div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id">
 		<mk-follow-button :user="user" size="big"/>
 		<p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p>
-		<p class="stalk">
-			<span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%i18n:@unstalk%</a></span>
-			<span v-if="!user.isStalking"><a @click="stalk">%i18n:@stalk%</a></span>
-		</p>
-		<p class="mute">
-			<span v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></span>
-			<span v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></span>
+		<p class="stalk" v-if="user.isFollowing">
+			<span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%fa:meh% %i18n:@unstalk%</a></span>
+			<span v-if="!user.isStalking"><a @click="stalk">%fa:user-secret% %i18n:@stalk%</a></span>
 		</p>
 	</div>
+	<div class="action-form">
+		<button class="mute ui" @click="user.isMuted ? unmute() : mute()">
+			<span v-if="user.isMuted">%fa:eye% %i18n:@unmute%</span>
+			<span v-if="!user.isMuted">%fa:eye-slash% %i18n:@mute%</span>
+		</button>
+		<button class="mute ui" @click="list">%fa:list% リストに追加</button>
+	</div>
 	<div class="description" v-if="user.description">{{ user.description }}</div>
 	<div class="birthday" v-if="user.host === null && user.profile.birthday">
 		<p>%fa:birthday-cake%{{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p>
@@ -32,6 +35,7 @@ import Vue from 'vue';
 import * as age from 's-age';
 import MkFollowingWindow from '../../components/following-window.vue';
 import MkFollowersWindow from '../../components/followers-window.vue';
+import MkUserListsWindow from '../../components/user-lists-window.vue';
 
 export default Vue.extend({
 	props: ['user'],
@@ -91,6 +95,21 @@ export default Vue.extend({
 			}, () => {
 				alert('error');
 			});
+		},
+
+		list() {
+			const w = (this as any).os.new(MkUserListsWindow);
+			w.$once('choosen', async list => {
+				w.close();
+				await (this as any).api('users/lists/push', {
+					listId: list.id,
+					userId: this.user.id
+				});
+				(this as any).apis.dialog({
+					title: 'Done!',
+					text: `${this.user.name}を${list.title}に追加しました。`
+				});
+			});
 		}
 	}
 });
@@ -107,11 +126,9 @@ export default Vue.extend({
 
 	> .friend-form
 		padding 16px
+		text-align center
 		border-top solid 1px #eee
 
-		> .mk-big-follow-button
-			width 100%
-
 		> .followed
 			margin 12px 0 0 0
 			padding 0
@@ -122,6 +139,20 @@ export default Vue.extend({
 			background #eefaff
 			border-radius 4px
 
+		> .stalk
+			margin 12px 0 0 0
+
+	> .action-form
+		padding 16px
+		text-align center
+		border-top solid 1px #eee
+
+		> *
+			width 100%
+
+			&:not(:last-child)
+				margin-bottom 12px
+
 	> .description
 		padding 16px
 		color #555