diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 96398db6ac..678f20e8c2 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -564,6 +564,19 @@ overview: "概要"
 logs: "ログ"
 delayed: "遅延"
 database: "データベース"
+channel: "チャンネル"
+create: "作成"
+
+_channel:
+  create: "チャンネルを作成"
+  edit: "チャンネルを編集"
+  setBanner: "バナーを設定"
+  removeBanner: "バナーを削除"
+  featured: "トレンド"
+  owned: "管理中"
+  following: "フォロー中"
+  usersCount: "{n}人が参加中"
+  notesCount: "{n}投稿があります"
 
 _sidebar:
   full: "フル"
@@ -660,6 +673,7 @@ _sfx:
   chat: "チャット"
   chatBg: "チャット(バックグラウンド)"
   antenna: "アンテナ受信"
+  channel: "チャンネル通知"
 
 _ago:
   unknown: "謎"
@@ -740,6 +754,8 @@ _permissions:
   "write:page-likes": "ページのいいねを操作する"
   "read:user-groups": "ユーザーグループを見る"
   "write:user-groups": "ユーザーグループを操作する"
+  "read:channels": "チャンネルを見る"
+  "write:channels": "チャンネルを操作する"
 
 _auth:
   shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?"
@@ -822,6 +838,7 @@ _visibility:
 _postForm:
   replyPlaceholder: "このノートに返信..."
   quotePlaceholder: "このノートを引用..."
+  channelPlaceholder: "チャンネルに投稿..."
   _placeholders:
     a: "いまどうしてる?"
     b: "何かありましたか?"
