From 77528f022d2e9f76298331b55303cfc42359c7af Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Mon, 30 Oct 2017 17:30:32 +0900
Subject: [PATCH 01/16] wip

---
 locales/en.yml                          |  6 ++++
 locales/ja.yml                          |  6 ++++
 src/api/endpoints/bbs/threads/create.ts | 29 ++++++++++++++++
 src/api/models/bbs-thread.ts            | 13 ++++++++
 src/api/serializers/bbs-thread.ts       | 44 +++++++++++++++++++++++++
 src/web/app/desktop/tags/index.js       |  1 +
 src/web/app/desktop/tags/pages/bbs.tag  | 30 +++++++++++++++++
 src/web/app/desktop/tags/ui.tag         | 32 +++++++++++-------
 8 files changed, 149 insertions(+), 12 deletions(-)
 create mode 100644 src/api/endpoints/bbs/threads/create.ts
 create mode 100644 src/api/models/bbs-thread.ts
 create mode 100644 src/api/serializers/bbs-thread.ts
 create mode 100644 src/web/app/desktop/tags/pages/bbs.tag

diff --git a/locales/en.yml b/locales/en.yml
index 03d5306d3..6c763886d 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -241,6 +241,7 @@ desktop:
     mk-ui-header-nav:
       home: "Home"
       messaging: "Messages"
+      bbs: "BBS"
       info: "News"
 
     mk-ui-header-search:
@@ -351,6 +352,11 @@ desktop:
     mk-repost-form-window:
       title: "Are you sure you want to repost this post?"
 
+    mk-bbs-page:
+      title: "Misskey BBS"
+      new: "Create new thread"
+      thread-title: "Thread title"
+
 mobile:
   tags:
     mk-drive-file-viewer:
diff --git a/locales/ja.yml b/locales/ja.yml
index b640f0f24..1e243fb8d 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -241,6 +241,7 @@ desktop:
     mk-ui-header-nav:
       home: "ホーム"
       messaging: "メッセージ"
+      bbs: "掲示板"
       info: "お知らせ"
 
     mk-ui-header-search:
@@ -351,6 +352,11 @@ desktop:
     mk-repost-form-window:
       title: "この投稿をRepostしますか?"
 
+    mk-bbs-page:
+      title: "Misskey掲示板"
+      new: "スレッドを作成"
+      thread-title: "スレッドのタイトル"
+
 mobile:
   tags:
     mk-drive-file-viewer:
diff --git a/src/api/endpoints/bbs/threads/create.ts b/src/api/endpoints/bbs/threads/create.ts
new file mode 100644
index 000000000..71d61d871
--- /dev/null
+++ b/src/api/endpoints/bbs/threads/create.ts
@@ -0,0 +1,29 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Thread from '../../../models/bbs-thread';
+import serialize from '../../../serializers/bbs-thread';
+
+/**
+ * Create a thread
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+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');
+
+	// Create a thread
+	const thread = await Thread.insert({
+		created_at: new Date(),
+		user_id: user._id,
+		title: title
+	});
+
+	// Response
+	res(await serialize(thread));
+});
diff --git a/src/api/models/bbs-thread.ts b/src/api/models/bbs-thread.ts
new file mode 100644
index 000000000..a92157c6f
--- /dev/null
+++ b/src/api/models/bbs-thread.ts
@@ -0,0 +1,13 @@
+import * as mongo from 'mongodb';
+import db from '../../db/mongodb';
+
+const collection = db.get('bbs_threads');
+
+export default collection as any; // fuck type definition
+
+export type IBbsThread = {
+	_id: mongo.ObjectID;
+	created_at: Date;
+	title: string;
+	user_id: mongo.ObjectID;
+};
diff --git a/src/api/serializers/bbs-thread.ts b/src/api/serializers/bbs-thread.ts
new file mode 100644
index 000000000..d9e41a846
--- /dev/null
+++ b/src/api/serializers/bbs-thread.ts
@@ -0,0 +1,44 @@
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
+import { IUser } from '../models/user';
+import { default as Thread, IBbsThread } from '../models/bbs-thread';
+
+/**
+ * Serialize a thread
+ *
+ * @param thread target
+ * @param me? serializee
+ * @return response
+ */
+export default (
+	thread: string | mongo.ObjectID | IBbsThread,
+	me?: string | mongo.ObjectID | IUser
+) => new Promise<any>(async (resolve, reject) => {
+
+	let _thread: any;
+
+	// Populate the thread if 'thread' is ID
+	if (mongo.ObjectID.prototype.isPrototypeOf(thread)) {
+		_thread = await Thread.findOne({
+			_id: thread
+		});
+	} else if (typeof thread === 'string') {
+		_thread = await Thread.findOne({
+			_id: new mongo.ObjectID(thread)
+		});
+	} else {
+		_thread = deepcopy(thread);
+	}
+
+	// Rename _id to id
+	_thread.id = _thread._id;
+	delete _thread._id;
+
+	// Remove needless properties
+	delete _thread.user_id;
+
+	resolve(_thread);
+});
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 4e286013a..fa7161ddf 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -61,6 +61,7 @@ require('./pages/user.tag');
 require('./pages/post.tag');
 require('./pages/search.tag');
 require('./pages/not-found.tag');
+require('./pages/bbs.tag');
 require('./autocomplete-suggestion.tag');
 require('./progress-dialog.tag');
 require('./user-preview.tag');
diff --git a/src/web/app/desktop/tags/pages/bbs.tag b/src/web/app/desktop/tags/pages/bbs.tag
new file mode 100644
index 000000000..cb58af193
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/bbs.tag
@@ -0,0 +1,30 @@
+<mk-bbs-page>
+	<mk-ui ref="ui">
+		<main>
+			<h1>%i18n:desktop.tags.mk-bbs-page.title%</h1>
+			<button onclick={ parent.new }>%i18n:desktop.tags.mk-bbs-page.new%</button>
+		</main>
+	</mk-ui>
+	<style>
+		:scope
+			display block
+
+	</style>
+	<script>
+		this.mixin('api');
+
+		this.on('mount', () => {
+			document.title = '%i18n:desktop.tags.mk-bbs-page.title%';
+		});
+
+		this.new = () => {
+			const title = window.prompt('%i18n:desktop.tags.mk-bbs-page.thread-title%');
+
+			this.api('bbs/threads/create', {
+				title: title
+			}).then(thread => {
+				location.href = '/bbs/' + thread.id;
+			});
+		};
+	</script>
+</mk-bbs-page>
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index e0d7393b0..452a72c00 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -319,18 +319,26 @@
 </mk-ui-header-notifications>
 
 <mk-ui-header-nav>
-	<ul if={ SIGNIN }>
-		<li class="home { active: page == 'home' }">
-			<a href={ CONFIG.url }>
-				<i class="fa fa-home"></i>
-				<p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
-			</a>
-		</li>
-		<li class="messaging">
-			<a onclick={ messaging }>
-				<i class="fa fa-comments"></i>
-				<p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
-				<i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
+	<ul>
+		<virtual if={ SIGNIN }>
+			<li class="home { active: page == 'home' }">
+				<a href={ CONFIG.url }>
+					<i class="fa fa-home"></i>
+					<p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
+				</a>
+			</li>
+			<li class="messaging">
+				<a onclick={ messaging }>
+					<i class="fa fa-comments"></i>
+					<p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
+					<i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
+				</a>
+			</li>
+		</virtual>
+		<li class="bbs">
+			<a href={ CONFIG.url + '/bbs' }>
+				<i class="fa fa-coffee"></i>
+				<p>%i18n:desktop.tags.mk-ui-header-nav.bbs%</p>
 			</a>
 		</li>
 		<li class="info">

From dc9fddf839df7959a83819eb7064f402db05f200 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 31 Oct 2017 21:42:11 +0900
Subject: [PATCH 02/16] RENAME: bbs -> channel

---
 locales/en.yml                                |  7 ++-
 locales/ja.yml                                |  7 ++-
 src/api/endpoints/bbs/threads/create.ts       | 12 ++---
 src/api/models/{bbs-thread.ts => channel.ts}  |  4 +-
 src/api/serializers/bbs-thread.ts             | 44 -------------------
 src/api/serializers/channel.ts                | 44 +++++++++++++++++++
 src/web/app/desktop/tags/index.js             |  2 +-
 .../tags/pages/{bbs.tag => channels.tag}      | 12 ++---
 8 files changed, 65 insertions(+), 67 deletions(-)
 rename src/api/models/{bbs-thread.ts => channel.ts} (75%)
 delete mode 100644 src/api/serializers/bbs-thread.ts
 create mode 100644 src/api/serializers/channel.ts
 rename src/web/app/desktop/tags/pages/{bbs.tag => channels.tag} (64%)

diff --git a/locales/en.yml b/locales/en.yml
index f0204b52c..da532fc78 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -352,10 +352,9 @@ desktop:
     mk-repost-form-window:
       title: "Are you sure you want to repost this post?"
 
-    mk-bbs-page:
-      title: "Misskey BBS"
-      new: "Create new thread"
-      thread-title: "Thread title"
+    mk-channels-page:
+      new: "Create new channel"
+      channel-title: "Channel title"
 
 mobile:
   tags:
diff --git a/locales/ja.yml b/locales/ja.yml
index 65d92782f..1ae94652b 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -352,10 +352,9 @@ desktop:
     mk-repost-form-window:
       title: "この投稿をRepostしますか?"
 
-    mk-bbs-page:
-      title: "Misskey掲示板"
-      new: "スレッドを作成"
-      thread-title: "スレッドのタイトル"
+    mk-channels-page:
+      new: "チャンネルを作成"
+      channel-title: "チャンネルのタイトル"
 
 mobile:
   tags:
diff --git a/src/api/endpoints/bbs/threads/create.ts b/src/api/endpoints/bbs/threads/create.ts
index 71d61d871..d9b4d34a0 100644
--- a/src/api/endpoints/bbs/threads/create.ts
+++ b/src/api/endpoints/bbs/threads/create.ts
@@ -2,11 +2,11 @@
  * Module dependencies
  */
 import $ from 'cafy';