diff --git a/migration/1596548170836-channel.ts b/migration/1596548170836-channel.ts
new file mode 100644
index 0000000000..4e3ebb330a
--- /dev/null
+++ b/migration/1596548170836-channel.ts
@@ -0,0 +1,58 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class channel1596548170836 implements MigrationInterface {
+    name = 'channel1596548170836'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TABLE "channel" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastNotedAt" TIMESTAMP WITH TIME ZONE, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "description" character varying(2048), "bannerId" character varying(32), "notesCount" integer NOT NULL DEFAULT 0, "usersCount" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_590f33ee6ee7d76437acf362e39" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_71cb7b435b7c0d4843317e7e16" ON "channel" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_29ef80c6f13bcea998447fce43" ON "channel" ("lastNotedAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_823bae55bd81b3be6e05cff438" ON "channel" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_0f58c11241e649d2a638a8de94" ON "channel" ("notesCount") `);
+        await queryRunner.query(`CREATE INDEX "IDX_094b86cd36bb805d1aa1e8cc9a" ON "channel" ("usersCount") `);
+        await queryRunner.query(`CREATE TABLE "channel_following" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, CONSTRAINT "PK_8b104be7f7415113f2a02cd5bdd" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_11e71f2511589dcc8a4d3214f9" ON "channel_following" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_0e43068c3f92cab197c3d3cd86" ON "channel_following" ("followeeId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_6d8084ec9496e7334a4602707e" ON "channel_following" ("followerId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2e230dd45a10e671d781d99f3e" ON "channel_following" ("followerId", "followeeId") `);
+        await queryRunner.query(`CREATE TABLE "channel_note_pining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_44f7474496bcf2e4b741681146d" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_8125f950afd3093acb10d2db8a" ON "channel_note_pining" ("channelId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f36fed37d6d4cdcc68c803cd9c" ON "channel_note_pining" ("channelId", "noteId") `);
+        await queryRunner.query(`ALTER TABLE "note" ADD "channelId" character varying(32) DEFAULT null`);
+        await queryRunner.query(`CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId") `);
+        await queryRunner.query(`ALTER TABLE "channel" ADD CONSTRAINT "FK_823bae55bd81b3be6e05cff4383" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel" ADD CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19" FOREIGN KEY ("bannerId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_f22169eb10657bded6d875ac8f9" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel_following" ADD CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e" FOREIGN KEY ("followeeId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel_following" ADD CONSTRAINT "FK_6d8084ec9496e7334a4602707e1" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_8125f950afd3093acb10d2db8a8" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_10b19ef67d297ea9de325cd4502" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_10b19ef67d297ea9de325cd4502"`);
+        await queryRunner.query(`ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_8125f950afd3093acb10d2db8a8"`);
+        await queryRunner.query(`ALTER TABLE "channel_following" DROP CONSTRAINT "FK_6d8084ec9496e7334a4602707e1"`);
+        await queryRunner.query(`ALTER TABLE "channel_following" DROP CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e"`);
+        await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_f22169eb10657bded6d875ac8f9"`);
+        await queryRunner.query(`ALTER TABLE "channel" DROP CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19"`);
+        await queryRunner.query(`ALTER TABLE "channel" DROP CONSTRAINT "FK_823bae55bd81b3be6e05cff4383"`);
+        await queryRunner.query(`DROP INDEX "IDX_f22169eb10657bded6d875ac8f"`);
+        await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "channelId"`);
+        await queryRunner.query(`DROP INDEX "IDX_f36fed37d6d4cdcc68c803cd9c"`);
+        await queryRunner.query(`DROP INDEX "IDX_8125f950afd3093acb10d2db8a"`);
+        await queryRunner.query(`DROP TABLE "channel_note_pining"`);
+        await queryRunner.query(`DROP INDEX "IDX_2e230dd45a10e671d781d99f3e"`);
+        await queryRunner.query(`DROP INDEX "IDX_6d8084ec9496e7334a4602707e"`);
+        await queryRunner.query(`DROP INDEX "IDX_0e43068c3f92cab197c3d3cd86"`);
+        await queryRunner.query(`DROP INDEX "IDX_11e71f2511589dcc8a4d3214f9"`);
+        await queryRunner.query(`DROP TABLE "channel_following"`);
+        await queryRunner.query(`DROP INDEX "IDX_094b86cd36bb805d1aa1e8cc9a"`);
+        await queryRunner.query(`DROP INDEX "IDX_0f58c11241e649d2a638a8de94"`);
+        await queryRunner.query(`DROP INDEX "IDX_823bae55bd81b3be6e05cff438"`);
+        await queryRunner.query(`DROP INDEX "IDX_29ef80c6f13bcea998447fce43"`);
+        await queryRunner.query(`DROP INDEX "IDX_71cb7b435b7c0d4843317e7e16"`);
+        await queryRunner.query(`DROP TABLE "channel"`);
+    }
+
+}
diff --git a/migration/1596786425167-channel2.ts b/migration/1596786425167-channel2.ts
new file mode 100644
index 0000000000..0233f7ab0f
--- /dev/null
+++ b/migration/1596786425167-channel2.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class channel21596786425167 implements MigrationInterface {
+    name = 'channel21596786425167'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "channel_following" DROP COLUMN "readCursor"`);
+    }
+
+}
diff --git a/migration/1597459042300-channel-unread.ts b/migration/1597459042300-channel-unread.ts
new file mode 100644
index 0000000000..a0f862114d
--- /dev/null
+++ b/migration/1597459042300-channel-unread.ts
@@ -0,0 +1,27 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class channelUnread1597459042300 implements MigrationInterface {
+    name = 'channelUnread1597459042300'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+				await queryRunner.query(`TRUNCATE TABLE "note_unread"`, undefined);
+        await queryRunner.query(`ALTER TABLE "channel_following" DROP COLUMN "readCursor"`);
+        await queryRunner.query(`ALTER TABLE "note_unread" ADD "isMentioned" boolean NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "note_unread" ADD "noteChannelId" character varying(32)`);
+        await queryRunner.query(`CREATE INDEX "IDX_25b1dd384bec391b07b74b861c" ON "note_unread" ("isMentioned") `);
+        await queryRunner.query(`CREATE INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30" ON "note_unread" ("isSpecified") `);
+        await queryRunner.query(`CREATE INDEX "IDX_29e8c1d579af54d4232939f994" ON "note_unread" ("noteUserId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_6a57f051d82c6d4036c141e107" ON "note_unread" ("noteChannelId") `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP INDEX "IDX_6a57f051d82c6d4036c141e107"`);
+        await queryRunner.query(`DROP INDEX "IDX_29e8c1d579af54d4232939f994"`);
+        await queryRunner.query(`DROP INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30"`);
+        await queryRunner.query(`DROP INDEX "IDX_25b1dd384bec391b07b74b861c"`);
+        await queryRunner.query(`ALTER TABLE "note_unread" DROP COLUMN "noteChannelId"`);
+        await queryRunner.query(`ALTER TABLE "note_unread" DROP COLUMN "isMentioned"`);
+        await queryRunner.query(`ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`);
+    }
+
+}
diff --git a/src/client/assets/sounds/noizenecio/kick_gaba2.mp3 b/src/client/assets/sounds/noizenecio/kick_gaba2.mp3
new file mode 100644
index 0000000000..33c2837620
Binary files /dev/null and b/src/client/assets/sounds/noizenecio/kick_gaba2.mp3 differ
diff --git a/src/client/assets/sounds/syuilo/reverved.mp3 b/src/client/assets/sounds/syuilo/reverved.mp3
new file mode 100644
index 0000000000..47588ef270
Binary files /dev/null and b/src/client/assets/sounds/syuilo/reverved.mp3 differ
diff --git a/src/client/assets/sounds/syuilo/ryukyu.mp3 b/src/client/assets/sounds/syuilo/ryukyu.mp3
new file mode 100644
index 0000000000..9e935e3f37
Binary files /dev/null and b/src/client/assets/sounds/syuilo/ryukyu.mp3 differ
diff --git a/src/client/assets/sounds/syuilo/square-pico.mp3 b/src/client/assets/sounds/syuilo/square-pico.mp3
new file mode 100644
index 0000000000..c4d8305ae7
Binary files /dev/null and b/src/client/assets/sounds/syuilo/square-pico.mp3 differ
diff --git a/src/client/components/channel-follow-button.vue b/src/client/components/channel-follow-button.vue
new file mode 100644
index 0000000000..3b83865b55
--- /dev/null
+++ b/src/client/components/channel-follow-button.vue
@@ -0,0 +1,141 @@
+<template>
+<button class="hdcaacmi _button"
+	:class="{ wait, active: isFollowing, full }"
+	@click="onClick"
+	:disabled="wait"
+>
+	<template v-if="!wait">
+		<template v-if="isFollowing">
+			<span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/>
+		</template>
+		<template v-else>
+			<span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/>
+		</template>
+	</template>
+	<template v-else>
+		<span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/>
+	</template>
+</button>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSpinner, faPlus, faMinus, } from '@fortawesome/free-solid-svg-icons';
+
+export default Vue.extend({
+	props: {
+		channel: {
+			type: Object,
+			required: true
+		},
+		full: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+	},
+
+	data() {
+		return {
+			isFollowing: this.channel.isFollowing,
+			wait: false,
+			faSpinner, faPlus, faMinus,
+		};
+	},
+
+	methods: {
+		async onClick() {
+			this.wait = true;
+
+			try {
+				if (this.isFollowing) {
+					await this.$root.api('channels/unfollow', {
+						channelId: this.channel.id
+					});
+					this.isFollowing = false;
+				} else {
+					await this.$root.api('channels/follow', {
+						channelId: this.channel.id
+					});
+					this.isFollowing = true;
+				}
+			} catch (e) {
+				console.error(e);
+			} finally {
+				this.wait = false;
+			}
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.hdcaacmi {
+	position: relative;
+	display: inline-block;
+	font-weight: bold;
+	color: var(--accent);
+	background: transparent;
+	border: solid 1px var(--accent);
+	padding: 0;
+	height: 31px;
+	font-size: 16px;
+	border-radius: 32px;
+	background: #fff;
+
+	&.full {
+		padding: 0 8px 0 12px;
+		font-size: 14px;
+	}
+
+	&:not(.full) {
+		width: 31px;
+	}
+
+	&:focus {
+		&:after {
+			content: "";
+			pointer-events: none;
+			position: absolute;
+			top: -5px;
+			right: -5px;
+			bottom: -5px;
+			left: -5px;
+			border: 2px solid var(--focus);
+			border-radius: 32px;
+		}
+	}
+
+	&:hover {
+		//background: mix($primary, #fff, 20);
+	}
+
+	&:active {
+		//background: mix($primary, #fff, 40);
+	}
+
+	&.active {
+		color: #fff;
+		background: var(--accent);
+
+		&:hover {
+			background: var(--accentLighten);
+			border-color: var(--accentLighten);
+		}
+
+		&:active {
+			background: var(--accentDarken);
+			border-color: var(--accentDarken);
+		}
+	}
+
+	&.wait {
+		cursor: wait !important;
+		opacity: 0.7;
+	}
+
+	> span {
+		margin-right: 6px;
+	}
+}
+</style>
diff --git a/src/client/components/channel-preview.vue b/src/client/components/channel-preview.vue
new file mode 100644
index 0000000000..bef4759570
--- /dev/null
+++ b/src/client/components/channel-preview.vue
@@ -0,0 +1,144 @@
+<template>
+<router-link :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
+	<div class="banner" v-if="channel.bannerUrl" :style="`background-image: url('${channel.bannerUrl}')`">
+		<div class="fade"></div>
+		<div class="name"><fa :icon="faSatelliteDish"/> {{ channel.name }}</div>
+		<div class="status">
+			<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
+			<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
+		</div>
+	</div>
+	<article v-if="channel.description">
+		<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
+	</article>
+	<footer>
+		<span>
+			{{ $t('updatedAt') }}: <mk-time :time="channel.lastNotedAt"/>
+		</span>
+	</footer>
+</router-link>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatelliteDish, faUsers, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+
+export default Vue.extend({
+	props: {
+		channel: {
+			type: Object,
+			required: true
+		},
+	},
+
+	data() {
+		return {
+			faSatelliteDish, faUsers, faPencilAlt,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.eftoefju {
+	display: block;
+	overflow: hidden;
+	width: 100%;
+	border: 1px solid var(--divider);
+
+	&:hover {
+		text-decoration: none;
+	}
+
+	> .banner {
+		position: relative;
+		width: 100%;
+		height: 200px;
+		background-position: center;
+		background-size: cover;
+
+		> .fade {
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), var(--X15));
+		}
+
+		> .name {
+			position: absolute;
+			top: 16px;
+			left: 16px;
+			padding: 12px 16px;
+			background: rgba(0, 0, 0, 0.7);
+			color: #fff;
+			font-size: 1.2em;
+		}
+
+		> .status {
+			position: absolute;
+			z-index: 1;
+			bottom: 16px;
+			right: 16px;
+			padding: 8px 12px;
+			font-size: 80%;
+			background: rgba(0, 0, 0, 0.7);
+			border-radius: 6px;
+			color: #fff;
+		}
+	}
+
+	> article {
+		padding: 16px;
+
+		> p {
+			margin: 0;
+			font-size: 1em;
+		}
+	}
+
+	> footer {
+		padding: 12px 16px;
+		border-top: solid 1px var(--divider);
+
+		> span {
+			opacity: 0.7;
+			font-size: 0.9em;
+		}
+	}
+
+	@media (max-width: 550px) {
+		font-size: 0.9em;
+
+		> .banner {
+			height: 80px;
+
+			> .status {
+				display: none;
+			}
+		}
+
+		> article {
+			padding: 12px;
+		}
+
+		> footer {
+			display: none;
+		}
+	}
+
+	@media (max-width: 500px) {
+		font-size: 0.8em;
+
+		> .banner {
+			height: 70px;
+		}
+
+		> article {
+			padding: 8px;
+		}
+	}
+}
+
+</style>
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 99a088b3e0..a731f9a36e 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -57,6 +57,7 @@
 					<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/>
 					<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
 				</div>
+				<router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link>
 			</div>
 			<footer class="footer">
 				<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
@@ -96,7 +97,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
+import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
 import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
 import { parse } from '../../mfm/parse';
 import { sum, unique } from '../../prelude/array';
@@ -133,6 +134,12 @@ export default Vue.extend({
 		MkUrlPreview,
 	},
 
+	inject: {
+		inChannel: {
+			default: null
+		}
+	},
+
 	props: {
 		note: {
 			type: Object,
@@ -159,7 +166,7 @@ export default Vue.extend({
 			isDeleted: false,
 			muted: false,
 			noteBody: this.$refs.noteBody,
-			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
+			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish
 		};
 	},
 
@@ -954,6 +961,11 @@ export default Vue.extend({
 						}
 					}
 				}
+
+				> .channel {
+					opacity: 0.7;
+					font-size: 80%;
+				}
 			}
 
 			> .footer {
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 307501b78a..031246e73a 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -10,7 +10,7 @@
 		<div>
 			<span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" />
 			<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span>
-			<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')">
+			<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')" :disabled="channel != null">
 				<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
 				<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
 				<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
@@ -88,6 +88,10 @@ export default Vue.extend({
 			type: Object,
 			required: false
 		},
+		channel: {
+			type: Object,
+			required: false
+		},
 		mention: {
 			type: Object,
 			required: false
@@ -140,30 +144,38 @@ export default Vue.extend({
 	},
 
 	computed: {
-		draftId(): string {
-			return this.renote
-				? `renote:${this.renote.id}`
-				: this.reply
-					? `reply:${this.reply.id}`
-					: 'note';
+		draftKey(): string {
+			let key = this.channel ? `channel:${this.channel.id}` : '';
+
+			if (this.renote) {
+				key += `renote:${this.renote.id}`;
+			} else if (this.reply) {
+				key += `reply:${this.reply.id}`;
+			} else {
+				key += 'note';
+			}
+
+			return key;
 		},
 
 		placeholder(): string {
-			const xs = [
-				this.$t('_postForm._placeholders.a'),
-				this.$t('_postForm._placeholders.b'),
-				this.$t('_postForm._placeholders.c'),
-				this.$t('_postForm._placeholders.d'),
-				this.$t('_postForm._placeholders.e'),
-				this.$t('_postForm._placeholders.f')
-			];
-			const x = xs[Math.floor(Math.random() * xs.length)];
-
-			return this.renote
-				? this.$t('_postForm.quotePlaceholder')
-				: this.reply
-					? this.$t('_postForm.replyPlaceholder')
-					: x;
+			if (this.renote) {
+				return this.$t('_postForm.quotePlaceholder');
+			} else if (this.reply) {
+				return this.$t('_postForm.replyPlaceholder');
+			} else if (this.channel) {
+				return this.$t('_postForm.channelPlaceholder');
+			} else {
+				const xs = [
+					this.$t('_postForm._placeholders.a'),
+					this.$t('_postForm._placeholders.b'),
+					this.$t('_postForm._placeholders.c'),
+					this.$t('_postForm._placeholders.d'),
+					this.$t('_postForm._placeholders.e'),
+					this.$t('_postForm._placeholders.f')
+				];
+				return xs[Math.floor(Math.random() * xs.length)];
+			}
 		},
 
 		submitText(): string {
@@ -224,9 +236,11 @@ export default Vue.extend({
 		}
 
 		// デフォルト公開範囲
-		this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
+		if (this.channel == null) {
+			this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : this.$store.state.settings.defaultNoteVisibility);
 
-		this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
+			this.localOnly = this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.localOnly : this.$store.state.settings.defaultNoteLocalOnly;
+		}
 
 		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
 		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
@@ -266,7 +280,7 @@ export default Vue.extend({
 		this.$nextTick(() => {
 			// 書きかけの投稿を復元
 			if (!this.instant && !this.mention) {
-				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
+				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
 				if (draft) {
 					this.text = draft.data.text;
 					this.useCw = draft.data.useCw;
@@ -398,6 +412,10 @@ export default Vue.extend({
 		},
 
 		setVisibility() {
+			if (this.channel) {
+				// TODO: information dialog
+				return;
+			}
 			const w = this.$root.new(MkVisibilityChooser, {
 				source: this.$refs.visibilityButton,
 				currentVisibility: this.visibility,
@@ -510,7 +528,7 @@ export default Vue.extend({
 
 			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 
-			data[this.draftId] = {
+			data[this.draftKey] = {
 				updatedAt: new Date(),
 				data: {
 					text: this.text,
@@ -529,7 +547,7 @@ export default Vue.extend({
 		deleteDraft() {
 			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 
-			delete data[this.draftId];
+			delete data[this.draftKey];
 
 			localStorage.setItem('drafts', JSON.stringify(data));
 		},
@@ -540,6 +558,7 @@ export default Vue.extend({
 				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
 				replyId: this.reply ? this.reply.id : undefined,
 				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
+				channelId: this.channel ? this.channel.id : undefined,
 				poll: this.poll ? (this.$refs.poll as any).get() : undefined,
 				cw: this.useCw ? this.cw || '' : undefined,
 				localOnly: this.localOnly,
diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue
index 28ff6ab1b3..cd78d53cfc 100644
--- a/src/client/components/timeline.vue
+++ b/src/client/components/timeline.vue
@@ -24,6 +24,10 @@ export default Vue.extend({
 			type: String,
 			required: false
 		},
+		channel: {
+			type: String,
+			required: false
+		},
 		sound: {
 			type: Boolean,
 			required: false,
@@ -31,6 +35,12 @@ export default Vue.extend({
 		}
 	},
 
+	provide() {
+		return {
+			inChannel: this.src === 'channel'
+		};
+	},
+
 	data() {
 		return {
 			connection: null,
@@ -117,6 +127,15 @@ export default Vue.extend({
 			this.connection.on('note', prepend);
 			this.connection.on('userAdded', onUserAdded);
 			this.connection.on('userRemoved', onUserRemoved);
+		} else if (this.src == 'channel') {
+			endpoint = 'channels/timeline';
+			this.query = {
+				channelId: this.channel
+			};
+			this.connection = this.$root.stream.connectToChannel('channel', {
+				channelId: this.channel
+			});
+			this.connection.on('note', prepend);
 		}
 
 		this.pagination = {
diff --git a/src/client/init.ts b/src/client/init.ts
index d76e94c5a3..b80df44c88 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -350,6 +350,20 @@ os.init(async () => {
 			app.sound('antenna');
 		});
 
+		main.on('readAllChannels', () => {
+			store.dispatch('mergeMe', {
+				hasUnreadChannel: false
+			});
+		});
+
+		main.on('unreadChannel', () => {
+			store.dispatch('mergeMe', {
+				hasUnreadChannel: true
+			});
+
+			app.sound('channel');
+		});
+
 		main.on('readAllAnnouncements', () => {
 			store.dispatch('mergeMe', {
 				hasUnreadAnnouncement: false
diff --git a/src/client/pages/channel-editor.vue b/src/client/pages/channel-editor.vue
new file mode 100644
index 0000000000..0178662119
--- /dev/null
+++ b/src/client/pages/channel-editor.vue
@@ -0,0 +1,128 @@
+<template>
+<div>
+	<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
+	<portal to="title">{{ channelId ? $t('_channel.edit') : $t('_channel.create') }}</portal>
+
+	<div class="_card">
+		<div class="_content">
+			<mk-input v-model="name">{{ $t('name') }}</mk-input>
+
+			<mk-textarea v-model="description">{{ $t('description') }}</mk-textarea>
+
+			<div class="banner">
+				<mk-button v-if="bannerId == null" @click="setBannerImage"><fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</mk-button>
+				<div v-else-if="bannerUrl">
+					<img :src="bannerUrl" style="width: 100%;"/>
+					<mk-button @click="removeBannerImage()"><fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</mk-button>
+				</div>
+			</div>
+		</div>
+		<div class="_footer">
+			<mk-button @click="save()" primary><fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</mk-button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faSatelliteDish } from '@fortawesome/free-solid-svg-icons';
+import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import MkTextarea from '../components/ui/textarea.vue';
+import MkButton from '../components/ui/button.vue';
+import MkInput from '../components/ui/input.vue';
+import { selectFile } from '../scripts/select-file';
+
+export default Vue.extend({
+	components: {
+		MkTextarea, MkButton, MkInput,
+	},
+
+	props: {
+		channelId: {
+			type: String,
+			required: false
+		},
+	},
+
+	data() {
+		return {
+			channel: null,
+			name: null,
+			description: null,
+			bannerUrl: null,
+			bannerId: null,
+			faSave, faTrashAlt, faPlus,faSatelliteDish,
+		};
+	},
+
+	watch: {
+		async bannerId() {
+			if (this.bannerId == null) {
+				this.bannerUrl = null;
+			} else {
+				this.bannerUrl = (await this.$root.api('drive/files/show', {
+					fileId: this.bannerId,
+				})).url;
+			}
+		},
+	},
+
+	async created() {
+		if (this.channelId) {
+			this.channel = await this.$root.api('channels/show', {
+				channelId: this.channelId,
+			});
+
+			this.name = this.channel.name;
+			this.description = this.channel.description;
+			this.bannerId = this.channel.bannerId;
+			this.bannerUrl = this.channel.bannerUrl;
+		}
+	},
+
+	methods: {
+		save() {
+			const params = {
+				name: this.name,
+				description: this.description,
+				bannerId: this.bannerId,
+			};
+
+			if (this.channelId) {
+				params.channelId = this.channelId;
+				this.$root.api('channels/update', params)
+				.then(channel => {
+					this.$root.dialog({
+						type: 'success',
+						iconOnly: true, autoClose: true
+					});
+				});
+			} else {
+				this.$root.api('channels/create', params)
+				.then(channel => {
+					this.$root.dialog({
+						type: 'success',
+						iconOnly: true, autoClose: true
+					});
+					this.$router.push(`/channels/${channel.id}`);
+				});
+			}
+		},
+
+		setBannerImage(e) {
+			selectFile(this, e.currentTarget || e.target, null, false).then(file => {
+				this.bannerId = file.id;
+			});
+		},
+
+		removeBannerImage() {
+			this.bannerId = null;
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/pages/channel.vue b/src/client/pages/channel.vue
new file mode 100644
index 0000000000..69631af74b
--- /dev/null
+++ b/src/client/pages/channel.vue
@@ -0,0 +1,190 @@
+<template>
+<div v-if="channel">
+	<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
+	<portal to="title">{{ channel.name }}</portal>
+
+	<div class="wpgynlbz _panel _vMargin" :class="{ hide: !showBanner }">
+		<x-channel-follow-button :channel="channel" :full="true" class="subscribe"/>
+		<button class="_button toggle" @click="() => showBanner = !showBanner">
+			<template v-if="showBanner"><fa :icon="faAngleUp"/></template>
+			<template v-else><fa :icon="faAngleDown"/></template>
+		</button>
+		<div class="hideOverlay" v-if="!showBanner">
+		</div>
+		<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+			<div class="status">
+				<div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div>
+				<div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div>
+			</div>
+			<div class="fade"></div>
+		</div>
+		<div class="description" v-if="channel.description">
+			<mfm :text="channel.description" :is-note="false" :i="$store.state.i"/>
+		</div>
+	</div>
+
+	<x-post-form :channel="channel" class="post-form _panel _vMargin" fixed/>
+
+	<x-timeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
+import {  } from '@fortawesome/free-regular-svg-icons';
+import MkContainer from '../components/ui/container.vue';
+import XPostForm from '../components/post-form.vue';
+import XTimeline from '../components/timeline.vue';
+import XChannelFollowButton from '../components/channel-follow-button.vue';
+
+export default Vue.extend({
+	metaInfo() {
+		return {
+			title: this.$t('channel') as string
+		};
+	},
+
+	components: {
+		MkContainer,
+		XPostForm,
+		XTimeline,
+		XChannelFollowButton
+	},
+
+	props: {
+		channelId: {
+			type: String,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			channel: null,
+			showBanner: true,
+			pagination: {
+				endpoint: 'channels/timeline',
+				limit: 10,
+				params: () => ({
+					channelId: this.channelId,
+				})
+			},
+			faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown,
+		};
+	},
+
+	watch: {
+		channelId: {
+			async handler() {
+				this.channel = await this.$root.api('channels/show', {
+					channelId: this.channelId,
+				});
+			},
+			immediate: true
+		}
+	},
+
+	created() {
+
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.wpgynlbz {
+	> .subscribe {
+		position: absolute;
+		z-index: 1;
+		top: 16px;
+		left: 16px;
+	}
+
+	> .toggle {
+		position: absolute;
+		z-index: 2;
+    top: 8px;
+		right: 8px;
+		font-size: 1.2em;
+		width: 48px;
+		height: 48px;
+		color: #fff;
+		background: rgba(0, 0, 0, 0.5);
+		border-radius: 100%;
+		
+		> [data-icon] {
+			vertical-align: middle;
+		}
+	}
+	
+	> .banner {
+		position: relative;
+		height: 200px;
+		background-position: center;
+		background-size: cover;
+
+		> .fade {
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 100%;
+			height: 64px;
+			background: linear-gradient(0deg, var(--panel), var(--X15));
+		}
+
+		> .status {
+			position: absolute;
+			z-index: 1;
+			bottom: 16px;
+			right: 16px;
+			padding: 8px 12px;
+			font-size: 80%;
+			background: rgba(0, 0, 0, 0.7);
+			border-radius: 6px;
+			color: #fff;
+		}
+	}
+
+	> .description {
+		padding: 16px;
+	}
+
+	> .hideOverlay {
+		position: absolute;
+		z-index: 1;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		-webkit-backdrop-filter: blur(16px);
+		backdrop-filter: blur(16px);
+		background: rgba(0, 0, 0, 0.3);
+	}
+
+	&.hide {
+		> .subscribe {
+			display: none;
+		}
+
+		> .toggle {
+			top: 0;
+			right: 0;
+			height: 100%;
+			background: transparent;
+		}
+
+		> .banner {
+			height: 42px;
+			filter: blur(8px);
+
+			> * {
+				display: none;
+			}
+		}
+
+		> .description {
+			display: none;
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/channels.vue b/src/client/pages/channels.vue
new file mode 100644
index 0000000000..85804141f1
--- /dev/null
+++ b/src/client/pages/channels.vue
@@ -0,0 +1,86 @@
+<template>
+<div>
+	<portal to="icon"><fa :icon="faSatelliteDish"/></portal>
+	<portal to="title">{{ $t('channel') }}</portal>
+
+	<mk-tab v-model="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/>
+
+	<div class="grwlizim featured" v-if="tab === 'featured'">
+		<mk-pagination :pagination="featuredPagination" #default="{items}">
+			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
+		</mk-pagination>
+	</div>
+
+	<div class="grwlizim following" v-if="tab === 'following'">
+		<mk-pagination :pagination="followingPagination" #default="{items}">
+			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
+		</mk-pagination>
+	</div>
+
+	<div class="grwlizim owned" v-if="tab === 'owned'">
+		<mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button>
+		<mk-pagination :pagination="ownedPagination" #default="{items}">
+			<mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/>
+		</mk-pagination>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatelliteDish, faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons';
+import { faHeart } from '@fortawesome/free-regular-svg-icons';
+import MkChannelPreview from '../components/channel-preview.vue';
+import MkPagination from '../components/ui/pagination.vue';
+import MkButton from '../components/ui/button.vue';
+import MkTab from '../components/tab.vue';
+
+export default Vue.extend({
+	components: {
+		MkChannelPreview, MkPagination, MkButton, MkTab
+	},
+	data() {
+		return {
+			tab: 'featured',
+			featuredPagination: {
+				endpoint: 'channels/featured',
+				limit: 5,
+			},
+			followingPagination: {
+				endpoint: 'channels/followed',
+				limit: 5,
+			},
+			ownedPagination: {
+				endpoint: 'channels/owned',
+				limit: 5,
+			},
+			faSatelliteDish, faPlus, faEdit, faHeart, faFireAlt
+		};
+	},
+	methods: {
+		create() {
+			this.$router.push(`/channels/new`);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.grwlizim {
+	padding: 16px 0;
+
+	&.my .uveselbe:first-child {
+		margin-top: 16px;
+	}
+
+	.uveselbe:not(:last-child) {
+		margin-bottom: 8px;
+	}
+
+	@media (min-width: 500px) {
+		.uveselbe:not(:last-child) {
+			margin-bottom: 16px;
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue
index b40086c310..d3f60ea910 100644
--- a/src/client/pages/index.home.vue
+++ b/src/client/pages/index.home.vue
@@ -2,14 +2,15 @@
 <div class="mk-home" v-hotkey.global="keymap">
 	<portal to="header" v-if="showTitle">
 		<button @click="choose" class="_button _kjvfvyph_">
-			<i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
+			<i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :icon="faCircle"/></i>
 			<fa v-if="src === 'home'" :icon="faHome"/>
 			<fa v-if="src === 'local'" :icon="faComments"/>
 			<fa v-if="src === 'social'" :icon="faShareAlt"/>
 			<fa v-if="src === 'global'" :icon="faGlobe"/>
 			<fa v-if="src === 'list'" :icon="faListUl"/>
 			<fa v-if="src === 'antenna'" :icon="faSatellite"/>
-			<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
+			<fa v-if="src === 'channel'" :icon="faSatelliteDish"/>
+			<span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span>
 			<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
 		</button>
 	</portal>
@@ -19,13 +20,13 @@
 	<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
 
 	<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
-	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
+	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
-import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
+import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons';
 import { faComments } from '@fortawesome/free-regular-svg-icons';
 import Progress from '../scripts/loading';
 import XTimeline from '../components/timeline.vue';
@@ -57,10 +58,11 @@ export default Vue.extend({
 			src: 'home',
 			list: null,
 			antenna: null,
+			channel: null,
 			menuOpened: false,
 			queue: 0,
 			width: 0,
-			faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
+			faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle
 		};
 	},
 
@@ -79,16 +81,20 @@ export default Vue.extend({
 	watch: {
 		src() {
 			this.showNav = false;
-			this.saveSrc();
 		},
 		list(x) {
 			this.showNav = false;
-			this.saveSrc();
 			if (x != null) this.antenna = null;
+			if (x != null) this.channel = null;
 		},
 		antenna(x) {
 			this.showNav = false;
-			this.saveSrc();
+			if (x != null) this.list = null;
+			if (x != null) this.channel = null;
+		},
+		channel(x) {
+			this.showNav = false;
+			if (x != null) this.antenna = null;
 			if (x != null) this.list = null;
 		},
 	},
@@ -99,6 +105,8 @@ export default Vue.extend({
 			this.list = this.$store.state.deviceUser.tl.arg;
 		} else if (this.src === 'antenna') {
 			this.antenna = this.$store.state.deviceUser.tl.arg;
+		} else if (this.src === 'channel') {
+			this.channel = this.$store.state.deviceUser.tl.arg;
 		}
 	},
 
@@ -127,9 +135,10 @@ export default Vue.extend({
 		async choose(ev) {
 			if (this.meta == null) return;
 			this.menuOpened = true;
-			const [antennas, lists] = await Promise.all([
+			const [antennas, lists, channels] = await Promise.all([
 				this.$root.api('antennas/list'),
-				this.$root.api('users/lists/list')
+				this.$root.api('users/lists/list'),
+				this.$root.api('channels/followed'),
 			]);
 			const antennaItems = antennas.map(antenna => ({
 				text: antenna.name,
@@ -137,7 +146,8 @@ export default Vue.extend({
 				indicate: antenna.hasUnreadNote,
 				action: () => {
 					this.antenna = antenna;
-					this.setSrc('antenna');
+					this.src = 'antenna';
+					this.saveSrc();
 				}
 			}));
 			const listItems = lists.map(list => ({
@@ -145,27 +155,40 @@ export default Vue.extend({
 				icon: faListUl,
 				action: () => {
 					this.list = list;
-					this.setSrc('list');
+					this.src = 'list';
+					this.saveSrc();
+				}
+			}));
+			const channelItems = channels.map(channel => ({
+				text: channel.name,
+				icon: faSatelliteDish,
+				indicate: channel.hasUnreadNote,
+				action: () => {
+					// NOTE: チャンネルタイムラインをこのコンポーネントで表示するようにすると投稿フォームはどうするかなどの問題が生じるのでとりあえずページ遷移で
+					//this.channel = channel;
+					//this.src = 'channel';
+					//this.saveSrc();
+					this.$router.push(`/channels/${channel.id}`);
 				}
 			}));
 			this.$root.menu({
 				items: [{
 					text: this.$t('_timelines.home'),
 					icon: faHome,
-					action: () => { this.setSrc('home') }
+					action: () => { this.src = 'home'; this.saveSrc(); }
 				}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
 					text: this.$t('_timelines.local'),
 					icon: faComments,
-					action: () => { this.setSrc('local') }
+					action: () => { this.src = 'local'; this.saveSrc(); }
 				}, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
 					text: this.$t('_timelines.social'),
 					icon: faShareAlt,
-					action: () => { this.setSrc('social') }
+					action: () => { this.src = 'social'; this.saveSrc(); }
 				}, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : {
 					text: this.$t('_timelines.global'),
 					icon: faGlobe,
-					action: () => { this.setSrc('global') }
-				}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
+					action: () => { this.src = 'global'; this.saveSrc(); }
+				}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems],
 				fixed: true,
 				noCenter: true,
 				source: ev.currentTarget || ev.target
@@ -174,14 +197,13 @@ export default Vue.extend({
 			});
 		},
 
-		setSrc(src) {
-			this.src = src;
-		},
-
 		saveSrc() {
 			this.$store.commit('deviceUser/setTl', {
 				src: this.src,
-				arg: this.src == 'list' ? this.list : this.antenna
+				arg:
+					this.src === 'list' ? this.list :
+					this.src === 'antenna' ? this.antenna :
+					this.channel
 			});
 		},
 
diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue
index 1c33eaa76e..eda8914c4a 100644
--- a/src/client/pages/messaging/messaging-room.form.vue
+++ b/src/client/pages/messaging/messaging-room.form.vue
@@ -53,7 +53,7 @@ export default Vue.extend({
 		};
 	},
 	computed: {
-		draftId(): string {
+		draftKey(): string {
 			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
 		},
 		canSend(): boolean {
@@ -79,7 +79,7 @@ export default Vue.extend({
 		autosize(this.$refs.text);
 
 		// 書きかけの投稿を復元
-		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
+		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
 		if (draft) {
 			this.text = draft.data.text;
 			this.file = draft.data.file;
@@ -199,7 +199,7 @@ export default Vue.extend({
 		saveDraft() {
 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
 
-			data[this.draftId] = {
+			data[this.draftKey] = {
 				updatedAt: new Date(),
 				data: {
 					text: this.text,
@@ -213,7 +213,7 @@ export default Vue.extend({
 		deleteDraft() {
 			const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
 
-			delete data[this.draftId];
+			delete data[this.draftKey];
 
 			localStorage.setItem('message_drafts', JSON.stringify(data));
 		},
diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue
index 16e786bfc8..6fb3116912 100644
--- a/src/client/pages/my-settings/index.vue
+++ b/src/client/pages/my-settings/index.vue
@@ -3,11 +3,11 @@
 	<portal to="icon"><fa :icon="faCog"/></portal>
 	<portal to="title">{{ $t('accountSettings') }}</portal>
 
-	<x-profile-setting/>
-	<x-privacy-setting/>
-	<x-reaction-setting/>
+	<x-profile-setting class="_vMargin"/>
+	<x-privacy-setting class="_vMargin"/>
+	<x-reaction-setting class="_vMargin"/>
 
-	<section class="_card">
+	<section class="_card _vMargin">
 		<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
 		<div class="_content">
 			<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
@@ -24,14 +24,14 @@
 		</div>
 	</section>
 
-	<x-import-export/>
-	<x-drive/>
-	<x-mute-block/>
-	<x-word-mute/>
-	<x-security/>
-	<x-2fa/>
-	<x-integration/>
-	<x-api/>
+	<x-import-export class="_vMargin"/>
+	<x-drive class="_vMargin"/>
+	<x-mute-block class="_vMargin"/>
+	<x-word-mute class="_vMargin"/>
+	<x-security class="_vMargin"/>
+	<x-2fa class="_vMargin"/>
+	<x-integration class="_vMargin"/>
+	<x-api class="_vMargin"/>
 
 	<router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link>
 
diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue
index a2a5ab80f4..9d3959fcc2 100644
--- a/src/client/pages/preferences/index.vue
+++ b/src/client/pages/preferences/index.vue
@@ -5,11 +5,11 @@
 
 	<router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link>
 
-	<x-theme/>
+	<x-theme class="_vMargin"/>
 
-	<x-sidebar/>
+	<x-sidebar class="_vMargin"/>
 
-	<x-plugins/>
+	<x-plugins class="_vMargin"/>
 
 	<section class="_card _vMargin">
 		<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
@@ -50,6 +50,11 @@
 				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
 				<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
 			</mk-select>
+			<mk-select v-model="sfxChannel">
+				<template #label>{{ $t('_sfx.channel') }}</template>
+				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
+				<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
+			</mk-select>
 		</div>
 	</section>
 
@@ -142,10 +147,14 @@ const sounds = [
 	'syuilo/pirori',
 	'syuilo/pirori-wet',
 	'syuilo/pirori-square-wet',
+	'syuilo/square-pico',
+	'syuilo/reverved',
+	'syuilo/ryukyu',
 	'aisha/1',
 	'aisha/2',
 	'aisha/3',
 	'noizenecio/kick_gaba',
+	'noizenecio/kick_gaba2',
 ];
 
 export default Vue.extend({
@@ -272,6 +281,11 @@ export default Vue.extend({
 			set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
 		},
 
+		sfxChannel: {
+			get() { return this.$store.state.device.sfxChannel; },
+			set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
+		},
+
 		volumeIcon: {
 			get() {
 				return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
diff --git a/src/client/router.ts b/src/client/router.ts
index a741aeb955..c506dd6be0 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -29,6 +29,10 @@ export const router = new VueRouter({
 		{ path: '/explore', component: page('explore') },
 		{ path: '/explore/tags/:tag', props: true, component: page('explore') },
 		{ path: '/search', component: page('search') },
+		{ path: '/channels', component: page('channels') },
+		{ path: '/channels/new', component: page('channel-editor') },
+		{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
+		{ path: '/channels/:channelId', component: page('channel'), props: true },
 		{ path: '/my/notifications', component: page('notifications') },
 		{ path: '/my/favorites', component: page('favorites') },
 		{ path: '/my/messages', component: page('messages') },
diff --git a/src/client/store.ts b/src/client/store.ts
index f1d4770530..7e747a570d 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -1,7 +1,7 @@
 import Vuex from 'vuex';
 import createPersistedState from 'vuex-persistedstate';
 import * as nestedProperty from 'nested-property';
-import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
+import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
 import { AiScript, utils, values } from '@syuilo/aiscript';
 import { apiUrl, deckmode } from './config';
@@ -90,6 +90,7 @@ export const defaultDeviceSettings = {
 	sfxChat: 'syuilo/pope1',
 	sfxChatBg: 'syuilo/waon',
 	sfxAntenna: 'syuilo/triple',
+	sfxChannel: 'syuilo/square-pico',
 	userData: {},
 };
 
@@ -213,6 +214,11 @@ export default () => new Vuex.Store({
 				get show() { return getters.isSignedIn; },
 				to: '/my/pages',
 			},
+			channels: {
+				title: 'channel',
+				icon: faSatelliteDish,
+				to: '/channels',
+			},
 			games: {
 				title: 'games',
 				icon: faGamepad,
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index 35d79421c4..d401e807a9 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -72,5 +72,6 @@
 		X12: 'rgba(255, 255, 255, 0.1)',
 		X13: 'rgba(255, 255, 255, 0.15)',
 		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
 	},
 }
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index 0d5071a682..50aa0cd235 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -72,5 +72,6 @@
 		X12: 'rgba(0, 0, 0, 0.1)',
 		X13: 'rgba(0, 0, 0, 0.15)',
 		X14: ':alpha<0.5<@navBg',
+		X15: ':alpha<0<@panel',
 	},
 }
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 6ffc56ee08..e2acdeafd1 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -38,7 +38,7 @@ import { FollowRequest } from '../models/entities/follow-request';
 import { Emoji } from '../models/entities/emoji';
 import { ReversiGame } from '../models/entities/games/reversi/game';
 import { ReversiMatching } from '../models/entities/games/reversi/matching';
-import { UserNotePining } from '../models/entities/user-note-pinings';
+import { UserNotePining } from '../models/entities/user-note-pining';
 import { Poll } from '../models/entities/poll';
 import { UserKeypair } from '../models/entities/user-keypair';
 import { UserPublickey } from '../models/entities/user-publickey';
@@ -60,6 +60,9 @@ import { PromoRead } from '../models/entities/promo-read';
 import { program } from '../argv';
 import { Relay } from '../models/entities/relay';
 import { MutedNote } from '../models/entities/muted-note';
+import { Channel } from '../models/entities/channel';
+import { ChannelFollowing } from '../models/entities/channel-following';
+import { ChannelNotePining } from '../models/entities/channel-note-pining';
 
 const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
 
@@ -153,6 +156,9 @@ export const entities = [
 	ReversiMatching,
 	Relay,
 	MutedNote,
+	Channel,
+	ChannelFollowing,
+	ChannelNotePining,
 	...charts as any
 ];
 
diff --git a/src/misc/api-permissions.ts b/src/misc/api-permissions.ts
index be3c30f7d9..de9fdea52c 100644
--- a/src/misc/api-permissions.ts
+++ b/src/misc/api-permissions.ts
@@ -25,4 +25,6 @@ export const kinds = [
 	'read:page-likes',
 	'read:user-groups',
 	'write:user-groups',
+	'read:channels',
+	'write:channels',
 ];
diff --git a/src/models/entities/channel-following.ts b/src/models/entities/channel-following.ts
new file mode 100644
index 0000000000..fca801e5ab
--- /dev/null
+++ b/src/models/entities/channel-following.ts
@@ -0,0 +1,43 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+import { Channel } from './channel';
+
+@Entity()
+@Index(['followerId', 'followeeId'], { unique: true })
+export class ChannelFollowing {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the ChannelFollowing.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The followee channel ID.'
+	})
+	public followeeId: Channel['id'];
+
+	@ManyToOne(type => Channel, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public followee: Channel | null;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The follower user ID.'
+	})
+	public followerId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public follower: User | null;
+}
diff --git a/src/models/entities/channel-note-pining.ts b/src/models/entities/channel-note-pining.ts
new file mode 100644
index 0000000000..26a7eb501f
--- /dev/null
+++ b/src/models/entities/channel-note-pining.ts
@@ -0,0 +1,35 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { Note } from './note';
+import { Channel } from './channel';
+import { id } from '../id';
+
+@Entity()
+@Index(['channelId', 'noteId'], { unique: true })
+export class ChannelNotePining {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the ChannelNotePining.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column(id())
+	public channelId: Channel['id'];
+
+	@ManyToOne(type => Channel, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public channel: Channel | null;
+
+	@Column(id())
+	public noteId: Note['id'];
+
+	@ManyToOne(type => Note, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public note: Note | null;
+}
diff --git a/src/models/entities/channel.ts b/src/models/entities/channel.ts
new file mode 100644
index 0000000000..1868f75143
--- /dev/null
+++ b/src/models/entities/channel.ts
@@ -0,0 +1,74 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+import { DriveFile } from './drive-file';
+
+@Entity()
+export class Channel {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the Channel.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		nullable: true
+	})
+	public lastNotedAt: Date | null;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The owner ID.'
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'SET NULL'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column('varchar', {
+		length: 128,
+		comment: 'The name of the Channel.'
+	})
+	public name: string;
+
+	@Column('varchar', {
+		length: 2048, nullable: true,
+		comment: 'The description of the Channel.'
+	})
+	public description: string | null;
+
+	@Column({
+		...id(),
+		nullable: true,
+		comment: 'The ID of banner Channel.'
+	})
+	public bannerId: DriveFile['id'] | null;
+
+	@ManyToOne(type => DriveFile, {
+		onDelete: 'SET NULL'
+	})
+	@JoinColumn()
+	public banner: DriveFile | null;
+
+	@Index()
+	@Column('integer', {
+		default: 0,
+		comment: 'The count of notes.'
+	})
+	public notesCount: number;
+
+	@Index()
+	@Column('integer', {
+		default: 0,
+		comment: 'The count of users.'
+	})
+	public usersCount: number;
+}
diff --git a/src/models/entities/note-unread.ts b/src/models/entities/note-unread.ts
index 2d18728256..57dda4fafd 100644
--- a/src/models/entities/note-unread.ts
+++ b/src/models/entities/note-unread.ts
@@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
 import { User } from './user';
 import { Note } from './note';
 import { id } from '../id';
+import { Channel } from './channel';
 
 @Entity()
 @Index(['userId', 'noteId'], { unique: true })
@@ -29,15 +30,34 @@ export class NoteUnread {
 	@JoinColumn()
 	public note: Note | null;
 
+	/**
+	 * メンションか否か
+	 */
+	@Index()
+	@Column('boolean')
+	public isMentioned: boolean;
+
+	/**
+	 * ダイレクト投稿か否か
+	 */
+	@Index()
+	@Column('boolean')
+	public isSpecified: boolean;
+
+	//#region Denormalized fields
+	@Index()
 	@Column({
 		...id(),
 		comment: '[Denormalized]'
 	})
 	public noteUserId: User['id'];
 
-	/**
-	 * ダイレクト投稿か
-	 */
-	@Column('boolean')
-	public isSpecified: boolean;
+	@Index()
+	@Column({
+		...id(),
+		nullable: true,
+		comment: '[Denormalized]'
+	})
+	public noteChannelId: Channel['id'] | null;
+	//#endregion
 }
diff --git a/src/models/entities/note.ts b/src/models/entities/note.ts
index 196be1e350..2be7d2b33c 100644
--- a/src/models/entities/note.ts
+++ b/src/models/entities/note.ts
@@ -3,7 +3,7 @@ import { User } from './user';
 import { DriveFile } from './drive-file';
 import { id } from '../id';
 import { noteVisibilities } from '../../types';
-
+import { Channel } from './channel';
 
 @Entity()
 @Index('IDX_NOTE_TAGS', { synchronize: false })
@@ -173,6 +173,20 @@ export class Note {
 	})
 	public hasPoll: boolean;
 
+	@Index()
+	@Column({
+		...id(),
+		nullable: true, default: null,
+		comment: 'The ID of source channel.'
+	})
+	public channelId: Channel['id'] | null;
+
+	@ManyToOne(type => Channel, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public channel: Channel | null;
+
 	//#region Denormalized fields
 	@Index()
 	@Column('varchar', {
diff --git a/src/models/entities/user-note-pinings.ts b/src/models/entities/user-note-pining.ts
similarity index 100%
rename from src/models/entities/user-note-pinings.ts
rename to src/models/entities/user-note-pining.ts
diff --git a/src/models/index.ts b/src/models/index.ts
index e58d8b551d..dd05dcbcc6 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -15,7 +15,7 @@ import { DriveFileRepository } from './repositories/drive-file';
 import { DriveFolderRepository } from './repositories/drive-folder';
 import { Log } from './entities/log';
 import { AccessToken } from './entities/access-token';
-import { UserNotePining } from './entities/user-note-pinings';
+import { UserNotePining } from './entities/user-note-pining';
 import { SigninRepository } from './repositories/signin';
 import { MessagingMessageRepository } from './repositories/messaging-message';
 import { ReversiGameRepository } from './repositories/games/reversi/game';
@@ -53,7 +53,10 @@ import { PromoNote } from './entities/promo-note';
 import { PromoRead } from './entities/promo-read';
 import { EmojiRepository } from './repositories/emoji';
 import { RelayRepository } from './repositories/relay';
+import { ChannelRepository } from './repositories/channel';
 import { MutedNote } from './entities/muted-note';
+import { ChannelFollowing } from './entities/channel-following';
+import { ChannelNotePining } from './entities/channel-note-pining';
 
 export const Announcements = getRepository(Announcement);
 export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -110,3 +113,6 @@ export const PromoNotes = getRepository(PromoNote);
 export const PromoReads = getRepository(PromoRead);
 export const Relays = getCustomRepository(RelayRepository);
 export const MutedNotes = getRepository(MutedNote);
+export const Channels = getCustomRepository(ChannelRepository);
+export const ChannelFollowings = getRepository(ChannelFollowing);
+export const ChannelNotePinings = getRepository(ChannelNotePining);
diff --git a/src/models/repositories/channel.ts b/src/models/repositories/channel.ts
new file mode 100644
index 0000000000..2a90419922
--- /dev/null
+++ b/src/models/repositories/channel.ts
@@ -0,0 +1,101 @@
+import { EntityRepository, Repository } from 'typeorm';
+import { Channel } from '../entities/channel';
+import { ensure } from '../../prelude/ensure';
+import { SchemaType } from '../../misc/schema';
+import { DriveFiles, ChannelFollowings, NoteUnreads } from '..';
+import { User } from '../entities/user';
+
+export type PackedChannel = SchemaType<typeof packedChannelSchema>;
+
+@EntityRepository(Channel)
+export class ChannelRepository extends Repository<Channel> {
+	public async pack(
+		src: Channel['id'] | Channel,
+		me?: User['id'] | User | null | undefined,
+	): Promise<PackedChannel> {
+		const channel = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
+		const meId = me ? typeof me === 'string' ? me : me.id : null;
+
+		const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null;
+
+		const hasUnreadNote = me ? (await NoteUnreads.findOne({ noteChannelId: channel.id, userId: meId })) != null : undefined;
+
+		const following = await ChannelFollowings.findOne({
+			followerId: meId,
+			followeeId: channel.id,
+		});
+
+		return {
+			id: channel.id,
+			createdAt: channel.createdAt.toISOString(),
+			lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null,
+			name: channel.name,
+			description: channel.description,
+			userId: channel.userId,
+			bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null,
+			usersCount: channel.usersCount,
+			notesCount: channel.notesCount,
+
+			...(me ? {
+				isFollowing: following != null,
+				hasUnreadNote,
+			} : {})
+		};
+	}
+}
+
+export const packedChannelSchema = {
+	type: 'object' as const,
+	optional: false as const, nullable: false as const,
+	properties: {
+		id: {
+			type: 'string' as const,
+			optional: false as const, nullable: false as const,
+			format: 'id',
+			description: 'The unique identifier for this Channel.',
+			example: 'xxxxxxxxxx',
+		},
+		createdAt: {
+			type: 'string' as const,
+			optional: false as const, nullable: false as const,
+			format: 'date-time',
+			description: 'The date that the Channel was created.'
+		},
+		lastNotedAt: {
+			type: 'string' as const,
+			optional: false as const, nullable: true as const,
+			format: 'date-time',
+		},
+		name: {
+			type: 'string' as const,
+			optional: false as const, nullable: false as const,
+			description: 'The name of the Channel.'
+		},
+		description: {
+			type: 'string' as const,
+			nullable: true as const, optional: false as const,
+		},
+		bannerUrl: {
+			type: 'string' as const,
+			format: 'url',
+			nullable: true as const, optional: false as const,
+		},
+		notesCount: {
+			type: 'number' as const,
+			nullable: false as const, optional: false as const,
+		},
+		usersCount: {
+			type: 'number' as const,
+			nullable: false as const, optional: false as const,
+		},
+		isFollowing: {
+			type: 'boolean' as const,
+			optional: true as const, nullable: false as const,
+		},
+		userId: {
+			type: 'string' as const,
+			nullable: false as const, optional: false as const,
+			format: 'id',
+		},
+	},
+};
diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts
index 3c5500f493..b60744bb2b 100644
--- a/src/models/repositories/note.ts
+++ b/src/models/repositories/note.ts
@@ -1,7 +1,7 @@
 import { EntityRepository, Repository, In } from 'typeorm';
 import { Note } from '../entities/note';
 import { User } from '../entities/user';
-import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls } from '..';
+import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..';
 import { ensure } from '../../prelude/ensure';
 import { SchemaType } from '../../misc/schema';
 import { awaitAll } from '../../prelude/await-all';
@@ -207,6 +207,12 @@ export class NoteRepository extends Repository<Note> {
 			text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url || note.uri}`;
 		}
 
+		const channel = note.channelId
+			? note.channel
+				? note.channel
+				: await Channels.findOne(note.channelId)
+			: null;
+
 		const packed = await awaitAll({
 			id: note.id,
 			createdAt: note.createdAt.toISOString(),
@@ -227,6 +233,11 @@ export class NoteRepository extends Repository<Note> {
 			files: DriveFiles.packMany(note.fileIds),
 			replyId: note.replyId,
 			renoteId: note.renoteId,
+			channelId: note.channelId || undefined,
+			channel: channel ? {
+				id: channel.id,
+				name: channel.name,
+			} : undefined,
 			mentions: note.mentions.length > 0 ? note.mentions : undefined,
 			uri: note.uri || undefined,
 			url: note.url || undefined,
@@ -391,6 +402,16 @@ export const packedNoteSchema = {
 			type: 'object' as const,
 			optional: true as const, nullable: true as const,
 		},
-
+		channelId: {
+			type: 'string' as const,
+			optional: true as const, nullable: true as const,
+			format: 'id',
+			example: 'xxxxxxxxxx',
+		},
+		channel: {
+			type: 'object' as const,
+			optional: true as const, nullable: true as const,
+			ref: 'Channel'
+		},
 	},
 };
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 955a70ee60..ae5321b15a 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy';
 import { EntityRepository, Repository, In, Not } from 'typeorm';
 import { User, ILocalUser, IRemoteUser } from '../entities/user';
-import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes } from '..';
+import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..';
 import { ensure } from '../../prelude/ensure';
 import config from '../../config';
 import { SchemaType } from '../../misc/schema';
@@ -107,6 +107,17 @@ export class UserRepository extends Repository<User> {
 		return unread != null;
 	}
 
+	public async getHasUnreadChannel(userId: User['id']): Promise<boolean> {
+		const channels = await ChannelFollowings.find({ followerId: userId });
+
+		const unread = channels.length > 0 ? await NoteUnreads.findOne({
+			userId: userId,
+			noteChannelId: In(channels.map(x => x.id)),
+		}) : null;
+
+		return unread != null;
+	}
+
 	public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
 		const mute = await Mutings.find({
 			muterId: userId
@@ -139,7 +150,6 @@ export class UserRepository extends Repository<User> {
 		options?: {
 			detail?: boolean,
 			includeSecrets?: boolean,
-			includeHasUnreadNotes?: boolean
 		}
 	): Promise<PackedUser> {
 		const opts = Object.assign({
@@ -181,17 +191,6 @@ export class UserRepository extends Repository<User> {
 				select: ['name', 'host', 'url', 'aliases']
 			}) : [],
 
-			...(opts.includeHasUnreadNotes ? {
-				hasUnreadSpecifiedNotes: NoteUnreads.count({
-					where: { userId: user.id, isSpecified: true },
-					take: 1
-				}).then(count => count > 0),
-				hasUnreadMentions: NoteUnreads.count({
-					where: { userId: user.id },
-					take: 1
-				}).then(count => count > 0),
-			} : {}),
-
 			...(opts.detail ? {
 				url: profile!.url,
 				createdAt: user.createdAt.toISOString(),
@@ -233,8 +232,17 @@ export class UserRepository extends Repository<User> {
 				alwaysMarkNsfw: profile!.alwaysMarkNsfw,
 				carefulBot: profile!.carefulBot,
 				autoAcceptFollowed: profile!.autoAcceptFollowed,
+				hasUnreadSpecifiedNotes: NoteUnreads.count({
+					where: { userId: user.id, isSpecified: true },
+					take: 1
+				}).then(count => count > 0),
+				hasUnreadMentions: NoteUnreads.count({
+					where: { userId: user.id, isMentioned: true },
+					take: 1
+				}).then(count => count > 0),
 				hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id),
 				hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
+				hasUnreadChannel: this.getHasUnreadChannel(user.id),
 				hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
 				hasUnreadNotification: this.getHasUnreadNotification(user.id),
 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
@@ -276,7 +284,6 @@ export class UserRepository extends Repository<User> {
 		options?: {
 			detail?: boolean,
 			includeSecrets?: boolean,
-			includeHasUnreadNotes?: boolean
 		}
 	) {
 		return Promise.all(users.map(u => this.pack(u, me, options)));
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index 5213f872ec..9f6392174b 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -15,7 +15,7 @@ import { updateUsertags } from '../../../services/update-hashtag';
 import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models';
 import { User, IRemoteUser } from '../../../models/entities/user';
 import { Emoji } from '../../../models/entities/emoji';
-import { UserNotePining } from '../../../models/entities/user-note-pinings';
+import { UserNotePining } from '../../../models/entities/user-note-pining';
 import { genId } from '../../../misc/gen-id';
 import { instanceChart, usersChart } from '../../../services/chart';
 import { UserPublickey } from '../../../models/entities/user-publickey';
diff --git a/src/server/api/common/generate-channel-query.ts b/src/server/api/common/generate-channel-query.ts
new file mode 100644
index 0000000000..c0337b2c6b
--- /dev/null
+++ b/src/server/api/common/generate-channel-query.ts
@@ -0,0 +1,24 @@
+import { User } from '../../../models/entities/user';
+import { ChannelFollowings } from '../../../models';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: User | null) {
+	if (me == null) {
+		q.andWhere('note.channelId IS NULL');
+	} else {
+		q.leftJoinAndSelect('note.channel', 'channel');
+
+		const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing')
+			.select('channelFollowing.followeeId')
+			.where('channelFollowing.followerId = :followerId', { followerId: me.id });
+
+		q.andWhere(new Brackets(qb => { qb
+			// チャンネルのノートではない
+			.where('note.channelId IS NULL')
+			// または自分がフォローしているチャンネルのノート
+			.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
+		}));
+
+		q.setParameters(channelFollowingQuery.getParameters());
+	}
+}
diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts
new file mode 100644
index 0000000000..53436e703d
--- /dev/null
+++ b/src/server/api/endpoints/channels/create.ts
@@ -0,0 +1,68 @@
+import $ from 'cafy';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Channels, DriveFiles } from '../../../../models';
+import { Channel } from '../../../../models/entities/channel';
+import { genId } from '../../../../misc/gen-id';
+import { ID } from '../../../../misc/cafy-id';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true as const,
+
+	kind: 'write:channels',
+
+	params: {
+		name: {
+			validator: $.str.range(1, 128)
+		},
+
+		description: {
+			validator: $.nullable.optional.str.range(1, 2048)
+		},
+
+		bannerId: {
+			validator: $.nullable.optional.type(ID),
+		}
+	},
+
+	res: {
+		type: 'object' as const,
+		optional: false as const, nullable: false as const,
+		ref: 'Channel',
+	},
+
+	errors: {
+		noSuchFile: {
+			message: 'No such file.',
+			code: 'NO_SUCH_FILE',
+			id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	let banner = null;
+	if (ps.bannerId != null) {
+		banner = await DriveFiles.findOne({
+			id: ps.bannerId,
+			userId: user.id
+		});
+
+		if (banner == null) {
+			throw new ApiError(meta.errors.noSuchFile);
+		}
+	}
+
+	const channel = await Channels.save({
+		id: genId(),
+		createdAt: new Date(),
+		userId: user.id,
+		name: ps.name,
+		description: ps.description || null,
+		bannerId: banner ? banner.id : null,
+	} as Channel);
+
+	return await Channels.pack(channel, user);
+});
diff --git a/src/server/api/endpoints/channels/featured.ts b/src/server/api/endpoints/channels/featured.ts
new file mode 100644
index 0000000000..abb0a19e2d
--- /dev/null
+++ b/src/server/api/endpoints/channels/featured.ts
@@ -0,0 +1,28 @@
+import define from '../../define';
+import { Channels } from '../../../../models';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: false as const,
+
+	res: {
+		type: 'array' as const,
+		optional: false as const, nullable: false as const,
+		items: {
+			type: 'object' as const,
+			optional: false as const, nullable: false as const,
+			ref: 'Channel',
+		}
+	},
+};
+
+export default define(meta, async (ps, me) => {
+	const query = Channels.createQueryBuilder('channel')
+		.where('channel.lastNotedAt IS NOT NULL')
+		.orderBy('channel.lastNotedAt', 'DESC');
+
+	const channels = await query.take(10).getMany();
+
+	return await Promise.all(channels.map(x => Channels.pack(x, me)));
+});
diff --git a/src/server/api/endpoints/channels/follow.ts b/src/server/api/endpoints/channels/follow.ts
new file mode 100644
index 0000000000..bf2f2bbb57
--- /dev/null
+++ b/src/server/api/endpoints/channels/follow.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Channels, ChannelFollowings } from '../../../../models';
+import { genId } from '../../../../misc/gen-id';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true as const,
+
+	kind: 'write:channels',
+
+	params: {
+		channelId: {
+			validator: $.type(ID),
+		},
+	},
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: 'c0031718-d573-4e85-928e-10039f1fbb68'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const channel = await Channels.findOne({
+		id: ps.channelId,
+	});
+
+	if (channel == null) {
+		throw new ApiError(meta.errors.noSuchChannel);
+	}
+
+	await ChannelFollowings.save({
+		id: genId(),
+		createdAt: new Date(),
+		followerId: user.id,
+		followeeId: channel.id,
+	});
+});
diff --git a/src/server/api/endpoints/channels/followed.ts b/src/server/api/endpoints/channels/followed.ts
new file mode 100644
index 0000000000..05c2ec6a75
--- /dev/null
+++ b/src/server/api/endpoints/channels/followed.ts
@@ -0,0 +1,28 @@
+import define from '../../define';
+import { Channels, ChannelFollowings } from '../../../../models';
+
+export const meta = {
+	tags: ['channels', 'account'],
+
+	requireCredential: true as const,
+
+	kind: 'read:channels',
+
+	res: {
+		type: 'array' as const,
+		optional: false as const, nullable: false as const,
+		items: {
+			type: 'object' as const,
+			optional: false as const, nullable: false as const,
+			ref: 'Channel',
+		}
+	},
+};
+
+export default define(meta, async (ps, me) => {
+	const followings = await ChannelFollowings.find({
+		followerId: me.id,
+	});
+
+	return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me)));
+});
diff --git a/src/server/api/endpoints/channels/owned.ts b/src/server/api/endpoints/channels/owned.ts
new file mode 100644
index 0000000000..9e563c0ac5
--- /dev/null
+++ b/src/server/api/endpoints/channels/owned.ts
@@ -0,0 +1,28 @@
+import define from '../../define';
+import { Channels } from '../../../../models';
+
+export const meta = {
+	tags: ['channels', 'account'],
+
+	requireCredential: true as const,
+
+	kind: 'read:channels',
+
+	res: {
+		type: 'array' as const,
+		optional: false as const, nullable: false as const,
+		items: {
+			type: 'object' as const,
+			optional: false as const, nullable: false as const,
+			ref: 'Channel',
+		}
+	},
+};
+
+export default define(meta, async (ps, me) => {
+	const channels = await Channels.find({
+		userId: me.id,
+	});
+
+	return await Promise.all(channels.map(x => Channels.pack(x, me)));
+});
diff --git a/src/server/api/endpoints/channels/pin-note.ts b/src/server/api/endpoints/channels/pin-note.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts
new file mode 100644
index 0000000000..63057dd57f
--- /dev/null
+++ b/src/server/api/endpoints/channels/show.ts
@@ -0,0 +1,43 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Channels } from '../../../../models';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: false as const,
+
+	params: {
+		channelId: {
+			validator: $.type(ID),
+		},
+	},
+
+	res: {
+		type: 'object' as const,
+		optional: false as const, nullable: false as const,
+		ref: 'Channel',
+	},
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: '6f6c314b-7486-4897-8966-c04a66a02923'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	const channel = await Channels.findOne({
+		id: ps.channelId,
+	});
+
+	if (channel == null) {
+		throw new ApiError(meta.errors.noSuchChannel);
+	}
+
+	return await Channels.pack(channel, me);
+});
diff --git a/src/server/api/endpoints/channels/timeline.ts b/src/server/api/endpoints/channels/timeline.ts
new file mode 100644
index 0000000000..3ae28fc67a
--- /dev/null
+++ b/src/server/api/endpoints/channels/timeline.ts
@@ -0,0 +1,99 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Notes, Channels } from '../../../../models';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { activeUsersChart } from '../../../../services/chart';
+
+export const meta = {
+	tags: ['notes', 'channels'],
+
+	requireCredential: false as const,
+
+	params: {
+		channelId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': 'チャンネルのID'
+			}
+		},
+
+		limit: {
+			validator: $.optional.num.range(1, 100),
+			default: 10,
+			desc: {
+				'ja-JP': '最大数'
+			}
+		},
+
+		sinceId: {
+			validator: $.optional.type(ID),
+			desc: {
+				'ja-JP': '指定すると、その投稿を基点としてより新しい投稿を取得します'
+			}
+		},
+
+		untilId: {
+			validator: $.optional.type(ID),
+			desc: {
+				'ja-JP': '指定すると、その投稿を基点としてより古い投稿を取得します'
+			}
+		},
+
+		sinceDate: {
+			validator: $.optional.num,
+			desc: {
+				'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
+			}
+		},
+
+		untilDate: {
+			validator: $.optional.num,
+			desc: {
+				'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
+			}
+		},
+	},
+
+	res: {
+		type: 'array' as const,
+		optional: false as const, nullable: false as const,
+		items: {
+			type: 'object' as const,
+			optional: false as const, nullable: false as const,
+			ref: 'Note',
+		}
+	},
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f'
+		}
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const channel = await Channels.findOne({
+		id: ps.channelId,
+	});
+
+	if (channel == null) {
+		throw new ApiError(meta.errors.noSuchChannel);
+	}
+
+	//#region Construct query
+	const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+		.andWhere('note.channelId = :channelId', { channelId: channel.id })
+		.leftJoinAndSelect('note.user', 'user')
+		.leftJoinAndSelect('note.channel', 'channel');
+	//#endregion
+
+	const timeline = await query.take(ps.limit!).getMany();
+
+	activeUsersChart.update(user);
+
+	return await Notes.packMany(timeline, user);
+});
diff --git a/src/server/api/endpoints/channels/unfollow.ts b/src/server/api/endpoints/channels/unfollow.ts
new file mode 100644
index 0000000000..8cab5c36a6
--- /dev/null
+++ b/src/server/api/endpoints/channels/unfollow.ts
@@ -0,0 +1,42 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Channels, ChannelFollowings } from '../../../../models';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true as const,
+
+	kind: 'write:channels',
+
+	params: {
+		channelId: {
+			validator: $.type(ID),
+		},
+	},
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6'
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const channel = await Channels.findOne({
+		id: ps.channelId,
+	});
+
+	if (channel == null) {
+		throw new ApiError(meta.errors.noSuchChannel);
+	}
+
+	await ChannelFollowings.delete({
+		followerId: user.id,
+		followeeId: channel.id,
+	});
+});
diff --git a/src/server/api/endpoints/channels/update.ts b/src/server/api/endpoints/channels/update.ts
new file mode 100644
index 0000000000..8b94646ad1
--- /dev/null
+++ b/src/server/api/endpoints/channels/update.ts
@@ -0,0 +1,93 @@
+import $ from 'cafy';
+import { ID } from '../../../../misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Channels, DriveFiles } from '../../../../models';
+
+export const meta = {
+	tags: ['channels'],
+
+	requireCredential: true as const,
+
+	kind: 'write:channels',
+
+	params: {
+		channelId: {
+			validator: $.type(ID),
+		},
+
+		name: {
+			validator: $.optional.str.range(1, 128)
+		},
+
+		description: {
+			validator: $.nullable.optional.str.range(1, 2048)
+		},
+
+		bannerId: {
+			validator: $.nullable.optional.type(ID),
+		}
+	},
+
+	res: {
+		type: 'object' as const,
+		optional: false as const, nullable: false as const,
+		ref: 'Channel',
+	},
+
+	errors: {
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512'
+		},
+
+		accessDenied: {
+			message: 'You do not have edit privilege of the channel.',
+			code: 'ACCESS_DENIED',
+			id: '1fb7cb09-d46a-4fdf-b8df-057788cce513'
+		},
+
+		noSuchFile: {
+			message: 'No such file.',
+			code: 'NO_SUCH_FILE',
+			id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	const channel = await Channels.findOne({
+		id: ps.channelId,
+	});
+
+	if (channel == null) {
+		throw new ApiError(meta.errors.noSuchChannel);
+	}
+
+	if (channel.userId !== me.id) {
+		throw new ApiError(meta.errors.accessDenied);
+	}
+
+	let banner = undefined;
+	if (ps.bannerId != null) {
+		banner = await DriveFiles.findOne({
+			id: ps.bannerId,
+			userId: me.id
+		});
+
+		if (banner == null) {
+			throw new ApiError(meta.errors.noSuchFile);
+		}
+	} else if (ps.bannerId === null) {
+		banner = null;
+	}
+
+	await Channels.update(channel.id, {
+		...(ps.name !== undefined ? { name: ps.name } : {}),
+		...(ps.description !== undefined ? { description: ps.description } : {}),
+		...(banner ? { bannerId: banner.id } : {}),
+	});
+
+	return await Channels.pack(channel.id, me);
+});
diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts
index 02d59682b8..bceb9548ef 100644
--- a/src/server/api/endpoints/i.ts
+++ b/src/server/api/endpoints/i.ts
@@ -24,7 +24,6 @@ export default define(meta, async (ps, user, token) => {
 
 	return await Users.pack(user, user, {
 		detail: true,
-		includeHasUnreadNotes: true,
 		includeSecrets: isSecure
 	});
 });
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index 5076dad82a..b8c4900af7 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -7,11 +7,12 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
 import { ApiError } from '../../error';
 import { ID } from '../../../../misc/cafy-id';
 import { User } from '../../../../models/entities/user';
-import { Users, DriveFiles, Notes } from '../../../../models';
+import { Users, DriveFiles, Notes, Channels } from '../../../../models';
 import { DriveFile } from '../../../../models/entities/drive-file';
 import { Note } from '../../../../models/entities/note';
 import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
 import { noteVisibilities } from '../../../../types';
+import { Channel } from '../../../../models/entities/channel';
 
 let maxNoteTextLength = 500;
 
@@ -128,19 +129,26 @@ export const meta = {
 		},
 
 		replyId: {
-			validator: $.optional.type(ID),
+			validator: $.optional.nullable.type(ID),
 			desc: {
 				'ja-JP': '返信対象'
 			}
 		},
 
 		renoteId: {
-			validator: $.optional.type(ID),
+			validator: $.optional.nullable.type(ID),
 			desc: {
 				'ja-JP': 'Renote対象'
 			}
 		},
 
+		channelId: {
+			validator: $.optional.nullable.type(ID),
+			desc: {
+				'ja-JP': 'チャンネル'
+			}
+		},
+
 		poll: {
 			validator: $.optional.obj({
 				choices: $.arr($.str)
@@ -206,7 +214,13 @@ export const meta = {
 			message: 'Poll is already expired.',
 			code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
 			id: '04da457d-b083-4055-9082-955525eda5a5'
-		}
+		},
+
+		noSuchChannel: {
+			message: 'No such channel.',
+			code: 'NO_SUCH_CHANNEL',
+			id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb'
+		},
 	}
 };
 
@@ -269,6 +283,15 @@ export default define(meta, async (ps, user) => {
 		throw new ApiError(meta.errors.contentRequired);
 	}
 
+	let channel: Channel | undefined;
+	if (ps.channelId != null) {
+		channel = await Channels.findOne(ps.channelId);
+
+		if (channel == null) {
+			throw new ApiError(meta.errors.noSuchChannel);
+		}
+	}
+
 	// 投稿を作成
 	const note = await create(user, {
 		createdAt: new Date(),
@@ -286,6 +309,7 @@ export default define(meta, async (ps, user) => {
 		localOnly: ps.localOnly,
 		visibility: ps.visibility,
 		visibleUsers,
+		channel,
 		apMentions: ps.noExtractMentions ? [] : undefined,
 		apHashtags: ps.noExtractHashtags ? [] : undefined,
 		apEmojis: ps.noExtractEmojis ? [] : undefined,
diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts
index 5e61c17841..6d99f1fdbc 100644
--- a/src/server/api/endpoints/notes/global-timeline.ts
+++ b/src/server/api/endpoints/notes/global-timeline.ts
@@ -80,6 +80,7 @@ export default define(meta, async (ps, user) => {
 	const query = makePaginationQuery(Notes.createQueryBuilder('note'),
 			ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
 		.andWhere('note.visibility = \'public\'')
+		.andWhere('note.channelId IS NULL')
 		.leftJoinAndSelect('note.user', 'user');
 
 	generateRepliesQuery(query, user);
diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts
index fab4e9f4e5..2b91b8c67b 100644
--- a/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
 import { injectPromo } from '../../common/inject-promo';
 import { injectFeatured } from '../../common/inject-featured';
 import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
 
 export const meta = {
 	desc: {
@@ -131,6 +132,7 @@ export default define(meta, async (ps, user) => {
 		.leftJoinAndSelect('note.user', 'user')
 		.setParameters(followingQuery.getParameters());
 
+	generateChannelQuery(query, user);
 	generateRepliesQuery(query, user);
 	generateVisibilityQuery(query, user);
 	generateMutedUserQuery(query, user);
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index 38ec1d4727..51e35e6241 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -13,6 +13,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
 import { injectPromo } from '../../common/inject-promo';
 import { injectFeatured } from '../../common/inject-featured';
 import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
 
 export const meta = {
 	desc: {
@@ -99,6 +100,7 @@ export default define(meta, async (ps, user) => {
 		.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
 		.leftJoinAndSelect('note.user', 'user');
 
+	generateChannelQuery(query, user);
 	generateRepliesQuery(query, user);
 	generateVisibilityQuery(query, user);
 	if (user) generateMutedUserQuery(query, user);
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index 657739820b..f09f3d1733 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -11,6 +11,7 @@ import { generateRepliesQuery } from '../../common/generate-replies-query';
 import { injectPromo } from '../../common/inject-promo';
 import { injectFeatured } from '../../common/inject-featured';
 import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
 
 export const meta = {
 	desc: {
@@ -124,6 +125,7 @@ export default define(meta, async (ps, user) => {
 		.leftJoinAndSelect('note.user', 'user')
 		.setParameters(followingQuery.getParameters());
 
+	generateChannelQuery(query, user);
 	generateRepliesQuery(query, user);
 	generateVisibilityQuery(query, user);
 	generateMutedUserQuery(query, user);
diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts
index 82a95ad3d7..9b7c31e7bb 100644
--- a/src/server/api/stream/channel.ts
+++ b/src/server/api/stream/channel.ts
@@ -27,6 +27,10 @@ export default abstract class Channel {
 		return this.connection.muting;
 	}
 
+	protected get followingChannels() {
+		return this.connection.followingChannels;
+	}
+
 	protected get subscriber() {
 		return this.connection.subscriber;
 	}
diff --git a/src/server/api/stream/channels/channel.ts b/src/server/api/stream/channels/channel.ts
new file mode 100644
index 0000000000..c24b3db937
--- /dev/null
+++ b/src/server/api/stream/channels/channel.ts
@@ -0,0 +1,49 @@
+import autobind from 'autobind-decorator';
+import Channel from '../channel';
+import { Notes } from '../../../../models';
+import { isMutedUserRelated } from '../../../../misc/is-muted-user-related';
+import { PackedNote } from '../../../../models/repositories/note';
+
+export default class extends Channel {
+	public readonly chName = 'channel';
+	public static shouldShare = false;
+	public static requireCredential = false;
+	private channelId: string;
+
+	@autobind
+	public async init(params: any) {
+		this.channelId = params.channelId as string;
+
+		// Subscribe stream
+		this.subscriber.on('notesStream', this.onNote);
+	}
+
+	@autobind
+	private async onNote(note: PackedNote) {
+		if (note.channelId !== this.channelId) return;
+
+		// リプライなら再pack
+		if (note.replyId != null) {
+			note.reply = await Notes.pack(note.replyId, this.user, {
+				detail: true
+			});
+		}
+		// Renoteなら再pack
+		if (note.renoteId != null) {
+			note.renote = await Notes.pack(note.renoteId, this.user, {
+				detail: true
+			});
+		}
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (isMutedUserRelated(note, this.muting)) return;
+
+		this.send('note', note);
+	}
+
+	@autobind
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off('notesStream', this.onNote);
+	}
+}
diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts
index d530907d8d..8c97e67226 100644
--- a/src/server/api/stream/channels/global-timeline.ts
+++ b/src/server/api/stream/channels/global-timeline.ts
@@ -25,6 +25,7 @@ export default class extends Channel {
 	@autobind
 	private async onNote(note: PackedNote) {
 		if (note.visibility !== 'public') return;
+		if (note.channelId != null) return;
 
 		// リプライなら再pack
 		if (note.replyId != null) {
diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts
index caf4ccf5e9..15fe7fa6f0 100644
--- a/src/server/api/stream/channels/home-timeline.ts
+++ b/src/server/api/stream/channels/home-timeline.ts
@@ -18,8 +18,12 @@ export default class extends Channel {
 
 	@autobind
 	private async onNote(note: PackedNote) {
-		// その投稿のユーザーをフォローしていなかったら弾く
-		if (this.user!.id !== note.userId && !this.following.includes(note.userId)) return;
+		if (note.channelId) {
+			if (!this.followingChannels.includes(note.channelId)) return;
+		} else {
+			// その投稿のユーザーをフォローしていなかったら弾く
+			if ((this.user!.id !== note.userId) && !this.following.includes(note.userId)) return;
+		}
 
 		if (['followers', 'specified'].includes(note.visibility)) {
 			note = await Notes.pack(note.id, this.user!, {
diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts
index 1aec98aa72..4dc5f01a32 100644
--- a/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/src/server/api/stream/channels/hybrid-timeline.ts
@@ -23,11 +23,15 @@ export default class extends Channel {
 
 	@autobind
 	private async onNote(note: PackedNote) {
-		// 自分自身の投稿 または その投稿のユーザーをフォローしている または 全体公開のローカルの投稿 の場合だけ
+		// チャンネルの投稿ではなく、自分自身の投稿 または
+		// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
+		// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
+		// フォローしているチャンネルの投稿 の場合だけ
 		if (!(
-			this.user!.id === note.userId ||
-			this.following.includes(note.userId) ||
-			((note.user as PackedUser).host == null && note.visibility === 'public')
+			(note.channelId == null && this.user!.id === note.userId) ||
+			(note.channelId == null && this.following.includes(note.userId)) ||
+			(note.channelId == null && ((note.user as PackedUser).host == null && note.visibility === 'public')) ||
+			(note.channelId != null && this.followingChannels.includes(note.channelId))
 		)) return;
 
 		if (['followers', 'specified'].includes(note.visibility)) {
diff --git a/src/server/api/stream/channels/index.ts b/src/server/api/stream/channels/index.ts
index 6efad078c6..1841573043 100644
--- a/src/server/api/stream/channels/index.ts
+++ b/src/server/api/stream/channels/index.ts
@@ -11,6 +11,7 @@ import messaging from './messaging';
 import messagingIndex from './messaging-index';
 import drive from './drive';
 import hashtag from './hashtag';
+import channel from './channel';
 import admin from './admin';
 import gamesReversi from './games/reversi';
 import gamesReversiGame from './games/reversi-game';
@@ -29,6 +30,7 @@ export default {
 	messagingIndex,
 	drive,
 	hashtag,
+	channel,
 	admin,
 	gamesReversi,
 	gamesReversiGame
diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts
index 6426ccc23f..baeae86603 100644
--- a/src/server/api/stream/channels/local-timeline.ts
+++ b/src/server/api/stream/channels/local-timeline.ts
@@ -27,6 +27,7 @@ export default class extends Channel {
 	private async onNote(note: PackedNote) {
 		if ((note.user as PackedUser).host !== null) return;
 		if (note.visibility !== 'public') return;
+		if (note.channelId != null && !this.followingChannels.includes(note.channelId)) return;
 
 		// リプライなら再pack
 		if (note.replyId != null) {
diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts
index bebf88a7cd..d420c6e794 100644
--- a/src/server/api/stream/index.ts
+++ b/src/server/api/stream/index.ts
@@ -7,7 +7,8 @@ import Channel from './channel';
 import channels from './channels';
 import { EventEmitter } from 'events';
 import { User } from '../../../models/entities/user';
-import { Users, Followings, Mutings, UserProfiles } from '../../../models';
+import { Channel as ChannelModel } from '../../../models/entities/channel';
+import { Users, Followings, Mutings, UserProfiles, ChannelFollowings } from '../../../models';
 import { ApiError } from '../error';
 import { AccessToken } from '../../../models/entities/access-token';
 import { UserProfile } from '../../../models/entities/user-profile';
@@ -20,6 +21,7 @@ export default class Connection {
 	public userProfile?: UserProfile;
 	public following: User['id'][] = [];
 	public muting: User['id'][] = [];
+	public followingChannels: ChannelModel['id'][] = [];
 	public token?: AccessToken;
 	private wsConnection: websocket.connection;
 	public subscriber: EventEmitter;
@@ -27,6 +29,7 @@ export default class Connection {
 	private subscribingNotes: any = {};
 	private followingClock: NodeJS.Timer;
 	private mutingClock: NodeJS.Timer;
+	private followingChannelsClock: NodeJS.Timer;
 	private userProfileClock: NodeJS.Timer;
 
 	constructor(
@@ -53,6 +56,9 @@ export default class Connection {
 			this.updateMuting();
 			this.mutingClock = setInterval(this.updateMuting, 5000);
 
+			this.updateFollowingChannels();
+			this.followingChannelsClock = setInterval(this.updateFollowingChannels, 5000);
+
 			this.updateUserProfile();
 			this.userProfileClock = setInterval(this.updateUserProfile, 5000);
 		}
@@ -268,6 +274,18 @@ export default class Connection {
 		this.muting = mutings.map(x => x.muteeId);
 	}
 
+	@autobind
+	private async updateFollowingChannels() {
+		const followings = await ChannelFollowings.find({
+			where: {
+				followerId: this.user!.id
+			},
+			select: ['followeeId']
+		});
+
+		this.followingChannels = followings.map(x => x.followeeId);
+	}
+
 	@autobind
 	private async updateUserProfile() {
 		this.userProfile = await UserProfiles.findOne({
@@ -286,6 +304,7 @@ export default class Connection {
 
 		if (this.followingClock) clearInterval(this.followingClock);
 		if (this.mutingClock) clearInterval(this.mutingClock);
+		if (this.followingChannelsClock) clearInterval(this.followingChannelsClock);
 		if (this.userProfileClock) clearInterval(this.userProfileClock);
 	}
 }
diff --git a/src/server/web/index.ts b/src/server/web/index.ts
index 42c37b61aa..18cd102eba 100644
--- a/src/server/web/index.ts
+++ b/src/server/web/index.ts
@@ -17,7 +17,7 @@ import packFeed from './feed';
 import { fetchMeta } from '../../misc/fetch-meta';
 import { genOpenapiSpec } from '../api/openapi/gen-spec';
 import config from '../../config';
-import { Users, Notes, Emojis, UserProfiles, Pages } from '../../models';
+import { Users, Notes, Emojis, UserProfiles, Pages, Channels } from '../../models';
 import parseAcct from '../../misc/acct/parse';
 import getNoteSummary from '../../misc/get-note-summary';
 import { ensure } from '../../prelude/ensure';
@@ -188,7 +188,7 @@ router.get('/@:user.json', async ctx => {
 	}
 });
 
-//#region for crawlers
+//#region SSR (for crawlers)
 // User
 router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
 	const { username, host } = parseAcct(ctx.params.user);
@@ -297,6 +297,28 @@ router.get('/@:user/pages/:page', async ctx => {
 
 	ctx.status = 404;
 });
+
+// Channel
+router.get('/channels/:channel', async ctx => {
+	const channel = await Channels.findOne({
+		id: ctx.params.channel,
+	});
+
+	if (channel) {
+		const _channel = await Channels.pack(channel);
+		const meta = await fetchMeta();
+		await ctx.render('channel', {
+			channel: _channel,
+			instanceName: meta.name || 'Misskey'
+		});
+
+		ctx.set('Cache-Control', 'public, max-age=180');
+
+		return;
+	}
+
+	ctx.status = 404;
+});
 //#endregion
 
 router.get('/info', async ctx => {
diff --git a/src/server/web/views/channel.pug b/src/server/web/views/channel.pug
new file mode 100644
index 0000000000..273632f0e0
--- /dev/null
+++ b/src/server/web/views/channel.pug
@@ -0,0 +1,21 @@
+extends ./base
+
+block vars
+	- const title = channel.name;
+	- const url = `${config.url}/channels/${channel.id}`;
+
+block title
+	= `${title} | ${instanceName}`
+
+block desc
+	meta(name='description' content= channel.description)
+
+block og
+	meta(property='og:type'        content='article')
+	meta(property='og:title'       content= title)
+	meta(property='og:description' content= channel.description)
+	meta(property='og:url'         content= url)
+	meta(property='og:image'       content= channel.bannerUrl)
+
+block meta
+	meta(name='twitter:card' content='summary')
diff --git a/src/services/add-note-to-antenna.ts b/src/services/add-note-to-antenna.ts
index 57a0df2752..e486494c9b 100644
--- a/src/services/add-note-to-antenna.ts
+++ b/src/services/add-note-to-antenna.ts
@@ -28,6 +28,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U
 			select: ['muteeId']
 		});
 
+		// Copy
 		const _note: Note = {
 			...note
 		};
diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts
index fcddc50636..1ff5476b40 100644
--- a/src/services/i/pin.ts
+++ b/src/services/i/pin.ts
@@ -6,7 +6,7 @@ import { IdentifiableError } from '../../misc/identifiable-error';
 import { User } from '../../models/entities/user';
 import { Note } from '../../models/entities/note';
 import { Notes, UserNotePinings, Users } from '../../models';
-import { UserNotePining } from '../../models/entities/user-note-pinings';
+import { UserNotePining } from '../../models/entities/user-note-pining';
 import { genId } from '../../misc/gen-id';
 import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
 import { deliverToRelays } from '../relay';
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 44ec5fda6f..a530b86f55 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
 import extractEmojis from '../../misc/extract-emojis';
 import extractHashtags from '../../misc/extract-hashtags';
 import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
-import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models';
+import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings } from '../../models';
 import { DriveFile } from '../../models/entities/drive-file';
 import { App } from '../../models/entities/app';
 import { Not, getConnection, In } from 'typeorm';
@@ -33,6 +33,7 @@ import { checkWordMute } from '../../misc/check-word-mute';
 import { addNoteToAntenna } from '../add-note-to-antenna';
 import { countSameRenotes } from '../../misc/count-same-renotes';
 import { deliverToRelays } from '../relay';
+import { Channel } from '../../models/entities/channel';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -102,6 +103,7 @@ type Option = {
 	cw?: string | null;
 	visibility?: string;
 	visibleUsers?: User[] | null;
+	channel?: Channel | null;
 	apMentions?: User[] | null;
 	apHashtags?: string[] | null;
 	apEmojis?: string[] | null;
@@ -111,13 +113,31 @@ type Option = {
 };
 
 export default async (user: User, data: Option, silent = false) => new Promise<Note>(async (res, rej) => {
+	// チャンネル外にリプライしたら対象のスコープに合わせる
+	// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
+	if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
+		if (data.reply.channelId) {
+			data.channel = await Channels.findOne(data.reply.channelId);
+		} else {
+			data.channel = null;
+		}
+	}
+
+	// チャンネル内にリプライしたら対象のスコープに合わせる
+	// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
+	if (data.reply && (data.channel == null) && data.reply.channelId) {
+		data.channel = await Channels.findOne(data.reply.channelId);
+	}
+
 	if (data.createdAt == null) data.createdAt = new Date();
 	if (data.visibility == null) data.visibility = 'public';
 	if (data.viaMobile == null) data.viaMobile = false;
 	if (data.localOnly == null) data.localOnly = false;
+	if (data.channel != null) data.visibility = 'public';
+	if (data.channel != null) data.visibleUsers = [];
 
 	// サイレンス
-	if (user.isSilenced && data.visibility === 'public') {
+	if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
 		data.visibility = 'home';
 	}
 
@@ -142,12 +162,12 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
 	}
 
 	// ローカルのみをRenoteしたらローカルのみにする
-	if (data.renote && data.renote.localOnly) {
+	if (data.renote && data.renote.localOnly && data.channel == null) {
 		data.localOnly = true;
 	}
 
 	// ローカルのみにリプライしたらローカルのみにする
-	if (data.reply && data.reply.localOnly) {
+	if (data.reply && data.reply.localOnly && data.channel == null) {
 		data.localOnly = true;
 	}
 
@@ -255,6 +275,18 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
 		}
 	});
 
+	// Channel
+	if (note.channelId) {
+		ChannelFollowings.find({ followeeId: note.channelId }).then(followings => {
+			for (const following of followings) {
+				insertNoteUnread(following.followerId, note, {
+					isSpecified: false,
+					isMentioned: false,
+				});
+			}
+		});
+	}
+
 	if (data.reply) {
 		saveReply(data.reply, note);
 	}
@@ -273,11 +305,23 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
 			if (data.visibleUsers == null) throw new Error('invalid param');
 
 			for (const u of data.visibleUsers) {
-				insertNoteUnread(u, note, true);
+				// ローカルユーザーのみ
+				if (!Users.isLocalUser(u)) continue;
+
+				insertNoteUnread(u.id, note, {
+					isSpecified: true,
+					isMentioned: false,
+				});
 			}
 		} else {
 			for (const u of mentionedUsers) {
-				insertNoteUnread(u, note, false);
+				// ローカルユーザーのみ
+				if (!Users.isLocalUser(u)) continue;
+
+				insertNoteUnread(u.id, note, {
+					isSpecified: false,
+					isMentioned: true,
+				});
 			}
 		}
 
@@ -379,6 +423,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
 		//#endregion
 	}
 
+	if (data.channel) {
+		Channels.increment({ id: data.channel.id }, 'notesCount', 1);
+		Channels.update(data.channel.id, {
+			lastNotedAt: new Date(),
+		});
+
+		Notes.count({
+			userId: user.id,
+			channelId: data.channel.id,
+		}).then(count => {
+			// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
+			// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
+			if (count === 1) {
+				Channels.increment({ id: data.channel.id }, 'usersCount', 1);
+			}
+		});
+	}
+
 	// Register to search database
 	index(note);
 });
@@ -405,6 +467,7 @@ async function insertNote(user: User, data: Option, tags: string[], emojis: stri
 		fileIds: data.files ? data.files.map(file => file.id) : [],
 		replyId: data.reply ? data.reply.id : null,
 		renoteId: data.renote ? data.renote.id : null,
+		channelId: data.channel ? data.channel.id : null,
 		name: data.name,
 		text: data.text,
 		hasPoll: data.poll != null,
diff --git a/src/services/note/read.ts b/src/services/note/read.ts
index 33fe4f1bf0..5a39ab30b7 100644
--- a/src/services/note/read.ts
+++ b/src/services/note/read.ts
@@ -2,71 +2,104 @@ import { publishMainStream } from '../stream';
 import { Note } from '../../models/entities/note';
 import { User } from '../../models/entities/user';
 import { NoteUnreads, Antennas, AntennaNotes, Users } from '../../models';
-
-// TODO: 状態が変化していない場合は各種イベントを送信しない
+import { Not, IsNull } from 'typeorm';
 
 /**
  * Mark a note as read
  */
-export default (
+export default async function(
 	userId: User['id'],
 	noteId: Note['id']
-) => new Promise<any>(async (resolve, reject) => {
-	// Remove document
-	/*const res = */await NoteUnreads.delete({
-		userId: userId,
-		noteId: noteId
-	});
-
-	// v11 TODO: https://github.com/typeorm/typeorm/issues/2415
-	//if (res.affected === 0) {
-	//	return;
-	//}
-
-	const [count1, count2] = await Promise.all([
-		NoteUnreads.count({
+) {
+	async function careNoteUnreads() {
+		const exist = await NoteUnreads.findOne({
 			userId: userId,
-			isSpecified: false
-		}),
-		NoteUnreads.count({
-			userId: userId,
-			isSpecified: true
-		})
-	]);
-
-	if (count1 === 0) {
-		// 全て既読になったイベントを発行
-		publishMainStream(userId, 'readAllUnreadMentions');
-	}
-
-	if (count2 === 0) {
-		// 全て既読になったイベントを発行
-		publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
-	}
-
-	const antennas = await Antennas.find({ userId });
-
-	await Promise.all(antennas.map(async antenna => {
-		await AntennaNotes.update({
-			antennaId: antenna.id,
-			noteId: noteId
-		}, {
-			read: true
+			noteId: noteId,
 		});
 
-		const count = await AntennaNotes.count({
-			antennaId: antenna.id,
-			read: false
+		if (!exist) return;
+
+		// Remove the record
+		await NoteUnreads.delete({
+			userId: userId,
+			noteId: noteId,
 		});
 
-		if (count === 0) {
-			publishMainStream(userId, 'readAntenna', antenna);
+		if (exist.isMentioned) {
+			NoteUnreads.count({
+				userId: userId,
+				isMentioned: true
+			}).then(mentionsCount => {
+				if (mentionsCount === 0) {
+					// 全て既読になったイベントを発行
+					publishMainStream(userId, 'readAllUnreadMentions');
+				}
+			});
 		}
-	}));
 
-	Users.getHasUnreadAntenna(userId).then(unread => {
-		if (!unread) {
-			publishMainStream(userId, 'readAllAntennas');
+		if (exist.isSpecified) {
+			NoteUnreads.count({
+				userId: userId,
+				isSpecified: true
+			}).then(specifiedCount => {
+				if (specifiedCount === 0) {
+					// 全て既読になったイベントを発行
+					publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
+				}
+			});
 		}
-	});
-});
+
+		if (exist.noteChannelId) {
+			NoteUnreads.count({
+				userId: userId,
+				noteChannelId: Not(IsNull())
+			}).then(channelNoteCount => {
+				if (channelNoteCount === 0) {
+					// 全て既読になったイベントを発行
+					publishMainStream(userId, 'readAllChannels');
+				}
+			});
+		}
+	}
+
+	async function careAntenna() {
+		const beforeUnread = await Users.getHasUnreadAntenna(userId);
+		if (!beforeUnread) return;
+
+		const antennas = await Antennas.find({ userId });
+
+		await Promise.all(antennas.map(async antenna => {
+			const countBefore = await AntennaNotes.count({
+				antennaId: antenna.id,
+				read: false
+			});
+
+			if (countBefore === 0) return;
+
+			await AntennaNotes.update({
+				antennaId: antenna.id,
+				noteId: noteId
+			}, {
+				read: true
+			});
+
+			const countAfter = await AntennaNotes.count({
+				antennaId: antenna.id,
+				read: false
+			});
+
+			if (countAfter === 0) {
+				publishMainStream(userId, 'readAntenna', antenna);
+			}
+		}));
+
+		Users.getHasUnreadAntenna(userId).then(unread => {
+			if (!unread) {
+				publishMainStream(userId, 'readAllAntennas');
+			}
+		});
+	}
+
+	careNoteUnreads();
+	careAntenna();
+}
diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts
index 549aa971a7..6fd9ee2cfe 100644
--- a/src/services/note/unread.ts
+++ b/src/services/note/unread.ts
@@ -1,16 +1,18 @@
 import { Note } from '../../models/entities/note';
 import { publishMainStream } from '../stream';
 import { User } from '../../models/entities/user';
-import { Mutings, NoteUnreads, Users } from '../../models';
+import { Mutings, NoteUnreads } from '../../models';
 import { genId } from '../../misc/gen-id';
 
-export default async function(user: User, note: Note, isSpecified = false) {
-	// ローカルユーザーのみ
-	if (!Users.isLocalUser(user)) return;
-
+export default async function(userId: User['id'], note: Note, params: {
+	// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
+	isSpecified: boolean;
+	isMentioned: boolean;
+}) {
 	//#region ミュートしているなら無視
+	// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
 	const mute = await Mutings.find({
-		muterId: user.id
+		muterId: userId
 	});
 	if (mute.map(m => m.muteeId).includes(note.userId)) return;
 	//#endregion
@@ -18,20 +20,27 @@ export default async function(user: User, note: Note, isSpecified = false) {
 	const unread = await NoteUnreads.save({
 		id: genId(),
 		noteId: note.id,
-		userId: user.id,
-		isSpecified,
-		noteUserId: note.userId
+		userId: userId,
+		isSpecified: params.isSpecified,
+		isMentioned: params.isMentioned,
+		noteChannelId: note.channelId,
+		noteUserId: note.userId,
 	});
 
 	// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
 	setTimeout(async () => {
 		const exist = await NoteUnreads.findOne(unread.id);
+
 		if (exist == null) return;
 
-		publishMainStream(user.id, 'unreadMention', note.id);
-
-		if (isSpecified) {
-			publishMainStream(user.id, 'unreadSpecifiedNote', note.id);
+		if (params.isMentioned) {
+			publishMainStream(userId, 'unreadMention', note.id);
+		}
+		if (params.isSpecified) {
+			publishMainStream(userId, 'unreadSpecifiedNote', note.id);
+		}
+		if (note.channelId) {
+			publishMainStream(userId, 'unreadChannel', note.id);
 		}
 	}, 2000);
 }