-import Thread from '../../../models/bbs-thread';
-import serialize from '../../../serializers/bbs-thread';
+import Channel from '../../../models/channel';
+import serialize from '../../../serializers/channel';
 
 /**
- * Create a thread
+ * Create a channel
  *
  * @param {any} params
  * @param {any} user
@@ -17,13 +17,13 @@ module.exports = async (params, user) => new Promise(async (res, rej) => {
 	const [title, titleErr] = $(params.title).string().range(1, 100).$;
 	if (titleErr) return rej('invalid title param');
 
-	// Create a thread
-	const thread = await Thread.insert({
+	// Create a channel
+	const channel = await Channel.insert({
 		created_at: new Date(),
 		user_id: user._id,
 		title: title
 	});
 
 	// Response
-	res(await serialize(thread));
+	res(await serialize(channel));
 });
diff --git a/src/api/models/bbs-thread.ts b/src/api/models/channel.ts
similarity index 75%
rename from src/api/models/bbs-thread.ts
rename to src/api/models/channel.ts
index a92157c6f..79edb7136 100644
--- a/src/api/models/bbs-thread.ts
+++ b/src/api/models/channel.ts
@@ -1,11 +1,11 @@
 import * as mongo from 'mongodb';
 import db from '../../db/mongodb';
 
-const collection = db.get('bbs_threads');
+const collection = db.get('channels');
 
 export default collection as any; // fuck type definition
 
-export type IBbsThread = {
+export type IChannel = {
 	_id: mongo.ObjectID;
 	created_at: Date;
 	title: string;
diff --git a/src/api/serializers/bbs-thread.ts b/src/api/serializers/bbs-thread.ts
deleted file mode 100644
index d9e41a846..000000000
--- a/src/api/serializers/bbs-thread.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * Module dependencies
- */
-import * as mongo from 'mongodb';
-import deepcopy = require('deepcopy');
-import { IUser } from '../models/user';
-import { default as Thread, IBbsThread } from '../models/bbs-thread';
-
-/**
- * Serialize a thread
- *
- * @param thread target
- * @param me? serializee
- * @return response
- */
-export default (
-	thread: string | mongo.ObjectID | IBbsThread,
-	me?: string | mongo.ObjectID | IUser
-) => new Promise<any>(async (resolve, reject) => {
-
-	let _thread: any;
-
-	// Populate the thread if 'thread' is ID
-	if (mongo.ObjectID.prototype.isPrototypeOf(thread)) {
-		_thread = await Thread.findOne({
-			_id: thread
-		});
-	} else if (typeof thread === 'string') {
-		_thread = await Thread.findOne({
-			_id: new mongo.ObjectID(thread)
-		});
-	} else {
-		_thread = deepcopy(thread);
-	}
-
-	// Rename _id to id
-	_thread.id = _thread._id;
-	delete _thread._id;
-
-	// Remove needless properties
-	delete _thread.user_id;
-
-	resolve(_thread);
-});
diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts
new file mode 100644
index 000000000..d4e16d6be
--- /dev/null
+++ b/src/api/serializers/channel.ts
@@ -0,0 +1,44 @@
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
+import { IUser } from '../models/user';
+import { default as Channel, IChannel } from '../models/channel';
+
+/**
+ * Serialize a channel
+ *
+ * @param channel target
+ * @param me? serializee
+ * @return response
+ */
+export default (
+	channel: string | mongo.ObjectID | IChannel,
+	me?: string | mongo.ObjectID | IUser
+) => new Promise<any>(async (resolve, reject) => {
+
+	let _channel: any;
+
+	// Populate the channel if 'channel' is ID
+	if (mongo.ObjectID.prototype.isPrototypeOf(channel)) {
+		_channel = await Channel.findOne({
+			_id: channel
+		});
+	} else if (typeof channel === 'string') {
+		_channel = await Channel.findOne({
+			_id: new mongo.ObjectID(channel)
+		});
+	} else {
+		_channel = deepcopy(channel);
+	}
+
+	// Rename _id to id
+	_channel.id = _channel._id;
+	delete _channel._id;
+
+	// Remove needless properties
+	delete _channel.user_id;
+
+	resolve(_channel);
+});
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index fa7161ddf..6d4900652 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -61,7 +61,7 @@ require('./pages/user.tag');
 require('./pages/post.tag');
 require('./pages/search.tag');
 require('./pages/not-found.tag');
-require('./pages/bbs.tag');
+require('./pages/channels.tag');
 require('./autocomplete-suggestion.tag');
 require('./progress-dialog.tag');
 require('./user-preview.tag');
diff --git a/src/web/app/desktop/tags/pages/bbs.tag b/src/web/app/desktop/tags/pages/channels.tag
similarity index 64%
rename from src/web/app/desktop/tags/pages/bbs.tag
rename to src/web/app/desktop/tags/pages/channels.tag
index cb58af193..9e47e52d2 100644
--- a/src/web/app/desktop/tags/pages/bbs.tag
+++ b/src/web/app/desktop/tags/pages/channels.tag
@@ -1,4 +1,4 @@
-<mk-bbs-page>
+<mk-channels-page>
 	<mk-ui ref="ui">
 		<main>
 			<h1>%i18n:desktop.tags.mk-bbs-page.title%</h1>
@@ -18,13 +18,13 @@
 		});
 
 		this.new = () => {
-			const title = window.prompt('%i18n:desktop.tags.mk-bbs-page.thread-title%');
+			const title = window.prompt('%i18n:desktop.tags.mk-bbs-page.channel-title%');
 
-			this.api('bbs/threads/create', {
+			this.api('bbs/channels/create', {
 				title: title
-			}).then(thread => {
-				location.href = '/bbs/' + thread.id;
+			}).then(channel => {
+				location.href = '/bbs/' + channel.id;
 			});
 		};
 	</script>
-</mk-bbs-page>
+</mk-channels-page>

From b4340b1d91a6fc1679c3cb891ea800e1b491109c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 31 Oct 2017 22:09:09 +0900
Subject: [PATCH 03/16] wip

---
 src/api/endpoints/posts/create.ts | 47 +++++++++++++++++++++++++++----
 src/api/models/post.ts            |  1 +
 2 files changed, 42 insertions(+), 6 deletions(-)

diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index 805dba7f8..42a55f850 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -4,9 +4,9 @@
 import $ from 'cafy';
 import deepEqual = require('deep-equal');
 import parse from '../../common/text';
-import Post from '../../models/post';
-import { isValidText } from '../../models/post';
+import { default as Post, IPost, isValidText } from '../../models/post';
 import { default as User, IUser } from '../../models/user';
+import { default as Channel, IChannel } from '../../models/channel';
 import Following from '../../models/following';
 import DriveFile from '../../models/drive-file';
 import Watching from '../../models/post-watching';
@@ -62,7 +62,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
 	if (repostIdErr) return rej('invalid repost_id');
 
-	let repost = null;
+	let repost: IPost = null;
+	let isQuote = false;
 	if (repostId !== undefined) {
 		// Fetch repost to post
 		repost = await Post.findOne({
@@ -84,18 +85,20 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 			}
 		});
 
+		isQuote = text != null || files != null;
+
 		// 直近と同じRepost対象かつ引用じゃなかったらエラー
 		if (latestPost &&
 			latestPost.repost_id &&
 			latestPost.repost_id.equals(repost._id) &&
-			text === undefined && files === null) {
+			!isQuote) {
 			return rej('cannot repost same post that already reposted in your latest post');
 		}
 
 		// 直近がRepost対象かつ引用じゃなかったらエラー
 		if (latestPost &&
 			latestPost._id.equals(repost._id) &&
-			text === undefined && files === null) {
+			!isQuote) {
 			return rej('cannot repost your latest post');
 		}
 	}
@@ -104,7 +107,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$;
 	if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id');
 
-	let inReplyToPost = null;
+	let inReplyToPost: IPost = null;
 	if (inReplyToPostId !== undefined) {
 		// Fetch reply
 		inReplyToPost = await Post.findOne({
@@ -121,6 +124,37 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 		}
 	}
 
+	// Get 'channel_id' parameter
+	const [channelId, channelIdErr] = $(params.channel_id).optional.id().$;
+	if (channelIdErr) return rej('invalid channel_id');
+
+	let channel: IChannel = null;
+	if (channelId !== undefined) {
+		// Fetch channel
+		channel = await Channel.findOne({
+			_id: channelId
+		});
+
+		if (channel === null) {
+			return rej('channel not found');
+		}
+
+		// 返信対象の投稿がこのチャンネルじゃなかったらダメ
+		if (inReplyToPost && !channelId.equals(inReplyToPost.channel_id)) {
+			return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
+		}
+
+		// Repost対象の投稿がこのチャンネルじゃなかったらダメ
+		if (repost && !channelId.equals(repost.channel_id)) {
+			return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません');
+		}
+
+		// 引用ではないRepostはダメ
+		if (repost && !isQuote) {
+			return rej('チャンネル内部では引用ではないRepostをすることはできません');
+		}
+	}
+
 	// Get 'poll' parameter
 	const [poll, pollErr] = $(params.poll).optional.strict.object()
 		.have('choices', $().array('string')
@@ -164,6 +198,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	// 投稿を作成
 	const post = await Post.insert({
 		created_at: new Date(),
+		channel_id: channel ? channel._id : undefined,
 		media_ids: files ? files.map(file => file._id) : undefined,
 		reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
 		repost_id: repost ? repost._id : undefined,
diff --git a/src/api/models/post.ts b/src/api/models/post.ts
index 8b9f7f5ef..fe07dcb0b 100644
--- a/src/api/models/post.ts
+++ b/src/api/models/post.ts
@@ -10,6 +10,7 @@ export function isValidText(text: string): boolean {
 
 export type IPost = {
 	_id: mongo.ObjectID;
+	channel_id: mongo.ObjectID;
 	created_at: Date;
 	media_ids: mongo.ObjectID[];
 	reply_to_id: mongo.ObjectID;

From 30a4e839a687bed7ed839e3c17f6781bb4b76499 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 31 Oct 2017 22:14:12 +0900
Subject: [PATCH 04/16] Fix indent

---
 src/api/endpoints/posts/create.ts | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index 42a55f850..e0a02fa4a 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -186,11 +186,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 			repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null,
 			media_ids: (user.latest_post.media_ids || []).map(id => id.toString())
 		}, {
-				text: text,
-				reply: inReplyToPost ? inReplyToPost._id.toString() : null,
-				repost: repost ? repost._id.toString() : null,
-				media_ids: (files || []).map(file => file._id.toString())
-			})) {
+			text: text,
+			reply: inReplyToPost ? inReplyToPost._id.toString() : null,
+			repost: repost ? repost._id.toString() : null,
+			media_ids: (files || []).map(file => file._id.toString())
+		})) {
 			return rej('duplicate');
 		}
 	}

From 5efb52b9f563ae7d6b5383d054a6c21fee676b68 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 31 Oct 2017 22:35:31 +0900
Subject: [PATCH 05/16] wip

---
 locales/en.yml                                       |  2 +-
 locales/ja.yml                                       |  2 +-
 src/api/endpoints.ts                                 | 12 ++++++++++--
 .../endpoints/{bbs/threads => channels}/create.ts    |  4 ++--
 src/web/app/desktop/router.js                        |  5 +++++
 src/web/app/desktop/tags/pages/channels.tag          |  8 +++-----
 src/web/app/desktop/tags/ui.tag                      |  8 ++++----
 7 files changed, 26 insertions(+), 15 deletions(-)
 rename src/api/endpoints/{bbs/threads => channels}/create.ts (84%)

diff --git a/locales/en.yml b/locales/en.yml
index da532fc78..5c7a1165b 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -241,7 +241,7 @@ desktop:
     mk-ui-header-nav:
       home: "Home"
       messaging: "Messages"
-      bbs: "BBS"
+      channels: "Channels"
       info: "News"
 
     mk-ui-header-search:
diff --git a/locales/ja.yml b/locales/ja.yml
index 1ae94652b..dd76a2b90 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -241,7 +241,7 @@ desktop:
     mk-ui-header-nav:
       home: "ホーム"
       messaging: "メッセージ"
-      bbs: "掲示板"
+      channels: "チャンネル"
       info: "お知らせ"
 
     mk-ui-header-search:
diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 29a97bcb8..26177b877 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -474,8 +474,16 @@ const endpoints: Endpoint[] = [
 		name: 'messaging/messages/create',
 		withCredential: true,
 		kind: 'messaging-write'
-	}
-
+	},
+	{
+		name: 'channels/create',
+		withCredential: true,
+		limit: {
+			duration: ms('1hour'),
+			max: 3,
+			minInterval: ms('10seconds')
+		}
+	},
 ];
 
 export default endpoints;
diff --git a/src/api/endpoints/bbs/threads/create.ts b/src/api/endpoints/channels/create.ts
similarity index 84%
rename from src/api/endpoints/bbs/threads/create.ts
rename to src/api/endpoints/channels/create.ts
index d9b4d34a0..74b089dfc 100644
--- a/src/api/endpoints/bbs/threads/create.ts
+++ b/src/api/endpoints/channels/create.ts
@@ -2,8 +2,8 @@
  * Module dependencies
  */
 import $ from 'cafy';
-import Channel from '../../../models/channel';
-import serialize from '../../../serializers/channel';
+import Channel from '../../models/channel';
+import serialize from '../../serializers/channel';
 
 /**
  * Create a channel
diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index afa8a2dce..51738f3af 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -9,6 +9,7 @@ let page = null;
 export default me => {
 	route('/',              index);
 	route('/i>mentions',    mentions);
+	route('/channel',       channels);
 	route('/post::post',    post);
 	route('/search::query', search);
 	route('/:user',         user.bind(null, 'home'));
@@ -54,6 +55,10 @@ export default me => {
 		mount(el);
 	}
 
+	function channels() {
+		mount(document.createElement('mk-channels-page'));
+	}
+
 	function notFound() {
 		mount(document.createElement('mk-not-found'));
 	}
diff --git a/src/web/app/desktop/tags/pages/channels.tag b/src/web/app/desktop/tags/pages/channels.tag
index 9e47e52d2..03fae3c8d 100644
--- a/src/web/app/desktop/tags/pages/channels.tag
+++ b/src/web/app/desktop/tags/pages/channels.tag
@@ -1,8 +1,7 @@
 <mk-channels-page>
 	<mk-ui ref="ui">
 		<main>
-			<h1>%i18n:desktop.tags.mk-bbs-page.title%</h1>
-			<button onclick={ parent.new }>%i18n:desktop.tags.mk-bbs-page.new%</button>
+			<button onclick={ parent.new }>%i18n:desktop.tags.mk-channels-page.new%</button>
 		</main>
 	</mk-ui>
 	<style>
@@ -14,16 +13,15 @@
 		this.mixin('api');
 
 		this.on('mount', () => {
-			document.title = '%i18n:desktop.tags.mk-bbs-page.title%';
 		});
 
 		this.new = () => {
-			const title = window.prompt('%i18n:desktop.tags.mk-bbs-page.channel-title%');
+			const title = window.prompt('%i18n:desktop.tags.mk-channels-page.channel-title%');
 
 			this.api('bbs/channels/create', {
 				title: title
 			}).then(channel => {
-				location.href = '/bbs/' + channel.id;
+				location.href = '/channel/' + channel.id;
 			});
 		};
 	</script>
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index 452a72c00..7527358dc 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -335,10 +335,10 @@
 				</a>
 			</li>
 		</virtual>
-		<li class="bbs">
-			<a href={ CONFIG.url + '/bbs' }>
-				<i class="fa fa-coffee"></i>
-				<p>%i18n:desktop.tags.mk-ui-header-nav.bbs%</p>
+		<li class="channels">
+			<a href={ CONFIG.url + '/channel' }>
+				<i class="fa fa-television"></i>
+				<p>%i18n:desktop.tags.mk-ui-header-nav.channels%</p>
 			</a>
 		</li>
 		<li class="info">

From f87ec61e96a8c1f070abefc6a3b5f7e68e24705d Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 31 Oct 2017 23:11:22 +0900
Subject: [PATCH 06/16] wip

---
 src/api/endpoints.ts                        |  3 ++
 src/api/endpoints/channels/show.ts          | 31 +++++++++++++++
 src/web/app/desktop/router.js               | 26 ++++++++-----
 src/web/app/desktop/tags/index.js           |  1 +
 src/web/app/desktop/tags/pages/channel.tag  | 43 +++++++++++++++++++++
 src/web/app/desktop/tags/pages/channels.tag |  2 +-
 src/web/app/desktop/tags/pages/user.tag     |  2 +-
 7 files changed, 97 insertions(+), 11 deletions(-)
 create mode 100644 src/api/endpoints/channels/show.ts
 create mode 100644 src/web/app/desktop/tags/pages/channel.tag

diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 26177b877..45b83fc9e 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -484,6 +484,9 @@ const endpoints: Endpoint[] = [
 			minInterval: ms('10seconds')
 		}
 	},
+	{
+		name: 'channels/show'
+	},
 ];
 
 export default endpoints;
diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts
new file mode 100644
index 000000000..8861e5459
--- /dev/null
+++ b/src/api/endpoints/channels/show.ts
@@ -0,0 +1,31 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import { default as Channel, IChannel } from '../../models/channel';
+import serialize from '../../serializers/channel';
+
+/**
+ * Show a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+	// Get 'channel_id' parameter
+	const [channelId, channelIdErr] = $(params.channel_id).id().$;
+	if (channelIdErr) return rej('invalid channel_id param');
+
+	// Fetch channel
+	const channel: IChannel = await Channel.findOne({
+		_id: channelId
+	});
+
+	if (channel === null) {
+		return rej('channel not found');
+	}
+
+	// Serialize
+	res(await serialize(channel, user));
+});
diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index 51738f3af..d9300cc69 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -7,15 +7,16 @@ const route = require('page');
 let page = null;
 
 export default me => {
-	route('/',              index);
-	route('/i>mentions',    mentions);
-	route('/channel',       channels);
-	route('/post::post',    post);
-	route('/search::query', search);
-	route('/:user',         user.bind(null, 'home'));
-	route('/:user/graphs',  user.bind(null, 'graphs'));
-	route('/:user/:post',   post);
-	route('*',              notFound);
+	route('/',                 index);
+	route('/i>mentions',       mentions);
+	route('/channel',          channels);
+	route('/channel/:channel', channel);
+	route('/post::post',       post);
+	route('/search::query',    search);
+	route('/:user',            user.bind(null, 'home'));
+	route('/:user/graphs',     user.bind(null, 'graphs'));
+	route('/:user/:post',      post);
+	route('*',                 notFound);
 
 	function index() {
 		me ? home() : entrance();
@@ -55,6 +56,12 @@ export default me => {
 		mount(el);
 	}
 
+	function channel(ctx) {
+		const el = document.createElement('mk-channel-page');
+		el.setAttribute('id', ctx.params.channel);
+		mount(el);
+	}
+
 	function channels() {
 		mount(document.createElement('mk-channels-page'));
 	}
@@ -72,6 +79,7 @@ export default me => {
 };
 
 function mount(content) {
+	document.documentElement.style.background = '#313a42';
 	document.documentElement.removeAttribute('data-page');
 	if (page) page.unmount();
 	const body = document.getElementById('app');
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 6d4900652..7fdeb6884 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -61,6 +61,7 @@ require('./pages/user.tag');
 require('./pages/post.tag');
 require('./pages/search.tag');
 require('./pages/not-found.tag');
+require('./pages/channel.tag');
 require('./pages/channels.tag');
 require('./autocomplete-suggestion.tag');
 require('./progress-dialog.tag');
diff --git a/src/web/app/desktop/tags/pages/channel.tag b/src/web/app/desktop/tags/pages/channel.tag
new file mode 100644
index 000000000..4fa172f99
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/channel.tag
@@ -0,0 +1,43 @@
+<mk-channel-page>
+	<mk-ui ref="ui">
+		<main if={ !parent.fetching }>
+			<h1>{ parent.channel.title }</h1>
+		</main>
+	</mk-ui>
+	<style>
+		:scope
+			display block
+
+			main
+				> h1
+					color #f00
+	</style>
+	<script>
+		import Progress from '../../../common/scripts/loading';
+
+		this.mixin('api');
+
+		this.id = this.opts.id;
+		this.fetching = true;
+		this.channel = null;
+
+		this.on('mount', () => {
+			document.documentElement.style.background = '#efefef';
+
+			Progress.start();
+
+			this.api('channels/show', {
+				channel_id: this.id
+			}).then(channel => {
+				Progress.done();
+
+				this.update({
+					fetching: false,
+					channel: channel
+				});
+
+				document.title = channel.title + ' | Misskey'
+			});
+		});
+	</script>
+</mk-channel-page>
diff --git a/src/web/app/desktop/tags/pages/channels.tag b/src/web/app/desktop/tags/pages/channels.tag
index 03fae3c8d..220f1ca50 100644
--- a/src/web/app/desktop/tags/pages/channels.tag
+++ b/src/web/app/desktop/tags/pages/channels.tag
@@ -18,7 +18,7 @@
 		this.new = () => {
 			const title = window.prompt('%i18n:desktop.tags.mk-channels-page.channel-title%');
 
-			this.api('bbs/channels/create', {
+			this.api('channels/create', {
 				title: title
 			}).then(channel => {
 				location.href = '/channel/' + channel.id;
diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag
index 864fe2273..811ca5c0f 100644
--- a/src/web/app/desktop/tags/pages/user.tag
+++ b/src/web/app/desktop/tags/pages/user.tag
@@ -16,7 +16,7 @@
 
 			this.refs.ui.refs.user.on('user-fetched', user => {
 				Progress.set(0.5);
-				document.title = user.name + ' | Misskey'
+				document.title = user.name + ' | Misskey';
 			});
 
 			this.refs.ui.refs.user.on('loaded', () => {

From 346c2959e058fa445ebb82e71eb37ef023ba6bd4 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 00:10:30 +0900
Subject: [PATCH 07/16] wip

---
 src/api/endpoints.ts                          |  3 +
 src/api/endpoints/channels/posts.ts           | 79 +++++++++++++++++
 src/web/app/common/scripts/channel-stream.js  | 14 +++
 src/web/app/desktop/tags/pages/channel.tag    | 87 +++++++++++++++++++
 .../app/desktop/tags/pages/drive-chooser.tag  | 44 ++++++++++
 5 files changed, 227 insertions(+)
 create mode 100644 src/api/endpoints/channels/posts.ts
 create mode 100644 src/web/app/common/scripts/channel-stream.js
 create mode 100644 src/web/app/desktop/tags/pages/drive-chooser.tag

diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 45b83fc9e..88c01d4e7 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -487,6 +487,9 @@ const endpoints: Endpoint[] = [
 	{
 		name: 'channels/show'
 	},
+	{
+		name: 'channels/posts'
+	},
 ];
 
 export default endpoints;
diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts
new file mode 100644
index 000000000..fa91fb93e
--- /dev/null
+++ b/src/api/endpoints/channels/posts.ts
@@ -0,0 +1,79 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import { default as Channel, IChannel } from '../../models/channel';
+import { default as Post, IPost } from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a posts of a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+	// Get 'limit' parameter
+	const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$;
+	if (limitErr) return rej('invalid limit param');
+
+	// Get 'since_id' parameter
+	const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+	if (sinceIdErr) return rej('invalid since_id param');
+
+	// Get 'max_id' parameter
+	const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
+	if (maxIdErr) return rej('invalid max_id param');
+
+	// Check if both of since_id and max_id is specified
+	if (sinceId && maxId) {
+		return rej('cannot set since_id and max_id');
+	}
+
+	// Get 'channel_id' parameter
+	const [channelId, channelIdErr] = $(params.channel_id).id().$;
+	if (channelIdErr) return rej('invalid channel_id param');
+
+	// Fetch channel
+	const channel: IChannel = await Channel.findOne({
+		_id: channelId
+	});
+
+	if (channel === null) {
+		return rej('channel not found');
+	}
+
+	//#region Construct query
+	const sort = {
+		_id: -1
+	};
+
+	const query = {
+		channel_id: channel._id
+	} as any;
+
+	if (sinceId) {
+		sort._id = 1;
+		query._id = {
+			$gt: sinceId
+		};
+	} else if (maxId) {
+		query._id = {
+			$lt: maxId
+		};
+	}
+	//#endregion Construct query
+
+	// Issue query
+	const posts = await Post
+		.find(query, {
+			limit: limit,
+			sort: sort
+		});
+
+	// Serialize
+	res(await Promise.all(posts.map(async (post) =>
+		await serialize(post, user)
+	)));
+});
diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js
new file mode 100644
index 000000000..38e7d9113
--- /dev/null
+++ b/src/web/app/common/scripts/channel-stream.js
@@ -0,0 +1,14 @@
+'use strict';
+
+import Stream from './stream';
+
+/**
+ * Channel stream connection
+ */
+class Connection extends Stream {
+	constructor() {
+		super('channel');
+	}
+}
+
+export default Connection;
diff --git a/src/web/app/desktop/tags/pages/channel.tag b/src/web/app/desktop/tags/pages/channel.tag
index 4fa172f99..8a3034f40 100644
--- a/src/web/app/desktop/tags/pages/channel.tag
+++ b/src/web/app/desktop/tags/pages/channel.tag
@@ -2,6 +2,8 @@
 	<mk-ui ref="ui">
 		<main if={ !parent.fetching }>
 			<h1>{ parent.channel.title }</h1>
+			<mk-channel-post each={ parent.posts } post={ this }/>
+			<mk-channel-form channel={ parent.channel }/>
 		</main>
 	</mk-ui>
 	<style>
@@ -14,12 +16,15 @@
 	</style>
 	<script>
 		import Progress from '../../../common/scripts/loading';
+		import ChannelStream from '../../../common/scripts/channel-stream';
 
 		this.mixin('api');
 
 		this.id = this.opts.id;
 		this.fetching = true;
 		this.channel = null;
+		this.posts = null;
+		this.connection = new ChannelStream();
 
 		this.on('mount', () => {
 			document.documentElement.style.background = '#efefef';
@@ -38,6 +43,88 @@
 
 				document.title = channel.title + ' | Misskey'
 			});
+
+			this.api('channels/posts', {
+				channel_id: this.id
+			}).then(posts => {
+				this.update({
+					posts: posts
+				});
+			});
 		});
 	</script>
 </mk-channel-page>
+
+<mk-channel-post>
+	<header>
+		<b>{ post.user.name }</b>
+	</header>
+	<div>
+		{ post.text }
+	</div>
+	<style>
+		:scope
+			display block
+			margin 0
+			padding 0
+
+			> header
+				> b
+					color #008000
+
+	</style>
+	<script>
+		this.post = this.opts.post;
+	</script>
+</mk-channel-post>
+
+<mk-channel-form>
+	<p if={ reply }>{ reply.user.name }への返信: (or <a onclick={ clearReply }>キャンセル</a>)</p>
+	<textarea ref="text" disabled={ wait }></textarea>
+	<button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
+		{ wait ? 'やってます' : 'やる' }<mk-ellipsis if={ wait }/>
+	</button>
+
+	<style>
+		:scope
+			display block
+
+	</style>
+	<script>
+		this.mixin('api');
+
+		this.channel = this.opts.channel;
+
+		this.clearReply = () => {
+			this.update({
+				reply: null
+			});
+		};
+
+		this.clear = () => {
+			this.clearReply();
+			this.refs.text.value = '';
+		};
+
+		this.post = e => {
+			this.update({
+				wait: true
+			});
+
+			this.api('posts/create', {
+				text: this.refs.text.value,
+				reply_to_id: this.reply ? this.reply.id : undefined,
+				channel_id: this.channel.id
+			}).then(data => {
+				this.clear();
+			}).catch(err => {
+				alert('失敗した');
+			}).then(() => {
+				this.update({
+					wait: false
+				});
+			});
+		};
+
+	</script>
+</mk-channel-form>
diff --git a/src/web/app/desktop/tags/pages/drive-chooser.tag b/src/web/app/desktop/tags/pages/drive-chooser.tag
new file mode 100644
index 000000000..49741ad40
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/drive-chooser.tag
@@ -0,0 +1,44 @@
+<mk-drive-chooser>
+	<mk-drive-browser ref="browser" multiple={ parent.multiple }/>
+	<div>
+		<button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button>
+		<button class="cancel" onclick={ close }>キャンセル</button>
+		<button class="ok" onclick={ parent.ok }>決定</button>
+	</div>
+
+	<style>
+		:scope
+			display block
+			height 100%
+
+	</style>
+	<script>
+		this.multiple = this.opts.multiple != null ? this.opts.multiple : false;
+
+		this.on('mount', () => {
+			this.refs.browser.on('selected', file => {
+				this.files = [file];
+				this.ok();
+			});
+
+			this.refs.browser.on('change-selection', files => {
+				this.update({
+					files: files
+				});
+			});
+		});
+
+		this.upload = () => {
+			this.refs.browser.selectLocalFile();
+		};
+
+		this.close = () => {
+			window.close();
+		};
+
+		this.ok = () => {
+			window.opener.cb(this.multiple ? this.files : this.files[0]);
+			window.close();
+		};
+	</script>
+</mk-drive-chooser>

From 71c3e11708dad327924bdcb95193d44c2b11a907 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 01:38:19 +0900
Subject: [PATCH 08/16] wip

---
 src/api/endpoints/channels/create.ts       |  3 +-
 src/api/endpoints/posts/create.ts          | 17 +++++++++++
 src/api/models/channel.ts                  |  1 +
 src/api/serializers/post.ts                |  8 ++++-
 src/web/app/desktop/tags/pages/channel.tag | 35 ++++++++++++++++++----
 src/web/app/desktop/tags/timeline.tag      |  4 +++
 src/web/app/mobile/tags/timeline.tag       |  4 +++
 7 files changed, 65 insertions(+), 7 deletions(-)

diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts
index 74b089dfc..e0c0e0192 100644
--- a/src/api/endpoints/channels/create.ts
+++ b/src/api/endpoints/channels/create.ts
@@ -21,7 +21,8 @@ module.exports = async (params, user) => new Promise(async (res, rej) => {
 	const channel = await Channel.insert({
 		created_at: new Date(),
 		user_id: user._id,
-		title: title
+		title: title,
+		index: 0
 	});
 
 	// Response
diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index e0a02fa4a..183cabf13 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -153,6 +153,16 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 		if (repost && !isQuote) {
 			return rej('チャンネル内部では引用ではないRepostをすることはできません');
 		}
+	} else {
+		// 返信対象の投稿がチャンネルへの投稿だったらダメ
+		if (inReplyToPost && inReplyToPost.channel_id != null) {
+			return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
+		}
+
+		// Repost対象の投稿がチャンネルへの投稿だったらダメ
+		if (repost && repost.channel_id != null) {
+			return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません');
+		}
 	}
 
 	// Get 'poll' parameter
@@ -199,6 +209,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	const post = await Post.insert({
 		created_at: new Date(),
 		channel_id: channel ? channel._id : undefined,
+		index: channel ? channel.index + 1 : undefined,
 		media_ids: files ? files.map(file => file._id) : undefined,
 		reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
 		repost_id: repost ? repost._id : undefined,
@@ -217,6 +228,12 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	// -----------------------------------------------------------
 	// Post processes
 
+	Channel.update({ _id: channel._id }, {
+		$inc: {
+			index: 1
+		}
+	});
+
 	User.update({ _id: user._id }, {
 		$set: {
 			latest_post: post
diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts
index 79edb7136..c80e84dbc 100644
--- a/src/api/models/channel.ts
+++ b/src/api/models/channel.ts
@@ -10,4 +10,5 @@ export type IChannel = {
 	created_at: Date;
 	title: string;
 	user_id: mongo.ObjectID;
+	index: number;
 };
diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts
index df917a859..7d40df2d6 100644
--- a/src/api/serializers/post.ts
+++ b/src/api/serializers/post.ts
@@ -8,6 +8,7 @@ import Reaction from '../models/post-reaction';
 import { IUser } from '../models/user';
 import Vote from '../models/poll-vote';
 import serializeApp from './app';
+import serializeChannel from './channel';
 import serializeUser from './user';
 import serializeDriveFile from './drive-file';
 import parse from '../common/text';
@@ -76,8 +77,13 @@ const self = (
 		_post.app = await serializeApp(_post.app_id);
 	}
 
+	// Populate channel
+	if (_post.channel_id) {
+		_post.channel = await serializeChannel(_post.channel_id);
+	}
+
+	// Populate media
 	if (_post.media_ids) {
-		// Populate media
 		_post.media = await Promise.all(_post.media_ids.map(async fileId =>
 			await serializeDriveFile(fileId)
 		));
diff --git a/src/web/app/desktop/tags/pages/channel.tag b/src/web/app/desktop/tags/pages/channel.tag
index 8a3034f40..ebd26f07b 100644
--- a/src/web/app/desktop/tags/pages/channel.tag
+++ b/src/web/app/desktop/tags/pages/channel.tag
@@ -2,8 +2,9 @@
 	<mk-ui ref="ui">
 		<main if={ !parent.fetching }>
 			<h1>{ parent.channel.title }</h1>
-			<mk-channel-post each={ parent.posts } post={ this }/>
-			<mk-channel-form channel={ parent.channel }/>
+			<mk-channel-post if={ parent.posts } each={ parent.posts.reverse() } post={ this } form={ parent.refs.form }/>
+			<hr>
+			<mk-channel-form channel={ parent.channel } ref="form"/>
 		</main>
 	</mk-ui>
 	<style>
@@ -11,6 +12,8 @@
 			display block
 
 			main
+				padding 8px
+
 				> h1
 					color #f00
 	</style>
@@ -57,9 +60,13 @@
 
 <mk-channel-post>
 	<header>
-		<b>{ post.user.name }</b>
+		<a class="index" onclick={ reply }>{ post.index }:</a>
+		<a class="name" href={ '/' + post.user.username }><b>{ post.user.name }</b></a>
+		<mk-time time={ post.created_at } mode="detail"/>
+		<span>ID:<i>{ post.user.username }</i></span>
 	</header>
 	<div>
+		<a if={ post.reply_to }>&gt;&gt;{ post.reply_to.index }</a>
 		{ post.text }
 	</div>
 	<style>
@@ -69,17 +76,35 @@
 			padding 0
 
 			> header
-				> b
+				> .index
+					margin-right 0.25em
+					color #000
+
+				> .name
+					margin-right 0.5em
 					color #008000
 
+				> mk-time
+					margin-right 0.5em
+
+			> div
+				padding 0 0 1em 2em
+
 	</style>
 	<script>
 		this.post = this.opts.post;
+		this.form = this.opts.form;
+
+		this.reply = () => {
+			this.form.update({
+				reply: this.post
+			});
+		};
 	</script>
 </mk-channel-post>
 
 <mk-channel-form>
-	<p if={ reply }>{ reply.user.name }への返信: (or <a onclick={ clearReply }>キャンセル</a>)</p>
+	<p if={ reply }><b>&gt;&gt;{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p>
 	<textarea ref="text" disabled={ wait }></textarea>
 	<button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
 		{ wait ? 'やってます' : 'やる' }<mk-ellipsis if={ wait }/>
diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag
index 2d6b439e3..17b2c66dc 100644
--- a/src/web/app/desktop/tags/timeline.tag
+++ b/src/web/app/desktop/tags/timeline.tag
@@ -112,6 +112,7 @@
 			</header>
 			<div class="body">
 				<div class="text" ref="text">
+					<p class="channel" if={ p.channel != null }><a href={ '/channel/' + p.channel.id }>{ p.channel.title }</a>:</p>
 					<a class="reply" if={ p.reply_to }>
 						<i class="fa fa-reply"></i>
 					</a>
@@ -333,6 +334,9 @@
 									font-weight 400
 									font-style normal
 
+							> .channel
+								margin 0
+
 							> .reply
 								margin-right 8px
 								color #717171
diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag
index c7f5bfd68..b26a5cb10 100644
--- a/src/web/app/mobile/tags/timeline.tag
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -164,6 +164,7 @@
 			</header>
 			<div class="body">
 				<div class="text" ref="text">
+					<p class="channel" if={ p.channel != null }><a href={ '/channel/' + p.channel.id }>{ p.channel.title }</a>:</p>
 					<a class="reply" if={ p.reply_to }>
 						<i class="fa fa-reply"></i>
 					</a>
@@ -373,6 +374,9 @@
 							mk-url-preview
 								margin-top 8px
 
+							> .channel
+								margin 0
+
 							> .reply
 								margin-right 8px
 								color #717171

From e770cd6f55a5e424e731ebb89b5a091afa129904 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 02:16:05 +0900
Subject: [PATCH 09/16] wip

---
 src/web/app/desktop/router.js                 |   5 +
 src/web/app/desktop/tags/index.js             |   1 +
 src/web/app/desktop/tags/pages/channel.tag    |  33 +++-
 .../app/desktop/tags/pages/drive-chooser.tag  |  44 -----
 .../app/desktop/tags/pages/selectdrive.tag    | 159 ++++++++++++++++++
 5 files changed, 196 insertions(+), 46 deletions(-)
 delete mode 100644 src/web/app/desktop/tags/pages/drive-chooser.tag
 create mode 100644 src/web/app/desktop/tags/pages/selectdrive.tag

diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index d9300cc69..df67bb7b7 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -8,6 +8,7 @@ let page = null;
 
 export default me => {
 	route('/',                 index);
+	route('/selectdrive',      selectDrive);
 	route('/i>mentions',       mentions);
 	route('/channel',          channels);
 	route('/channel/:channel', channel);
@@ -66,6 +67,10 @@ export default me => {
 		mount(document.createElement('mk-channels-page'));
 	}
 
+	function selectDrive() {
+		mount(document.createElement('mk-selectdrive-page'));
+	}
+
 	function notFound() {
 		mount(document.createElement('mk-not-found'));
 	}
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 7fdeb6884..0b92d8c23 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -63,6 +63,7 @@ require('./pages/search.tag');
 require('./pages/not-found.tag');
 require('./pages/channel.tag');
 require('./pages/channels.tag');
+require('./pages/selectdrive.tag');
 require('./autocomplete-suggestion.tag');
 require('./progress-dialog.tag');
 require('./user-preview.tag');
diff --git a/src/web/app/desktop/tags/pages/channel.tag b/src/web/app/desktop/tags/pages/channel.tag
index ebd26f07b..a14c0648c 100644
--- a/src/web/app/desktop/tags/pages/channel.tag
+++ b/src/web/app/desktop/tags/pages/channel.tag
@@ -2,7 +2,9 @@
 	<mk-ui ref="ui">
 		<main if={ !parent.fetching }>
 			<h1>{ parent.channel.title }</h1>
-			<mk-channel-post if={ parent.posts } each={ parent.posts.reverse() } post={ this } form={ parent.refs.form }/>
+			<virtual if={ parent.posts }>
+				<mk-channel-post each={ parent.posts.reverse() } post={ this } form={ parent.refs.form }/>
+			</virtual>
 			<hr>
 			<mk-channel-form channel={ parent.channel } ref="form"/>
 		</main>
@@ -68,6 +70,11 @@
 	<div>
 		<a if={ post.reply_to }>&gt;&gt;{ post.reply_to.index }</a>
 		{ post.text }
+		<div class="media" if={ post.media }>
+			<virtual each={ file in post.media }>
+				<img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/>
+			</virtual>
+		</div>
 	</div>
 	<style>
 		:scope
@@ -109,13 +116,19 @@
 	<button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
 		{ wait ? 'やってます' : 'やる' }<mk-ellipsis if={ wait }/>
 	</button>
-
+	<br>
+	<button onclick={ drive }>ドライブ</button>
+	<ol if={ files }>
+		<li each={ files }>{ name }</li>
+	</ol>
 	<style>
 		:scope
 			display block
 
 	</style>
 	<script>
+		import CONFIG from '../../../common/scripts/config';
+
 		this.mixin('api');
 
 		this.channel = this.opts.channel;
@@ -128,6 +141,9 @@
 
 		this.clear = () => {
 			this.clearReply();
+			this.update({
+				files: null
+			});
 			this.refs.text.value = '';
 		};
 
@@ -136,8 +152,13 @@
 				wait: true
 			});
 
+			const files = this.files && this.files.length > 0
+				? this.files.map(f => f.id)
+				: undefined;
+
 			this.api('posts/create', {
 				text: this.refs.text.value,
+				media_ids: files,
 				reply_to_id: this.reply ? this.reply.id : undefined,
 				channel_id: this.channel.id
 			}).then(data => {
@@ -151,5 +172,13 @@
 			});
 		};
 
+		this.drive = () => {
+			window['cb'] = files => {
+				this.update({
+					files: files
+				});
+			};
+			window.open(CONFIG.url + '/selectdrive?multiple=true', '_blank');
+		};
 	</script>
 </mk-channel-form>
diff --git a/src/web/app/desktop/tags/pages/drive-chooser.tag b/src/web/app/desktop/tags/pages/drive-chooser.tag
deleted file mode 100644
index 49741ad40..000000000
--- a/src/web/app/desktop/tags/pages/drive-chooser.tag
+++ /dev/null
@@ -1,44 +0,0 @@
-<mk-drive-chooser>
-	<mk-drive-browser ref="browser" multiple={ parent.multiple }/>
-	<div>
-		<button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button>
-		<button class="cancel" onclick={ close }>キャンセル</button>
-		<button class="ok" onclick={ parent.ok }>決定</button>
-	</div>
-
-	<style>
-		:scope
-			display block
-			height 100%
-
-	</style>
-	<script>
-		this.multiple = this.opts.multiple != null ? this.opts.multiple : false;
-
-		this.on('mount', () => {
-			this.refs.browser.on('selected', file => {
-				this.files = [file];
-				this.ok();
-			});
-
-			this.refs.browser.on('change-selection', files => {
-				this.update({
-					files: files
-				});
-			});
-		});
-
-		this.upload = () => {
-			this.refs.browser.selectLocalFile();
-		};
-
-		this.close = () => {
-			window.close();
-		};
-
-		this.ok = () => {
-			window.opener.cb(this.multiple ? this.files : this.files[0]);
-			window.close();
-		};
-	</script>
-</mk-drive-chooser>
diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag
new file mode 100644
index 000000000..b196357d8
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/selectdrive.tag
@@ -0,0 +1,159 @@
+<mk-selectdrive-page>
+	<mk-drive-browser ref="browser" multiple={ multiple }/>
+	<div>
+		<button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button>
+		<button class="cancel" onclick={ close }>キャンセル</button>
+		<button class="ok" onclick={ ok }>決定</button>
+	</div>
+
+	<style>
+		:scope
+			display block
+			height 100%
+			background #fff
+
+			> mk-drive-browser
+				height calc(100% - 72px)
+
+			> div
+				position fixed
+				bottom 0
+				left 0
+				width 100%
+				height 72px
+				background lighten($theme-color, 95%)
+
+				.upload
+					display inline-block
+					position absolute
+					top 8px
+					left 16px
+					cursor pointer
+					padding 0
+					margin 8px 4px 0 0
+					width 40px
+					height 40px
+					font-size 1em
+					color rgba($theme-color, 0.5)
+					background transparent
+					outline none
+					border solid 1px transparent
+					border-radius 4px
+
+					&:hover
+						background transparent
+						border-color rgba($theme-color, 0.3)
+
+					&:active
+						color rgba($theme-color, 0.6)
+						background transparent
+						border-color rgba($theme-color, 0.5)
+						box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset
+
+					&:focus
+						&:after
+							content ""
+							pointer-events none
+							position absolute
+							top -5px
+							right -5px
+							bottom -5px
+							left -5px
+							border 2px solid rgba($theme-color, 0.3)
+							border-radius 8px
+
+				.ok
+				.cancel
+					display block
+					position absolute
+					bottom 16px
+					cursor pointer
+					padding 0
+					margin 0
+					width 120px
+					height 40px
+					font-size 1em
+					outline none
+					border-radius 4px
+
+					&:focus
+						&:after
+							content ""
+							pointer-events none
+							position absolute
+							top -5px
+							right -5px
+							bottom -5px
+							left -5px
+							border 2px solid rgba($theme-color, 0.3)
+							border-radius 8px
+
+					&:disabled
+						opacity 0.7
+						cursor default
+
+				.ok
+					right 16px
+					color $theme-color-foreground
+					background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
+					border solid 1px lighten($theme-color, 15%)
+
+					&:not(:disabled)
+						font-weight bold
+
+					&:hover:not(:disabled)
+						background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
+						border-color $theme-color
+
+					&:active:not(:disabled)
+						background $theme-color
+						border-color $theme-color
+
+				.cancel
+					right 148px
+					color #888
+					background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
+					border solid 1px #e2e2e2
+
+					&:hover
+						background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
+						border-color #dcdcdc
+
+					&:active
+						background #ececec
+						border-color #dcdcdc
+
+	</style>
+	<script>
+		const q = (new URL(location)).searchParams;
+		this.multiple = q.get('multiple') == 'true' ? true : false;
+
+		this.on('mount', () => {
+			document.documentElement.style.background = '#fff';
+
+			this.refs.browser.on('selected', file => {
+				this.files = [file];
+				this.ok();
+			});
+
+			this.refs.browser.on('change-selection', files => {
+				this.update({
+					files: files
+				});
+			});
+		});
+
+		this.upload = () => {
+			this.refs.browser.selectLocalFile();
+		};
+
+		this.close = () => {
+			window.close();
+		};
+
+		this.ok = () => {
+			window.opener.cb(this.multiple ? this.files : this.files[0]);
+			window.close();
+		};
+	</script>
+</mk-selectdrive-page>

From f37fb38640a31c4b8865a5562628197ff21f3cce Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 03:17:14 +0900
Subject: [PATCH 10/16] wip

---
 docs/setup.en.md                              |  1 +
 docs/setup.ja.md                              |  1 +
 locales/en.yml                                | 13 +++--
 locales/ja.yml                                | 13 +++--
 src/api/endpoints/posts/create.ts             |  7 ++-
 src/api/event.ts                              |  6 +++
 src/api/stream/channel.ts                     | 12 +++++
 src/api/streaming.ts                          | 22 +++++---
 src/config.ts                                 |  2 +
 src/web/app/ch/router.js                      | 32 ++++++++++++
 src/web/app/ch/script.js                      | 18 +++++++
 src/web/app/ch/style.styl                     |  4 ++
 .../tags/pages => ch/tags}/channel.tag        | 52 +++++++++++++------
 src/web/app/ch/tags/index.js                  |  2 +
 src/web/app/ch/tags/index.tag                 | 24 +++++++++
 src/web/app/common/scripts/channel-stream.js  |  6 ++-
 src/web/app/common/scripts/config.js          |  2 +
 src/web/app/desktop/router.js                 | 12 -----
 src/web/app/desktop/tags/index.js             |  2 -
 src/web/app/desktop/tags/pages/channels.tag   | 28 ----------
 src/web/app/desktop/tags/timeline.tag         |  2 +-
 src/web/app/desktop/tags/ui.tag               |  6 +--
 src/web/app/mobile/tags/timeline.tag          |  2 +-
 src/web/app/mobile/tags/ui.tag                |  5 +-
 webpack/webpack.config.ts                     |  1 +
 25 files changed, 189 insertions(+), 86 deletions(-)
 create mode 100644 src/api/stream/channel.ts
 create mode 100644 src/web/app/ch/router.js
 create mode 100644 src/web/app/ch/script.js
 create mode 100644 src/web/app/ch/style.styl
 rename src/web/app/{desktop/tags/pages => ch/tags}/channel.tag (76%)
 create mode 100644 src/web/app/ch/tags/index.js
 create mode 100644 src/web/app/ch/tags/index.tag
 delete mode 100644 src/web/app/desktop/tags/pages/channels.tag

diff --git a/docs/setup.en.md b/docs/setup.en.md
index 3e4893534..dbc0599b5 100644
--- a/docs/setup.en.md
+++ b/docs/setup.en.md
@@ -25,6 +25,7 @@ Note that Misskey uses following subdomains:
 * **api**.*{primary domain}*
 * **auth**.*{primary domain}*
 * **about**.*{primary domain}*
+* **ch**.*{primary domain}*
 * **stats**.*{primary domain}*
 * **status**.*{primary domain}*
 * **dev**.*{primary domain}*
diff --git a/docs/setup.ja.md b/docs/setup.ja.md
index 4f48a0808..602fd9b6a 100644
--- a/docs/setup.ja.md
+++ b/docs/setup.ja.md
@@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います:
 * **api**.*{primary domain}*
 * **auth**.*{primary domain}*
 * **about**.*{primary domain}*
+* **ch**.*{primary domain}*
 * **stats**.*{primary domain}*
 * **status**.*{primary domain}*
 * **dev**.*{primary domain}*
diff --git a/locales/en.yml b/locales/en.yml
index 5c7a1165b..643649b46 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -164,6 +164,12 @@ common:
     mk-uploader:
       waiting: "Waiting"
 
+ch:
+  tags:
+    mk-index:
+      new: "Create new channel"
+      channel-title: "Channel title"
+
 desktop:
   tags:
     mk-api-info:
@@ -241,7 +247,7 @@ desktop:
     mk-ui-header-nav:
       home: "Home"
       messaging: "Messages"
-      channels: "Channels"
+      ch: "Channels"
       info: "News"
 
     mk-ui-header-search:
@@ -352,10 +358,6 @@ desktop:
     mk-repost-form-window:
       title: "Are you sure you want to repost this post?"
 
-    mk-channels-page:
-      new: "Create new channel"
-      channel-title: "Channel title"
-
 mobile:
   tags:
     mk-drive-file-viewer:
@@ -496,6 +498,7 @@ mobile:
       home: "Home"
       notifications: "Notifications"
       messaging: "Messages"
+      ch: "Channels"
       drive: "Drive"
       settings: "Settings"
       about: "About Misskey"
diff --git a/locales/ja.yml b/locales/ja.yml
index dd76a2b90..9fd7d94f0 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -164,6 +164,12 @@ common:
     mk-uploader:
       waiting: "待機中"
 
+ch:
+  tags:
+    mk-index:
+      new: "チャンネルを作成"
+      channel-title: "チャンネルのタイトル"
+
 desktop:
   tags:
     mk-api-info:
@@ -241,7 +247,7 @@ desktop:
     mk-ui-header-nav:
       home: "ホーム"
       messaging: "メッセージ"
-      channels: "チャンネル"
+      ch: "チャンネル"
       info: "お知らせ"
 
     mk-ui-header-search:
@@ -352,10 +358,6 @@ desktop:
     mk-repost-form-window:
       title: "この投稿をRepostしますか?"
 
-    mk-channels-page:
-      new: "チャンネルを作成"
-      channel-title: "チャンネルのタイトル"
-
 mobile:
   tags:
     mk-drive-file-viewer:
@@ -496,6 +498,7 @@ mobile:
       home: "ホーム"
       notifications: "通知"
       messaging: "メッセージ"
+      ch: "チャンネル"
       search: "検索"
       drive: "ドライブ"
       settings: "設定"
diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index 183cabf13..34265dcbc 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -13,7 +13,7 @@ import Watching from '../../models/post-watching';
 import serialize from '../../serializers/post';
 import notify from '../../common/notify';
 import watch from '../../common/watch-post';
-import event from '../../event';
+import { default as event, publishChannelStream } from '../../event';
 import config from '../../../conf';
 
 /**
@@ -258,6 +258,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	// Publish event to myself's stream
 	event(user._id, 'post', postObj);
 
+	// Publish event to channel
+	if (channel) {
+		publishChannelStream(channel._id, 'post', postObj);
+	}
+
 	// Fetch all followers
 	const followers = await Following
 		.find({
diff --git a/src/api/event.ts b/src/api/event.ts
index 9613a9f7c..909b0d255 100644
--- a/src/api/event.ts
+++ b/src/api/event.ts
@@ -25,6 +25,10 @@ class MisskeyEvent {
 		this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	public publishChannelStream(channelId: ID, type: string, value?: any): void {
+		this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	private publish(channel: string, type: string, value?: any): void {
 		const message = value == null ?
 			{ type: type } :
@@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev);
 export const publishPostStream = ev.publishPostStream.bind(ev);
 
 export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
+
+export const publishChannelStream = ev.publishChannelStream.bind(ev);
diff --git a/src/api/stream/channel.ts b/src/api/stream/channel.ts
new file mode 100644
index 000000000..d67d77cbf
--- /dev/null
+++ b/src/api/stream/channel.ts
@@ -0,0 +1,12 @@
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+
+export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void {
+	const channel = request.resourceURL.query.channel;
+
+	// Subscribe channel stream
+	subscriber.subscribe(`misskey:channel-stream:${channel}`);
+	subscriber.on('message', (_, data) => {
+		connection.send(data);
+	});
+}
diff --git a/src/api/streaming.ts b/src/api/streaming.ts
index db600013b..0e512fb21 100644
--- a/src/api/streaming.ts
+++ b/src/api/streaming.ts
@@ -9,6 +9,7 @@ import isNativeToken from './common/is-native-token';
 import homeStream from './stream/home';
 import messagingStream from './stream/messaging';
 import serverStream from './stream/server';
+import channelStream from './stream/channel';
 
 module.exports = (server: http.Server) => {
 	/**
@@ -26,14 +27,6 @@ module.exports = (server: http.Server) => {
 			return;
 		}
 
-		const user = await authenticate(request.resourceURL.query.i);
-
-		if (user == null) {
-			connection.send('authentication-failed');
-			connection.close();
-			return;
-		}
-
 		// Connect to Redis
 		const subscriber = redis.createClient(
 			config.redis.port, config.redis.host);
@@ -43,6 +36,19 @@ module.exports = (server: http.Server) => {
 			subscriber.quit();
 		});
 
+		if (request.resourceURL.pathname === '/channel') {
+			channelStream(request, connection, subscriber);
+			return;
+		}
+
+		const user = await authenticate(request.resourceURL.query.i);
+
+		if (user == null) {
+			connection.send('authentication-failed');
+			connection.close();
+			return;
+		}
+
 		const channel =
 			request.resourceURL.pathname === '/' ? homeStream :
 			request.resourceURL.pathname === '/messaging' ? messagingStream :
diff --git a/src/config.ts b/src/config.ts
index 46a93f5fe..18017e974 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -88,6 +88,7 @@ type Mixin = {
 	api_url: string;
 	auth_url: string;
 	about_url: string;
+	ch_url: stirng;
 	stats_url: string;
 	status_url: string;
 	dev_url: string;
@@ -122,6 +123,7 @@ export default function load() {
 	mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://'));
 	mixin.api_url = `${mixin.scheme}://api.${mixin.host}`;
 	mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`;
+	mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`;
 	mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
 	mixin.about_url = `${mixin.scheme}://about.${mixin.host}`;
 	mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`;
diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.js
new file mode 100644
index 000000000..424158f40
--- /dev/null
+++ b/src/web/app/ch/router.js
@@ -0,0 +1,32 @@
+import * as riot from 'riot';
+const route = require('page');
+let page = null;
+
+export default me => {
+	route('/',         index);
+	route('/:channel', channel);
+	route('*',         notFound);
+
+	function index() {
+		mount(document.createElement('mk-index'));
+	}
+
+	function channel(ctx) {
+		const el = document.createElement('mk-channel');
+		el.setAttribute('id', ctx.params.channel);
+		mount(el);
+	}
+
+	function notFound() {
+		mount(document.createElement('mk-not-found'));
+	}
+
+	// EXEC
+	route();
+};
+
+function mount(content) {
+	if (page) page.unmount();
+	const body = document.getElementById('app');
+	page = riot.mount(body.appendChild(content))[0];
+}
diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.js
new file mode 100644
index 000000000..760d405c5
--- /dev/null
+++ b/src/web/app/ch/script.js
@@ -0,0 +1,18 @@
+/**
+ * Channels
+ */
+
+// Style
+import './style.styl';
+
+require('./tags');
+import init from '../init';
+import route from './router';
+
+/**
+ * init
+ */
+init(me => {
+	// Start routing
+	route(me);
+});
diff --git a/src/web/app/ch/style.styl b/src/web/app/ch/style.styl
new file mode 100644
index 000000000..2fc3ac3fc
--- /dev/null
+++ b/src/web/app/ch/style.styl
@@ -0,0 +1,4 @@
+@import "../base"
+
+html
+	background #efefef
diff --git a/src/web/app/desktop/tags/pages/channel.tag b/src/web/app/ch/tags/channel.tag
similarity index 76%
rename from src/web/app/desktop/tags/pages/channel.tag
rename to src/web/app/ch/tags/channel.tag
index a14c0648c..b16844b8b 100644
--- a/src/web/app/desktop/tags/pages/channel.tag
+++ b/src/web/app/ch/tags/channel.tag
@@ -1,14 +1,19 @@
-<mk-channel-page>
-	<mk-ui ref="ui">
-		<main if={ !parent.fetching }>
-			<h1>{ parent.channel.title }</h1>
-			<virtual if={ parent.posts }>
-				<mk-channel-post each={ parent.posts.reverse() } post={ this } form={ parent.refs.form }/>
-			</virtual>
-			<hr>
-			<mk-channel-form channel={ parent.channel } ref="form"/>
-		</main>
-	</mk-ui>
+<mk-channel>
+	<main if={ !fetching }>
+		<h1>{ channel.title }</h1>
+		<virtual if={ posts }>
+			<mk-channel-post each={ posts.slice().reverse() } post={ this } form={ parent.refs.form }/>
+		</virtual>
+		<hr>
+		<mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/>
+		<div if={ !SIGNIN }>
+			<p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p>
+		</div>
+		<hr>
+		<footer>
+			<small>Misskey ver { version } (葵 aoi)</small>
+		</footer>
+	</main>
 	<style>
 		:scope
 			display block
@@ -20,16 +25,18 @@
 					color #f00
 	</style>
 	<script>
-		import Progress from '../../../common/scripts/loading';
-		import ChannelStream from '../../../common/scripts/channel-stream';
+		import Progress from '../../common/scripts/loading';
+		import ChannelStream from '../../common/scripts/channel-stream';
 
+		this.mixin('i');
 		this.mixin('api');
 
 		this.id = this.opts.id;
 		this.fetching = true;
 		this.channel = null;
 		this.posts = null;
-		this.connection = new ChannelStream();
+		this.connection = new ChannelStream(this.id);
+		this.version = VERSION;
 
 		this.on('mount', () => {
 			document.documentElement.style.background = '#efefef';
@@ -56,9 +63,22 @@
 					posts: posts
 				});
 			});
+
+			this.connection.on('post', this.onPost);
 		});
+
+		this.on('unmount', () => {
+			this.connection.off('post', this.onPost);
+			this.connection.close();
+		});
+
+		this.onPost = post => {
+			this.posts.unshift(post);
+			this.update();
+		};
+
 	</script>
-</mk-channel-page>
+</mk-channel>
 
 <mk-channel-post>
 	<header>
@@ -127,7 +147,7 @@
 
 	</style>
 	<script>
-		import CONFIG from '../../../common/scripts/config';
+		import CONFIG from '../../common/scripts/config';
 
 		this.mixin('api');
 
diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js
new file mode 100644
index 000000000..1e99ccd43
--- /dev/null
+++ b/src/web/app/ch/tags/index.js
@@ -0,0 +1,2 @@
+require('./index.tag');
+require('./channel.tag');
diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag
new file mode 100644
index 000000000..1c0a037c2
--- /dev/null
+++ b/src/web/app/ch/tags/index.tag
@@ -0,0 +1,24 @@
+<mk-index>
+	<button onclick={ new }>%i18n:ch.tags.mk-index.new%</button>
+	<style>
+		:scope
+			display block
+
+	</style>
+	<script>
+		this.mixin('api');
+
+		this.on('mount', () => {
+		});
+
+		this.new = () => {
+			const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%');
+
+			this.api('channels/create', {
+				title: title
+			}).then(channel => {
+				location.href = '/' + channel.id;
+			});
+		};
+	</script>
+</mk-index>
diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js
index 38e7d9113..17944dbe4 100644
--- a/src/web/app/common/scripts/channel-stream.js
+++ b/src/web/app/common/scripts/channel-stream.js
@@ -6,8 +6,10 @@ import Stream from './stream';
  * Channel stream connection
  */
 class Connection extends Stream {
-	constructor() {
-		super('channel');
+	constructor(channelId) {
+		super('channel', {
+			channel: channelId
+		});
 	}
 }
 
diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js
index 75a7abba2..c5015622f 100644
--- a/src/web/app/common/scripts/config.js
+++ b/src/web/app/common/scripts/config.js
@@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U
 const scheme = Url.protocol;
 const url = `${scheme}//${host}`;
 const apiUrl = `${scheme}//api.${host}`;
+const chUrl = `${scheme}//ch.${host}`;
 const devUrl = `${scheme}//dev.${host}`;
 const aboutUrl = `${scheme}//about.${host}`;
 const statsUrl = `${scheme}//stats.${host}`;
@@ -16,6 +17,7 @@ export default {
 	scheme,
 	url,
 	apiUrl,
+	chUrl,
 	devUrl,
 	aboutUrl,
 	statsUrl,
diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index df67bb7b7..977e3fa9a 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -10,8 +10,6 @@ export default me => {
 	route('/',                 index);
 	route('/selectdrive',      selectDrive);
 	route('/i>mentions',       mentions);
-	route('/channel',          channels);
-	route('/channel/:channel', channel);
 	route('/post::post',       post);
 	route('/search::query',    search);
 	route('/:user',            user.bind(null, 'home'));
@@ -57,16 +55,6 @@ export default me => {
 		mount(el);
 	}
 
-	function channel(ctx) {
-		const el = document.createElement('mk-channel-page');
-		el.setAttribute('id', ctx.params.channel);
-		mount(el);
-	}
-
-	function channels() {
-		mount(document.createElement('mk-channels-page'));
-	}
-
 	function selectDrive() {
 		mount(document.createElement('mk-selectdrive-page'));
 	}
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 0b92d8c23..37fdfe37e 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -61,8 +61,6 @@ require('./pages/user.tag');
 require('./pages/post.tag');
 require('./pages/search.tag');
 require('./pages/not-found.tag');
-require('./pages/channel.tag');
-require('./pages/channels.tag');
 require('./pages/selectdrive.tag');
 require('./autocomplete-suggestion.tag');
 require('./progress-dialog.tag');
diff --git a/src/web/app/desktop/tags/pages/channels.tag b/src/web/app/desktop/tags/pages/channels.tag
deleted file mode 100644
index 220f1ca50..000000000
--- a/src/web/app/desktop/tags/pages/channels.tag
+++ /dev/null
@@ -1,28 +0,0 @@
-<mk-channels-page>
-	<mk-ui ref="ui">
-		<main>
-			<button onclick={ parent.new }>%i18n:desktop.tags.mk-channels-page.new%</button>
-		</main>
-	</mk-ui>
-	<style>
-		:scope
-			display block
-
-	</style>
-	<script>
-		this.mixin('api');
-
-		this.on('mount', () => {
-		});
-
-		this.new = () => {
-			const title = window.prompt('%i18n:desktop.tags.mk-channels-page.channel-title%');
-
-			this.api('channels/create', {
-				title: title
-			}).then(channel => {
-				location.href = '/channel/' + channel.id;
-			});
-		};
-	</script>
-</mk-channels-page>
diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag
index 17b2c66dc..64b64f902 100644
--- a/src/web/app/desktop/tags/timeline.tag
+++ b/src/web/app/desktop/tags/timeline.tag
@@ -112,7 +112,7 @@
 			</header>
 			<div class="body">
 				<div class="text" ref="text">
-					<p class="channel" if={ p.channel != null }><a href={ '/channel/' + p.channel.id }>{ p.channel.title }</a>:</p>
+					<p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
 					<a class="reply" if={ p.reply_to }>
 						<i class="fa fa-reply"></i>
 					</a>
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index 7527358dc..3123c34f4 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -335,10 +335,10 @@
 				</a>
 			</li>
 		</virtual>
-		<li class="channels">
-			<a href={ CONFIG.url + '/channel' }>
+		<li class="ch">
+			<a href={ CONFIG.chUrl } target="_blank">
 				<i class="fa fa-television"></i>
-				<p>%i18n:desktop.tags.mk-ui-header-nav.channels%</p>
+				<p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
 			</a>
 		</li>
 		<li class="info">
diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag
index b26a5cb10..ad18521df 100644
--- a/src/web/app/mobile/tags/timeline.tag
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -164,7 +164,7 @@
 			</header>
 			<div class="body">
 				<div class="text" ref="text">
-					<p class="channel" if={ p.channel != null }><a href={ '/channel/' + p.channel.id }>{ p.channel.title }</a>:</p>
+					<p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
 					<a class="reply" if={ p.reply_to }>
 						<i class="fa fa-reply"></i>
 					</a>
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
index fb8cbcdbd..b2d96f6b8 100644
--- a/src/web/app/mobile/tags/ui.tag
+++ b/src/web/app/mobile/tags/ui.tag
@@ -231,10 +231,11 @@
 				<li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li>
 			</ul>
 			<ul>
-				<li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li>
+				<li><a href={ CONFIG.chUrl } target="_blank"><i class="fa fa-television"></i>%i18n:mobile.tags.mk-ui-nav.ch%<i class="fa fa-angle-right"></i></a></li>
+				<li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li>
 			</ul>
 			<ul>
-				<li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li>
+				<li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li>
 			</ul>
 			<ul>
 				<li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li>
diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts
index 5199285d5..066df1815 100644
--- a/webpack/webpack.config.ts
+++ b/webpack/webpack.config.ts
@@ -16,6 +16,7 @@ module.exports = langs.map(([lang, locale]) => {
 	const entry = {
 		desktop: './src/web/app/desktop/script.js',
 		mobile: './src/web/app/mobile/script.js',
+		ch: './src/web/app/ch/script.js',
 		stats: './src/web/app/stats/script.js',
 		status: './src/web/app/status/script.js',
 		dev: './src/web/app/dev/script.js',

From 3c4719a0b119c78108edeff2ecf7965f1c517237 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 03:31:36 +0900
Subject: [PATCH 11/16] wip

---
 src/web/app/ch/tags/channel.tag              |  2 +-
 src/web/app/mobile/router.js                 |  5 ++
 src/web/app/mobile/tags/drive.tag            |  6 +-
 src/web/app/mobile/tags/index.js             |  1 +
 src/web/app/mobile/tags/page/selectdrive.tag | 83 ++++++++++++++++++++
 5 files changed, 95 insertions(+), 2 deletions(-)
 create mode 100644 src/web/app/mobile/tags/page/selectdrive.tag

diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
index b16844b8b..d43113a55 100644
--- a/src/web/app/ch/tags/channel.tag
+++ b/src/web/app/ch/tags/channel.tag
@@ -11,7 +11,7 @@
 		</div>
 		<hr>
 		<footer>
-			<small>Misskey ver { version } (葵 aoi)</small>
+			<small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small>
 		</footer>
 	</main>
 	<style>
diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.js
index d59b2ec3a..01eb3c814 100644
--- a/src/web/app/mobile/router.js
+++ b/src/web/app/mobile/router.js
@@ -8,6 +8,7 @@ let page = null;
 
 export default me => {
 	route('/',                           index);
+	route('/selectdrive',                selectDrive);
 	route('/i/notifications',            notifications);
 	route('/i/messaging',                messaging);
 	route('/i/messaging/:username',      messaging);
@@ -122,6 +123,10 @@ export default me => {
 		mount(el);
 	}
 
+	function selectDrive() {
+		mount(document.createElement('mk-selectdrive-page'));
+	}
+
 	function notFound() {
 		mount(document.createElement('mk-not-found'));
 	}
diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag
index 9f3e64773..c17b7ce57 100644
--- a/src/web/app/mobile/tags/drive.tag
+++ b/src/web/app/mobile/tags/drive.tag
@@ -483,7 +483,7 @@
 			if (fn == null || fn == '') return;
 			switch (fn) {
 				case '1':
-					this.refs.file.click();
+					this.selectLocalFile();
 					break;
 				case '2':
 					this.urlUpload();
@@ -503,6 +503,10 @@
 			}
 		};
 
+		this.selectLocalFile = () => {
+			this.refs.file.click();
+		};
+
 		this.createFolder = () => {
 			const name = window.prompt('フォルダー名');
 			if (name == null || name == '') return;
diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.js
index a79f4f7e7..19952c20c 100644
--- a/src/web/app/mobile/tags/index.js
+++ b/src/web/app/mobile/tags/index.js
@@ -19,6 +19,7 @@ require('./page/settings/authorized-apps.tag');
 require('./page/settings/twitter.tag');
 require('./page/messaging.tag');
 require('./page/messaging-room.tag');
+require('./page/selectdrive.tag');
 require('./home.tag');
 require('./home-timeline.tag');
 require('./timeline.tag');
diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag
new file mode 100644
index 000000000..d9e7d95c4
--- /dev/null
+++ b/src/web/app/mobile/tags/page/selectdrive.tag
@@ -0,0 +1,83 @@
+<mk-selectdrive-page>
+	<header>
+		<h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1>
+		<button class="upload" onclick={ upload }><i class="fa fa-upload"></i></button>
+		<button if={ multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button>
+	</header>
+	<mk-drive ref="browser" select-file={ true } multiple={ multiple }/>
+
+	<style>
+		:scope
+			display block
+			width 100%
+			height 100%
+			background #fff
+
+			> header
+				border-bottom solid 1px #eee
+
+				> h1
+					margin 0
+					padding 0
+					text-align center
+					line-height 42px
+					font-size 1em
+					font-weight normal
+
+					> .count
+						margin-left 4px
+						opacity 0.5
+
+				> .upload
+					position absolute
+					top 0
+					left 0
+					line-height 42px
+					width 42px
+
+				> .ok
+					position absolute
+					top 0
+					right 0
+					line-height 42px
+					width 42px
+
+			> mk-drive
+				height calc(100% - 42px)
+				overflow scroll
+				-webkit-overflow-scrolling touch
+
+	</style>
+	<script>
+		const q = (new URL(location)).searchParams;
+		this.multiple = q.get('multiple') == 'true' ? true : false;
+
+		this.on('mount', () => {
+			document.documentElement.style.background = '#fff';
+
+			this.refs.browser.on('selected', file => {
+				this.files = [file];
+				this.ok();
+			});
+
+			this.refs.browser.on('change-selection', files => {
+				this.update({
+					files: files
+				});
+			});
+		});
+
+		this.upload = () => {
+			this.refs.browser.selectLocalFile();
+		};
+
+		this.close = () => {
+			window.close();
+		};
+
+		this.ok = () => {
+			window.opener.cb(this.multiple ? this.files : this.files[0]);
+			window.close();
+		};
+	</script>
+</mk-selectdrive-page>

From 20707d6fd9ce2dea1342ad38156c32fcec82217a Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 03:41:34 +0900
Subject: [PATCH 12/16] wip

---
 locales/en.yml                  |  3 +++
 locales/ja.yml                  |  3 +++
 src/web/app/ch/tags/channel.tag | 24 +++++++++++++++++++++---
 3 files changed, 27 insertions(+), 3 deletions(-)

diff --git a/locales/en.yml b/locales/en.yml
index 643649b46..afb6d2f2f 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -360,6 +360,9 @@ desktop:
 
 mobile:
   tags:
+    mk-selectdrive-page:
+      select-file: "Select file(s)"
+
     mk-drive-file-viewer:
       download: "Download"
       rename: "Rename"
diff --git a/locales/ja.yml b/locales/ja.yml
index 9fd7d94f0..03975556b 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -360,6 +360,9 @@ desktop:
 
 mobile:
   tags:
+    mk-selectdrive-page:
+      select-file: "ファイルを選択"
+
     mk-drive-file-viewer:
       download: "ダウンロード"
       rename: "名前を変更"
diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
index d43113a55..e8537e3f0 100644
--- a/src/web/app/ch/tags/channel.tag
+++ b/src/web/app/ch/tags/channel.tag
@@ -1,9 +1,13 @@
 <mk-channel>
 	<main if={ !fetching }>
 		<h1>{ channel.title }</h1>
-		<virtual if={ posts }>
-			<mk-channel-post each={ posts.slice().reverse() } post={ this } form={ parent.refs.form }/>
-		</virtual>
+		<p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
+		<div if={ !postsFetching }>
+			<p if={ posts == null }></p>>
+			<virtual if={ posts != null }>
+				<mk-channel-post each={ posts.slice().reverse() } post={ this } form={ parent.refs.form }/>
+			</virtual>
+		</div>
 		<hr>
 		<mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/>
 		<div if={ !SIGNIN }>
@@ -33,6 +37,7 @@
 
 		this.id = this.opts.id;
 		this.fetching = true;
+		this.postsFetching = true;
 		this.channel = null;
 		this.posts = null;
 		this.connection = new ChannelStream(this.id);
@@ -60,6 +65,7 @@
 				channel_id: this.id
 			}).then(posts => {
 				this.update({
+					postsFetching: false,
 					posts: posts
 				});
 			});
@@ -84,6 +90,7 @@
 	<header>
 		<a class="index" onclick={ reply }>{ post.index }:</a>
 		<a class="name" href={ '/' + post.user.username }><b>{ post.user.name }</b></a>
+		<mk-time time={ post.created_at }/>
 		<mk-time time={ post.created_at } mode="detail"/>
 		<span>ID:<i>{ post.user.username }</i></span>
 	</header>
@@ -114,6 +121,17 @@
 				> mk-time
 					margin-right 0.5em
 
+					&:first-of-type
+						display none
+
+				@media (max-width 600px)
+					> mk-time
+						&:first-of-type
+							display initial
+
+						&:last-of-type
+							display none
+
 			> div
 				padding 0 0 1em 2em
 

From 0cffc1cac0140a420c64e039487c32237c581d5e Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 03:42:50 +0900
Subject: [PATCH 13/16] wip

---
 src/web/app/ch/tags/channel.tag | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
index e8537e3f0..43a1f851f 100644
--- a/src/web/app/ch/tags/channel.tag
+++ b/src/web/app/ch/tags/channel.tag
@@ -3,7 +3,7 @@
 		<h1>{ channel.title }</h1>
 		<p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
 		<div if={ !postsFetching }>
-			<p if={ posts == null }></p>>
+			<p if={ posts == null }>まだ投稿がありません</p>
 			<virtual if={ posts != null }>
 				<mk-channel-post each={ posts.slice().reverse() } post={ this } form={ parent.refs.form }/>
 			</virtual>

From 42be937fcb6f02037ff4024a2fb1cf463c50ce0c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 04:11:56 +0900
Subject: [PATCH 14/16] wip

---
 src/api/endpoints.ts            |  3 ++
 src/api/endpoints/channels.ts   | 59 +++++++++++++++++++++++++++++++++
 src/web/app/ch/tags/channel.tag |  7 ++--
 src/web/app/ch/tags/index.tag   | 13 ++++++--
 4 files changed, 77 insertions(+), 5 deletions(-)
 create mode 100644 src/api/endpoints/channels.ts

diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 88c01d4e7..c4dacad85 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -490,6 +490,9 @@ const endpoints: Endpoint[] = [
 	{
 		name: 'channels/posts'
 	},
+	{
+		name: 'channels'
+	},
 ];
 
 export default endpoints;
diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts
new file mode 100644
index 000000000..e10c94389
--- /dev/null
+++ b/src/api/endpoints/channels.ts
@@ -0,0 +1,59 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../models/channel';
+import serialize from '../serializers/channel';
+
+/**
+ * Get all channels
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+	// Get 'limit' parameter
+	const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+	if (limitErr) return rej('invalid limit param');
+
+	// Get 'since_id' parameter
+	const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+	if (sinceIdErr) return rej('invalid since_id param');
+
+	// Get 'max_id' parameter
+	const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
+	if (maxIdErr) return rej('invalid max_id param');
+
+	// Check if both of since_id and max_id is specified
+	if (sinceId && maxId) {
+		return rej('cannot set since_id and max_id');
+	}
+
+	// Construct query
+	const sort = {
+		_id: -1
+	};
+	const query = {} as any;
+	if (sinceId) {
+		sort._id = 1;
+		query._id = {
+			$gt: sinceId
+		};
+	} else if (maxId) {
+		query._id = {
+			$lt: maxId
+		};
+	}
+
+	// Issue query
+	const channels = await Channel
+		.find(query, {
+			limit: limit,
+			sort: sort
+		});
+
+	// Serialize
+	res(await Promise.all(channels.map(async channel =>
+		await serialize(channel, me))));
+});
diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
index 43a1f851f..12a6b5a3b 100644
--- a/src/web/app/ch/tags/channel.tag
+++ b/src/web/app/ch/tags/channel.tag
@@ -1,4 +1,6 @@
 <mk-channel>
+	<header><a href={ CONFIG.chUrl }>Misskey Channels</a></header>
+	<hr>
 	<main if={ !fetching }>
 		<h1>{ channel.title }</h1>
 		<p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
@@ -21,10 +23,9 @@
 	<style>
 		:scope
 			display block
+			padding 8px
 
-			main
-				padding 8px
-
+			> main
 				> h1
 					color #f00
 	</style>
diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag
index 1c0a037c2..a64ddb6cc 100644
--- a/src/web/app/ch/tags/index.tag
+++ b/src/web/app/ch/tags/index.tag
@@ -1,5 +1,9 @@
 <mk-index>
-	<button onclick={ new }>%i18n:ch.tags.mk-index.new%</button>
+	<button onclick={ n }>%i18n:ch.tags.mk-index.new%</button>
+	<hr>
+	<ul if={ channels }>
+		<li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li>
+	</ul>
 	<style>
 		:scope
 			display block
@@ -9,9 +13,14 @@
 		this.mixin('api');
 
 		this.on('mount', () => {
+			this.api('channels').then(channels => {
+				this.update({
+					channels: channels
+				});
+			});
 		});
 
-		this.new = () => {
+		this.n = () => {
 			const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%');
 
 			this.api('channels/create', {

From 6f242f229a48eeb97e8d43f9c75b35c172f6e4b1 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 04:16:16 +0900
Subject: [PATCH 15/16] :v:

---
 src/common/get-post-summary.ts | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/common/get-post-summary.ts b/src/common/get-post-summary.ts
index f628a32b4..ac15077b2 100644
--- a/src/common/get-post-summary.ts
+++ b/src/common/get-post-summary.ts
@@ -3,7 +3,13 @@
  * @param {*} post 投稿
  */
 const summarize = (post: any): string => {
-	let summary = post.text ? post.text : '';
+	let summary = '';
+
+	// チャンネル
+	summary += post.channel ? `${post.channel.title}:` : '';
+
+	// 本文
+	summary += post.text ? post.text : '';
 
 	// メディアが添付されているとき
 	if (post.media) {

From 92cd2265b17898201a1e45c89e82c321b78e5018 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Wed, 1 Nov 2017 04:18:02 +0900
Subject: [PATCH 16/16] v2769

---
 CHANGELOG.md | 4 ++++
 package.json | 2 +-
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f75462e5..4bf0f6abc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@ ChangeLog (Release Notes)
 =========================
 主に notable な changes を書いていきます
 
+2769 (2017/11/01)
+-----------------
+* New: チャンネルシステム
+
 2752 (2017/10/30)
 -----------------
 * New: 未読の通知がある場合アイコンを表示するように
diff --git a/package.json b/package.json
index 7a81bed7a..57b3439d6 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "misskey",
   "author": "syuilo <i@syuilo.com>",
-  "version": "0.0.2752",
+  "version": "0.0.2769",
   "license": "MIT",
   "description": "A miniblog-based SNS",
   "bugs": "https://github.com/syuilo/misskey/issues",