diff --git a/.autogen/autogen.sh b/.autogen/autogen.sh
index 1ea71ff00c..f01f633278 100755
--- a/.autogen/autogen.sh
+++ b/.autogen/autogen.sh
@@ -1,18 +1,19 @@
 #!/usr/bin/env bash
-# BEARER_TOKEN=
-# CAMPAIGN_ID=
-# GITHUB_TOKEN=
-# HEAD='acid-chicken:patch-autogen'
-# REPO='syuilo/misskey'
-test "$(curl -LSs -w '\n' -- "https://api.github.com/repos/$REPO/pulls?access_token=$GITHUB_TOKEN" | jq -r '.[].head.label' | grep $HEAD)" && exit 1
+# __MISSKEY_BEARER_TOKEN=
+# __MISSKEY_CAMPAIGN_ID=
+# __MISSKEY_GITHUB_TOKEN=
+# __MISSKEY_HEAD=acid-chicken:patch-autogen
+# __MISSKEY_REPO=syuilo/misskey
+# __MISSKEY_BRANCH=develop
+test "$(curl -LSs -w '\n' -- "https://api.github.com/repos/$REPO/pulls?access_token=$__MISSKEY_GITHUB_TOKEN" | jq -r '.[].head.label' | grep $__MISSKEY_HEAD)" && exit 1
 cd "$(dirname $0)/.." && \
 touch null.cache && \
 rm *.cache && \
-git checkout master && \
-git pull origin master && \
-git pull upstream master && \
+git checkout $__MISSKEY_BRANCH && \
+git pull origin $__MISSKEY_BRANCH && \
+git pull upstream $__MISSKEY_BRANCH && \
 git stash && \
-git rebase -f upstream/master && \
+git rebase -f upstream/$__MISSKEY_BRANCH && \
 git branch patch-autogen && \
 git checkout patch-autogen && \
 git reset --hard HEAD || \
@@ -20,12 +21,12 @@ exit 1
 touch patreon.md.cache && \
 rm patreon.md.cache && \
 echo '<!-- PATREON_START -->' > patreon.md.cache && \
-URL="https://www.patreon.com/api/oauth2/v2/campaigns/$CAMPAIGN_ID/members?include=currently_entitled_tiers,user&fields%5Btier%5D=title&fields%5Buser%5D=full_name,thumb_url,url,hide_pledges"
+url="https://www.patreon.com/api/oauth2/v2/campaigns/$__MISSKEY_CAMPAIGN_ID/members?include=currently_entitled_tiers,user&fields%5Btier%5D=title&fields%5Buser%5D=full_name,thumb_url,url,hide_pledges"
 while :
  do
   touch patreon.raw.cache && \
   rm patreon.raw.cache && \
-  curl -LSs -w '\n' -H "Authorization: Bearer $BEARER_TOKEN" -- $URL > patreon.raw.cache && \
+  curl -LSs -w '\n' -H "Authorization: Bearer $__MISSKEY_BEARER_TOKEN" -- $url > patreon.raw.cache && \
   touch patreon.cache && \
   rm patreon.cache && \
   cat patreon.raw.cache | \
@@ -42,31 +43,31 @@ while :
   xargs -I% echo '<td><a href="%</a></td>' >> patreon.md.cache && \
   echo '</tr></table>' >> patreon.md.cache || \
   exit 1
-  NEW_URL="$(cat patreon.raw.cache | jq -r '.links.next')"
-  test "$NEW_URL" = 'null' && \
+  new_url="$(cat patreon.raw.cache | jq -r '.links.next')"
+  test "$new_url" = 'null' && \
   break || \
-  URL="$NEW_URL"
+  URL="$url"
 done
-IGNORE= && \
+ignore= && \
 echo -e "\n**Last updated:** $(date -uR | sed 's/\+0000/UTC/')\n<!-- PATREON_END -->" >> patreon.md.cache && \
 touch README.md && \
 touch .autogen/README.md && \
 rm .autogen/README.md && \
 mv README.md .autogen/README.md && \
-cat .autogen/README.md | while IFS= read LINE;
+cat .autogen/README.md | while IFS= read line;
  do
-  if [[ -z "$IGNORE" ]]
+  if [[ -z "$ignore" ]]
    then
-    if [[ "$LINE" = '<!-- PATREON_START -->' ]]
+    if [[ "$line" = '<!-- PATREON_START -->' ]]
      then
-      IGNORE='PATREON_INSIDE'
+      ignore='PATREON_INSIDE'
      else
-      echo "$LINE" >> README.md
+      echo "$line" >> README.md
     fi
    else
     if [[ "$LINE" = '<!-- PATREON_END -->' ]]
      then
-      IGNORE=
+      ignore=
       cat patreon.md.cache >> README.md
     fi
   fi
@@ -80,7 +81,7 @@ test 4 -lt $(cat diff.cache | wc -l) && \
 git add README.md && \
 git commit -m 'Update README.md [AUTOGEN]' && \
 git push -f origin patch-autogen && \
-curl -LSs -w '\n' -X POST -d '{"title":"[AUTOMATED] Update README.md","body":"*This pull request was created by a tool.*","head":"'$HEAD'","base":"master"}' -- "https://api.github.com/repos/$REPO/pulls?access_token=$GITHUB_TOKEN"
+curl -LSs -w '\n' -X POST -d '{"title":"[AUTOMATED] Update README.md","body":"*This pull request was created by a tool.*","head":"'$__MISSKEY_HEAD'","base":"'$__MISSKEY_BRANCH'"}' -- "https://api.github.com/repos/$__MISSKEY_REPO/pulls?access_token=$__MISSKEY_GITHUB_TOKEN"
 git stash
-git checkout master
+git checkout $__MISSKEY_BRANCH
 git branch -D patch-autogen
diff --git a/.config/example.yml b/.config/example.yml
index ecb1dd1934..ebad171839 100644
--- a/.config/example.yml
+++ b/.config/example.yml
@@ -7,27 +7,51 @@ maintainer:
   repository_url: https://github.com/syuilo/misskey # Repository URL
   feedback_url: https://github.com/syuilo/misskey/issues # Feedback URL (e.g. github issue)
 
-# URL and Port settings overview
-# e.g., If you want to realize following structure:
-#
-#               +--- https://example.com:123 ----------+
-# +------+      |+-------------+      +---------------+|
-# | User | ---> || Proxy (123) | ---> | Misskey (456) ||
-# +------+      |+-------------+      +---------------+|
-#               +--------------------------------------+
-#
-# You need to set 'https://example.com:123' to 'url' prop and
-# You need to set 456 to 'port' prop.
-#
-# In other words, the 'url' prop should be the final accessible URL seen by a user.
-# 'port' prop is a port that the Misskey server should actually listen
-# on and it is not necessarily the port that a user accesses.
 
-url: http://localhost/
+# Final accessible URL seen by a user.
+url: https://example.tld/
+
+
+### Port and TLS settings ######################################
+#
+# Misskey supports two deployment options for public.
+#
+
+# Option 1: With Reverse Proxy
+#
+#                 +----- https://example.tld/ ------------+
+#   +------+      |+-------------+      +----------------+|
+#   | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
+#   +------+      |+-------------+      +----------------+|
+#                 +---------------------------------------+
+#
+#   You need to setup reverse proxy. (eg. Nginx)
+#   You do not define 'https' section.
+
+# Option 2: Standalone
+#
+#                 +- https://example.tld/ -+
+#   +------+      |   +---------------+    |
+#   | User | ---> |   | Misskey (443) |    |
+#   +------+      |   +---------------+    |
+#                 +------------------------+
+#
+#   You need to run Misskey as root.
+#   You need to set Certificate in 'https' section.
+
+# To use option 1, uncomment below line.
+# port: 3000    # A port that your Misskey server should listen.
+
+# To use option 2, uncomment below lines.
+# port: 443
+#
+# https:
+#   # path for certification
+#   key: /etc/letsencrypt/live/example.tld/privkey.pem
+#   cert: /etc/letsencrypt/live/example.tld/fullchain.pem
+
+################################################################
 
-# A port that your Misskey server should listen.
-# This value is not a port to use when accessing with a browser.
-port: 80
 
 mongodb:
   host: localhost
@@ -98,12 +122,6 @@ drive:
 # Below settings are optional
 #
 
-# TLS
-# https:
-#   # path for certification
-#   key: example-tls-key
-#   cert: example-tls-cert
-
 # Elasticsearch
 # elasticsearch:
 #   host: localhost
diff --git a/.npmrc b/.npmrc
index b680f3f72d..6b5f38e890 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,2 +1,2 @@
-save-exact=true
+save-exact = true
 package-lock = false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef3b5b4939..b26010b146 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,94 @@ ChangeLog
 
 This document describes breaking changes only.
 
+10.0.0
+------
+
+ストリーミングAPIに破壊的変更があります。運営者がすべきことはありません。
+
+変更は以下の通りです
+
+* ストリーミングでやり取りする際の snake_case が全て camelCase に
+* リバーシのストリームエンドポイント名が reversi → gamesReversi、reversiGame → gamesReversiGame に
+* ストリーミングの個々のエンドポイントが廃止され、一旦元となるストリームに接続してから、個々のチャンネル(今までのエンドポイント)に接続します。詳細は後述します。
+* ストリームから流れてくる、キャプチャした投稿の更新イベントに投稿自体のデータは含まれず、代わりにアクションが設定されるようになります。詳細は後述します。
+* ストリームに接続する際に追加で指定していたパラメータ(トークン除く)が、URLにクエリとして含むのではなくチャンネル接続時にパラメータ指定するように
+
+### 個々のエンドポイントが廃止されることによる新しいストリーミングAPIの利用方法
+具体的には、まず https://example.misskey/streaming にwebsocket接続します。
+次に、例えば「messaging」ストリーム(チャンネルと呼びます)に接続したいときは、ストリームに次のようなデータを送信します:
+``` javascript
+{
+  type: 'connect',
+  body: {
+    channel: 'messaging',
+    id: 'foobar',
+    params: {
+      otherparty: 'xxxxxxxxxxxx'
+    }
+  }
+}
+```
+ここで、`id`にはそのチャンネルとやり取りするための任意のIDを設定します。
+IDはチャンネルごとではなく「チャンネルの接続ごと」です。なぜなら、同じチャンネルに異なるパラメータで複数接続するケースもあるからです。
+`params`はチャンネルに接続する際のパラメータです。チャンネルによって接続時に必要とされるパラメータは異なります。パラメータ不要のチャンネルに接続する際は、このプロパティは省略可能です。
+
+チャンネルにメッセージを送信するには、次のようなデータを送信します:
+``` javascript
+{
+  type: 'channel',
+  body: {
+    id: 'foobar',
+    type: 'something',
+    body: {
+      some: 'thing'
+    }
+  }
+}
+```
+ここで、`id`にはチャンネルに接続するときに指定したIDを設定します。
+
+逆に、チャンネルからメッセージが流れてくると、次のようなデータが受信されます:
+``` javascript
+{
+  type: 'channel',
+  body: {
+    id: 'foobar',
+    type: 'something',
+    body: {
+      some: 'thing'
+    }
+  }
+}
+```
+ここで、`id`にはチャンネルに接続するときに指定したIDが設定されています。
+
+### 投稿のキャプチャに関する変更
+投稿の更新イベントに投稿情報は含まれなくなりました。代わりに、その投稿が「リアクションされた」「アンケートに投票された」「削除された」といったアクション情報が設定されます。
+
+具体的には次のようなデータが受信されます:
+``` javascript
+{
+  type: 'noteUpdated',
+  body: {
+    id: 'xxxxxxxxxxx',
+    type: 'reacted',
+    body: {
+      reaction: 'hmm'
+    }
+  }
+}
+```
+
+* reacted ... 投稿にリアクションされた。`reaction`プロパティにリアクションコードが含まれます。
+* pollVoted ... アンケートに投票された。`choice`プロパティに選択肢ID、`userId`に投票者IDが含まれます。
+
+9.0.0
+-----
+
+Misskey v8.64.0 を使っている方は、9.0.0に際しては特にすべきことはありません。
+Misskey v8.64.0 に満たないバージョンをお使いの方は、一旦8.64.0にアップデートして(そして起動して)から9.0.0に再度アップデートしてください。
+
 8.0.0
 -----
 
@@ -47,13 +135,13 @@ Please run `node cli/migration/5.0.0` before launch.
 
 オセロがリバーシに変更されました。
 
-Othello is now Reversi.
+Othello is rename to Reversi.
 
 ### Migration
 
 MongoDBの、`othelloGames`と`othelloMatchings`コレクションをそれぞれ`reversiGames`と`reversiMatchings`にリネームしてください。
 
-You need to rename `othelloGames` and `othelloMatchings` MongoDB collections to `reversiGames` and `reversiMatchings`.
+Please rename `othelloGames` and `othelloMatchings` MongoDB collections to `reversiGames` and `reversiMatchings` respectively.
 
 3.0.0
 -----
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0add0bdcb1..2fa78d1934 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,27 +1,27 @@
 # Contribution guide
-:v: Misskeyへの貢献ありがとうございます。 :v:
+:v: Thanks for your contributions :v:
 
-## Issueの報告
-新機能の提案や不具合の報告は https://github.com/syuilo/misskey/issues で管理しています。
-Issueを作成する前に、既に同じIssueが作成されていないかご確認ください。
-もし既にIssueが作成されている場合は、既存のIssueにコメントをしたりリアクションをするようお願いします。
+## Issues
+Feature suggestions and bug reports are filed in https://github.com/syuilo/misskey/issues .
+Before creating a new issue, please search existing issues to avoid duplication.
+If you find the existing issue, please add your reaction or comment to the issue.
 
-## Issueの解決
-[pr-welcomeのラベルがついているIssue](https://github.com/syuilo/misskey/labels/pr-welcome)
-の解決を目的としたPull Requestを作成してくださると非常にありがたいです。
+## Internationalization (i18n)
+Please see [Translation guide](./docs/translate.en.md).
 
-## 翻訳の改善
-ソースコード中の `%i18n:id%` という形の文字列は、言語ファイルの対応するテキストに置換されます。
-言語ファイルは /locales ディレクトリに存在します。
+## Localization (l10n)
+Please use [Crowdin](https://crowdin.com/project/misskey) for localization.
 
-## ドキュメントの編集
-現在Misskeyはドキュメントが大きく不足しています。
-ドキュメントは /docs ディレクトリに存在します。
+![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg)
 
-## テストの追加
-現在Misskeyはテストが大きく不足しています。
-テストコードは /test ディレクトリに存在します。
+## Documentation
+* Documents for contributors are located in `/docs`.
+* Documents for instance admins are located in `/docs`.
+* Documents for end users are located in `src/docs`.
 
-## 自動テスト及び自動リリース
-Travis CIで行っています。
-設定ファイルは /.travis に存在します。
+## Test
+* Test codes are located in `/test`.
+
+## Continuous integration
+Misskey uses Travis for automated test.
+Configuration files are located in `/.travis`.
diff --git a/README.md b/README.md
index 5c1b243396..5247671dda 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-<img src="https://github.com/syuilo/misskey/blob/b3f42e62af698a67c2250533c437569559f1fdf9/src/himasaku/resources/himasaku.png?raw=true" align="right" width="320px"/>
+<img src="https://github.com/syuilo/misskey/blob/develop/assets/ai-orig.png?raw=true" align="right" height="320px"/>
 
 [![Misskey](/assets/title.png)](https://misskey.xyz/)
 ================================================================
@@ -7,12 +7,12 @@
 [![][dependencies-badge]][dependencies-link]
 [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![Greenkeeper badge](https://badges.greenkeeper.io/syuilo/misskey.svg)](https://greenkeeper.io/)
 
-Sophisticated microblogging platform, evolving forever.
+**Sophisticated microblogging platform, evolving forever.**
 
 [Misskey](https://misskey.xyz) is a decentralized microblogging platform born on Earth.
 Since it exists within the Fediverse (a universe where various social media platforms are organized),
 it is mutually linked with other social media platforms.
-Why don't you take a short break from the hustle and bustle of the city, and dive into a new Internet?
+Why don't you take a short break from the hustle and bustle of the city, and dive into a new Internet? [Find instance!](https://joinmisskey.github.io/)
 
 <a href="https://www.patreon.com/syuilo"><img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" alt="Become a Patron!" width="160" /></a>
 
@@ -20,52 +20,70 @@ Why don't you take a short break from the hustle and bustle of the city, and div
 
 :sparkles: Features
 ----------------------------------------------------------------
-* Rich text contents
-* Reactions
-* User lists
-* Customizable column view (called MisskeyDeck)
-  * and widgets!
-* Private messages
-* ActivityPub support
 
-and more! You can see it with your own eyes at [misskey.xyz](https://misskey.xyz).
+<img src="/assets/about/post.png" align="left" height="200px"/>
+
+<h3 align="left">Posting</h3>
+<p align="left">
+Just post your idea, hot topics and anything you want to share. You may want to decorate your words, attach your favorite pictures, send files including movies and create a poll - those are the things you can do on Misskey!
+</p>
+
+---
+
+<img src="/assets/about/reaction.png" align="right" height="200px"/>
+
+<h3 align="right">Reactions</h3>
+<p align="right">
+Easiest way to tell your emotions. Misskey allows you to add various type of reactions to other’s post. The emotional experience on Misskey will never be on other SNSs which only able to push “likes”.
+</p>
+
+---
+
+<img src="/assets/about/ui.png" align="left" height="200px"/>
+
+<h3 align="left">Interface</h3>
+<p align="left">
+No UI fits for everyone. Therefore, Misskey has a highly customizable UI for your taste. You can edit layouts of your timeline, place selectable widgets you can easily move and create your unique home as this place will be your home.
+</p>
+
+---
+
+<img src="/assets/about/drive.png" align="right" width="300px"/>
+
+<h3 align="right">Misskey Drive</h3>
+<p align="right">
+Wanna post a picture you have already uploaded? Wish to organize, name and create a folder for your uploaded files? Misskey Drive is the best solution for you. Very easy to share your files online.
+</p>
+
+---
+
+and more! You can see it with your own eyes at [misskey.xyz](https://misskey.xyz) or [other instances](https://joinmisskey.github.io/).
 
 :package: Create your own instance
 ----------------------------------------------------------------
-If you want to run your own instance of Misskey,
-please see [Setup and installation guide](./docs/setup.en.md).
+Please see [Setup and installation guide](./docs/setup.en.md).
 
-:wrench: Contribute
+:wrench: Contribution
 ----------------------------------------------------------------
-**[PR](https://github.com/syuilo/misskey/pulls)s welcome!**
-
-### i18n
-
-Please see [Translation guide](./docs/translate.en.md).
-
-### l10n
-
-Misskey is using Crowdin for l10n.
-
-[![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg)](https://crowdin.com/project/misskey)
+Please see [Contribution guide](./CONTRIBUTING.md).
 
 :heart: Backers & Sponsors
 ----------------------------------------------------------------
 <!-- PATREON_START -->
 <table><tr>
-<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12378075/0156f769e20f412594fa6b87d85fe228/1?token-time=2145916800&token-hash=IsIJRUXszzoD6-7pDnRY8I05T9nSznc4GTaxj7C9SwU%3D" alt="39ff"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12731202/0995c46cdcb54153ab5f073f5869b70a/1?token-time=2145916800&token-hash=Yd60FK_SWfQO56SeiJpy1tDHOnCV4xdEywQe8gn5_Wo%3D" alt="negao"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/13099460/43cecdbaa63a40d79bf50a96b9910b9d/1?token-time=2145916800&token-hash=d6P5MWHHsCMxUuBAEPAoVc5wLUR19mIhqAq7Ma9h9rI%3D" alt="ne_moni"></td>
-<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/1?token-time=2145916800&token-hash=f03BFb4S2FUx9YEt87TnEmifb4h33OywGBW2akQVtQY%3D" alt="Melilot"></td>
+<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/2?token-time=2145916800&token-hash=mgPdX9TqZxEg4TTPuc477dxhIgYk9246qafjWZEqZ7g%3D" alt="Melilot"></td>
+<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12999811/5f349fafcce44dd1824a8b1ebbec4564/2?token-time=2145916800&token-hash=rwZ8qvbm_kpA4ib3kc07tVKupXeySpY5ATQFGxfL9v0%3D" alt="Xeltica"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/3384329/8b713330cb27404ea6e9fac50ff96efe/1?token-time=2145916800&token-hash=0eu4-m1gTWA9PhptVZt6rdKcusqcD7RB87rJT23VVFI%3D" alt="べすれい"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12021162/963128bb8d14476dbd8407943db8f31a/1?token-time=2145916800&token-hash=GgJ_NmUB6_nnRNLVGUWjV-WX91On7BOu59LKncYV9fE%3D" alt="gutfuckllc"></td>
 <td><img src="https://c8.patreon.com/2/100/12718187" alt="Peter G."></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/13039004/509d0c412eb14ae08d6a812a3054f7d6/1?token-time=2145916800&token-hash=zwSu01tOtn5xTUucDZHuPsCxF2HBEMVs9ROJKTlEV_o%3D" alt="nemu"></td>
 </tr><tr>
-<td><a href="https://www.patreon.com/user?u=12378075">39ff</a></td>
-<td><a href="https://www.patreon.com/user?u=12731202">negao</a></td>
+<td><a href="https://www.patreon.com/negao">negao</a></td>
 <td><a href="https://www.patreon.com/user?u=13099460">ne_moni</a></td>
 <td><a href="https://www.patreon.com/user?u=12913507">Melilot</a></td>
+<td><a href="https://www.patreon.com/AxellaMC">Xeltica</a></td>
 <td><a href="https://www.patreon.com/user?u=3384329">べすれい</a></td>
 <td><a href="https://www.patreon.com/gutfuckllc">gutfuckllc</a></td>
 <td><a href="https://www.patreon.com/user?u=12718187">Peter G.</a></td>
@@ -73,23 +91,17 @@ Misskey is using Crowdin for l10n.
 </tr></table>
 <table><tr>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/5881381/6235ca5d3fb04c8e95ef5b4ff2abcc18/2?token-time=2145916800&token-hash=zElv7ZcPL3viGsXbNG_KWiKrbV0vvw1gk0panx8DJoo%3D" alt="Naoki Kosaka"></td>
-<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12931605/ead494101f364dffa90efe49e36fb494/1?token-time=2145916800&token-hash=NzSFPjIlodXyv41rwK61aZWVZWfI4surJaNj8vWKvqM%3D" alt="Reiju"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/13034746/c711c7f58e204ecfbc2fd646bc8a4eee/1?token-time=2145916800&token-hash=UERBN4OyP7Nh5XwwdDg0N0IE5cD6_qUQMO81Z5Wizso%3D" alt="Hiratake"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/4503830/ccf2cc867ea64de0b524bb2e24b9a1cb/1?token-time=2145916800&token-hash=S1zP0QyLU52Dqq6dtc9qNYyWfW86XrYHiR4NMbeOrnA%3D" alt="dansup"></td>
-<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/4950409/28e7d016209243759d9316be2e21381d/2?token-time=2145916800&token-hash=LuEaDkchH3GQWUcTOhBQ8xfKQYF0s5FjlZRd7Yduia8%3D" alt="mikan54951"></td>
 <td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12531784/93a45137841849329ba692da92ac7c60/1?token-time=2145916800&token-hash=tMosUojzUYJCH_3t--tvYA-SMCyrS__hzSndyaRSnbo%3D" alt="Takashi Shibuya"></td>
-<td><img src="https://c10.patreonusercontent.com/3/eyJoIjoxMDAsInciOjEwMH0%3D/patreon-media/p/user/12959468/c249e15aebec4424b5c0f427173671b6/1?token-time=2145916800&token-hash=lubpCEdxAkxPlpR2O6bvZ7BIh8Q4nGf-U_mE1qpjVAQ%3D" alt="fujishan"></td>
 </tr><tr>
 <td><a href="https://www.patreon.com/user?u=5881381">Naoki Kosaka</a></td>
-<td><a href="https://www.patreon.com/user?u=12931605">Reiju</a></td>
 <td><a href="https://www.patreon.com/hiratake">Hiratake</a></td>
 <td><a href="https://www.patreon.com/dansup">dansup</a></td>
-<td><a href="https://www.patreon.com/user?u=4950409">mikan54951</a></td>
 <td><a href="https://www.patreon.com/user?u=12531784">Takashi Shibuya</a></td>
-<td><a href="https://www.patreon.com/fujishan">fujishan</a></td>
 </tr></table>
 
-**Last updated:** Wed, 22 Aug 2018 05:25:06 UTC
+**Last updated:** Tue, 02 Oct 2018 09:25:07 UTC
 <!-- PATREON_END -->
 
 :four_leaf_clover: Copyright
diff --git a/assets/about/drive.png b/assets/about/drive.png
new file mode 100644
index 0000000000..c35de433a8
Binary files /dev/null and b/assets/about/drive.png differ
diff --git a/assets/about/post.png b/assets/about/post.png
new file mode 100644
index 0000000000..ba291ec665
Binary files /dev/null and b/assets/about/post.png differ
diff --git a/assets/about/reaction.png b/assets/about/reaction.png
new file mode 100644
index 0000000000..e4e7e06bc0
Binary files /dev/null and b/assets/about/reaction.png differ
diff --git a/assets/about/ui.png b/assets/about/ui.png
new file mode 100644
index 0000000000..ad102a31af
Binary files /dev/null and b/assets/about/ui.png differ
diff --git a/assets/ai-orig.png b/assets/ai-orig.png
new file mode 100644
index 0000000000..b684e2c078
Binary files /dev/null and b/assets/ai-orig.png differ
diff --git a/assets/ai.png b/assets/ai.png
new file mode 100644
index 0000000000..9c6ca56632
Binary files /dev/null and b/assets/ai.png differ
diff --git a/docs/setup.en.md b/docs/setup.en.md
index 6a54817a78..23bcdcca98 100644
--- a/docs/setup.en.md
+++ b/docs/setup.en.md
@@ -54,7 +54,7 @@ Please visit https://www.google.com/recaptcha/intro/ and generate keys.
 
 *(optional)* Generating VAPID keys
 ----------------------------------------------------------------
-If you want to enable ServiceWroker, you need to generate VAPID keys:
+If you want to enable ServiceWorker, you need to generate VAPID keys:
 Unless you have set your global node_modules location elsewhere, you need to run this in root.
 
 ``` shell
@@ -131,6 +131,7 @@ You can check if the service is running with `systemctl status misskey`.
 2. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)`
 3. `npm install`
 4. `npm run build`
+5. Check [ChangeLog](../CHANGELOG.md) for migration information
 
 ----------------------------------------------------------------
 
diff --git a/docs/setup.ja.md b/docs/setup.ja.md
index 7c701b019f..e1ed63cab4 100644
--- a/docs/setup.ja.md
+++ b/docs/setup.ja.md
@@ -10,7 +10,7 @@ Misskeyサーバーの構築にご関心をお寄せいただきありがとう
 
 *1.* Misskeyユーザーの作成
 ----------------------------------------------------------------
-Misskeyのrootで実行しない方がよいため、代わりにユーザーを作成します。
+Misskeyはrootユーザーで実行しない方がよいため、代わりにユーザーを作成します。
 Debianの例:
 
 ```
@@ -109,6 +109,7 @@ Restart=always
 [Install]
 WantedBy=multi-user.target
 ```
+CentOSで1024以下のポートを使用してMisskeyを使用する場合は`ExecStart=/usr/bin/sudo /usr/bin/npm start`に変更する必要があります。
 
 3. `systemctl daemon-reload ; systemctl enable misskey` systemdを再読み込みしmisskeyサービスを有効化
 4. `systemctl start misskey` misskeyサービスの起動
@@ -120,6 +121,7 @@ WantedBy=multi-user.target
 2. `git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)`
 3. `npm install`
 4. `npm run build`
+5. [ChangeLog](../CHANGELOG.md)でマイグレーション情報を確認する
 
 ----------------------------------------------------------------
 
diff --git a/gulpfile.ts b/gulpfile.ts
index da111b2982..c47d90a1cc 100644
--- a/gulpfile.ts
+++ b/gulpfile.ts
@@ -2,7 +2,6 @@
  * Gulp tasks
  */
 
-import * as fs from 'fs';
 import * as gulp from 'gulp';
 import * as gutil from 'gulp-util';
 import * as ts from 'gulp-typescript';
@@ -78,7 +77,7 @@ gulp.task('build:copy', ['build:copy:views', 'build:copy:lang'], () =>
 	]).pipe(gulp.dest('./built/'))
 );
 
-gulp.task('test', ['lint', 'mocha']);
+gulp.task('test', ['mocha']);
 
 gulp.task('lint', () =>
 	gulp.src('./src/**/*.ts')
@@ -166,9 +165,7 @@ gulp.task('build:client:pug', [
 			.pipe(pug({
 				locals: {
 					themeColor: constants.themeColor,
-					facss: fa.dom.css(),
-					//hljscss: fs.readFileSync('./node_modules/highlight.js/styles/default.css', 'utf8')
-					hljscss: fs.readFileSync('./src/client/assets/code-highlight.css', 'utf8')
+					facss: fa.dom.css()
 				}
 			}))
 			.pipe(htmlmin({
diff --git a/locales/README.md b/locales/README.md
index 09888299cd..56bfae64d6 100644
--- a/locales/README.md
+++ b/locales/README.md
@@ -1,5 +1,3 @@
-# **Please DO NOT edit these files** except `ja-JP.yml`.
+# **DO NOT edit locale files** except `ja-JP.yml`.
 
-If you want to...
-* i18n ... please see [Translation guide](../docs/translate.en.md).
-* l10n ... please visit https://crowdin.com/project/misskey
+Please see [Contribution guide](../CONTRIBUTING.md) for more information.
diff --git a/locales/index.js b/locales/index.js
index b1bc782166..6780251e10 100644
--- a/locales/index.js
+++ b/locales/index.js
@@ -5,24 +5,9 @@
 const fs = require('fs');
 const yaml = require('js-yaml');
 
-const loadLang = lang => yaml.safeLoad(
-	fs.readFileSync(`${__dirname}/${lang}.yml`, 'utf-8'));
+const langs = ['de-DE', 'en-US', 'fr-FR', 'ja-JP', 'ja-KS', 'pl-PL', 'es-ES', 'nl-NL'];
 
-const native = loadLang('ja-JP');
+const loadLocale = lang => yaml.safeLoad(fs.readFileSync(`${__dirname}/${lang}.yml`, 'utf-8'));
+const locales = langs.map(lang => ({ [lang]: loadLocale(lang) }));
 
-const langs = {
-	'de-DE': loadLang('de-DE'),
-	'en-US': loadLang('en-US'),
-	'fr-FR': loadLang('fr-FR'),
-	'ja-JP': native,
-	'ja-KS': loadLang('ja-KS'),
-	'pl-PL': loadLang('pl-PL'),
-	'es-ES': loadLang('es-ES')
-};
-
-Object.values(langs).forEach(locale => {
-	// Extend native language (Japanese)
-	locale = Object.assign({}, native, locale);
-});
-
-module.exports = langs;
+module.exports = locales.reduce((a, b) => ({ ...a, ...b }));
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 84b7ddb26f..f9cc57d370 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -6,6 +6,19 @@ common:
   misskey: "A ⭐ of fediverse"
   about-title: "A ⭐ of fediverse."
   about: "Misskeyを見つけていただき、ありがとうございます。Misskeyは、地球で生まれた<b>分散マイクロブログSNS</b>です。Fediverse(様々なSNSで構成される宇宙)の中に存在するため、他のSNSと相互に繋がっています。暫し都会の喧騒から離れて、新しいインターネットにダイブしてみませんか。"
+  intro:
+    title: "Misskeyって?"
+    about: "Misskeyはオープンソースの<b>分散型マイクロブログSNS</b>です。リッチで高度にカスタマイズできるUI、投稿へのリアクション、ファイルを一元管理できるドライブなど、先進的な機能を揃えています。また、Fediverseと呼ばれるネットワークに接続できるため、他のSNSともやり取りできます。例えば、あなたが何か投稿すると、その投稿はMisskeyだけでなく他のSNSにも伝わります。ちょうどある惑星から他の惑星に電波を発信している様子をイメージしてください。"
+    features: "特徴"
+    rich-contents: "投稿"
+    rich-contents-desc: "自分の考え、話題の出来事、皆と共有したいことについて発信してください。必要であれば、様々な構文を使って投稿を装飾したり、好きな画像、動画などのファイルやアンケートを添付することもできます。"
+    reaction: "リアクション"
+    reaction-desc: "あなたの気持ちを伝える最も簡単な方法です。Misskeyは、他のユーザーの投稿に様々なリアクションを付けることができます。いちどMisskeyのリアクション機能を体験してしまうと、もう「いいね」の概念しか存在しないSNSには戻れなくなるかもしれません。"
+    ui: "インターフェース"
+    ui-desc: "どのようなUIが使いやすいかは人それぞれです。だから、Misskeyは自由度の高いUIを持っています。レイアウトやデザインを調整したり、カスタマイズ可能な様々なウィジェットを配置したりして、自分だけのホームを作ってください。"
+    drive: "ドライブ"
+    drive-desc: "以前投稿したことのある画像をまた投稿したくなったことはありませんか?もしくは、アップロードしたファイルをフォルダ分けして整理したくなったことはありませんか?Misskeyの根幹に組み込まれたドライブ機能によってそれらが解決します。ファイルの共有も簡単です。"
+    outro: "他にもMisskeyにしかない機能はまだまだあるので、ぜひあなた自身の目で確かめてください。Misskeyは分散型SNSなので、このインスタンスが気に入らなければ他のインスタンスを試すこともできます。それでは、GLHF!"
   adblock:
     detected: "広告ブロッカーを無効にしてください"
     warning: "<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。"
@@ -73,6 +86,16 @@ common:
     rip: "RIP"
     pudding: "Pudding"
 
+  note-visibility:
+    public: "公開"
+    home: "ホーム"
+    home-desc: "ホームタイムラインにのみ公開"
+    followers: "フォロワー"
+    followers-desc: "自分のフォロワーにのみ公開"
+    specified: "ダイレクト"
+    specified-desc: "指定したユーザーにのみ公開"
+    private: "非公開"
+
   note-placeholders:
     a: "今どうしてる?"
     b: "何かありましたか?"
@@ -93,6 +116,13 @@ common:
   use-contrast-reversi-stones: "リバーシのアイコンにコントラストを付ける"
   verified-user: "公式アカウント"
   disable-animated-mfm: "投稿内の動きのあるテキストを無効にする"
+  always-show-nsfw: "常に閲覧注意のメディアを表示する"
+  always-mark-nsfw: "常にメディアを閲覧注意として投稿"
+  show-full-acct: "ユーザー名のホストを省略しない"
+  reduce-motion: "UIの動きを減らす"
+  this-setting-is-this-device-only: "このデバイスのみ"
+
+  do-not-use-in-production: 'これは開発ビルドです。本番環境で使用しないでください。'
 
   reversi:
     drawn: "引き分け"
@@ -136,7 +166,10 @@ common:
     home: "ホーム"
     local: "ローカル"
     hybrid: "ソーシャル"
+    hashtag: "ハッシュタグ"
     global: "グローバル"
+    mentions: "あなた宛て"
+    direct: "ダイレクト投稿"
     notifications: "通知"
     list: "リスト"
     swap-left: "左に移動"
@@ -248,6 +281,47 @@ common/views/components/connect-failed.troubleshooter.vue:
   flush: "キャッシュの削除"
   set-version: "バージョン指定"
 
+common/views/components/media-banner.vue:
+  sensitive: "閲覧注意"
+  click-to-show: "クリックして表示"
+
+common/views/components/theme.vue:
+  light-theme: "非ダークモード時に使用するテーマ"
+  dark-theme: "ダークモード時に使用するテーマ"
+  light-themes: "明るいテーマ"
+  dark-themes: "暗いテーマ"
+  install-a-theme: "テーマのインストール"
+  theme-code: "テーマコード"
+  install: "インストール"
+  installed: "「{}」をインストールしました"
+  create-a-theme: "テーマの作成"
+  save-created-theme: "テーマを保存"
+  primary-color: "プライマリ カラー"
+  secondary-color: "セカンダリ カラー"
+  text-color: "文字色"
+  base-theme: "ベーステーマ"
+  base-theme-light: "Light"
+  base-theme-dark: "Dark"
+  theme-name: "テーマ名"
+  preview-created-theme: "プレビュー"
+  invalid-theme: "テーマが正しくありません。"
+  already-installed: "既にそのテーマはインストールされています。"
+  saved: "保存しました"
+  installed-themes: "インストールされたテーマ"
+  select-theme: "テーマを選択してください"
+  uninstall: "アンインストール"
+  uninstalled: "「{}」をアンインストールしました"
+  author: "作者"
+  desc: "説明"
+  export: "エクスポート"
+  import: "インポート"
+  import-by-code: "またはコードをペースト"
+  theme-name-required: "テーマ名は必須です。"
+
+common/views/components/cw-button.vue:
+  hide: "隠す"
+  show: "もっと見る"
+
 common/views/components/messaging.vue:
   search-user: "ユーザーを探す"
   you: "あなた"
@@ -283,8 +357,11 @@ common/views/components/nav.vue:
   feedback: "フィードバック"
 
 common/views/components/note-menu.vue:
+  detail: "詳細"
+  copy-link: "リンクをコピー"
   favorite: "お気に入り"
   pin: "ピン留め"
+  unpin: "ピン留め解除"
   delete: "削除"
   delete-confirm: "この投稿を削除しますか?"
   remote: "投稿元で見る"
@@ -371,6 +448,10 @@ common/views/components/visibility-chooser.vue:
   specified-desc: "指定したユーザーにのみ公開"
   private: "非公開"
 
+common/views/components/trends.vue:
+  count: "{}人が投稿"
+  empty: "トレンドなし"
+
 common/views/widgets/broadcast.vue:
   fetching: "確認中"
   no-broadcasts: "お知らせはありません"
@@ -399,8 +480,6 @@ common/views/widgets/posts-monitor.vue:
 
 common/views/widgets/hashtags.vue:
   title: "ハッシュタグ"
-  count: "{}人が投稿"
-  empty: "トレンドなし"
 
 common/views/widgets/server.vue:
   title: "サーバー情報"
@@ -443,6 +522,7 @@ common/views/pages/follow.vue:
   following: "フォロー中"
   follow: "フォロー"
   request-pending: "フォロー許可待ち"
+  follow-processing: "フォロー処理中"
   follow-request: "フォロー申請"
 
 desktop:
@@ -481,17 +561,21 @@ desktop/views/components/charts.vue:
   notes: "投稿"
   users: "ユーザー"
   drive: "ドライブ"
+  network: "ネットワーク"
   charts:
     notes: "投稿の増減 (統合)"
     local-notes: "投稿の増減 (ローカル)"
     remote-notes: "投稿の増減 (リモート)"
-    notes-total: "投稿の累計"
+    notes-total: "投稿の積算"
     users: "ユーザーの増減"
-    users-total: "ユーザーの累計"
+    users-total: "ユーザーの積算"
     drive: "ドライブ使用量の増減"
-    drive-total: "ドライブ使用量の累計"
+    drive-total: "ドライブ使用量の積算"
     drive-files: "ドライブのファイル数の増減"
-    drive-files-total: "ドライブのファイル数の累計"
+    drive-files-total: "ドライブのファイル数の積算"
+    network-requests: "リクエスト"
+    network-time: "応答時間"
+    network-usage: "通信量"
 
 desktop/views/components/choose-file-from-drive-window.vue:
   choose-file: "ファイル選択中"
@@ -581,6 +665,7 @@ desktop/views/components/follow-button.vue:
   following: "フォロー中"
   follow: "フォロー"
   request-pending: "フォロー許可待ち"
+  follow-processing: "フォロー処理中"
   follow-request: "フォロー申請"
 
 desktop/views/components/followers-window.vue:
@@ -637,8 +722,6 @@ desktop/views/components/notes.note.vue:
   detail: "詳細"
   private: "この投稿は非公開です"
   deleted: "この投稿は削除されました"
-  hide: "隠す"
-  see-more: "もっと見る"
 
 desktop/views/components/notes.vue:
   error: "読み込みに失敗しました。"
@@ -714,10 +797,14 @@ desktop/views/components/settings.vue:
   2fa: "二段階認証"
   other: "その他"
   license: "ライセンス"
+  theme: "テーマ"
 
   behaviour: "動作"
   fetch-on-scroll: "スクロールで自動読み込み"
   fetch-on-scroll-desc: "ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。"
+  note-visibility: "投稿の公開範囲"
+  default-note-visibility: "デフォルトの公開範囲"
+  remember-note-visibility: "投稿の公開範囲を記憶する"
   auto-popout: "ウィンドウの自動ポップアウト"
   auto-popout-desc: "ウィンドウが開かれるとき、ポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトします。この設定はブラウザに記憶されます。"
   advanced: "詳細設定"
@@ -729,8 +816,10 @@ desktop/views/components/settings.vue:
   choose-wallpaper: "壁紙を選択"
   delete-wallpaper: "壁紙を削除"
   dark-mode: "ダークモード"
+  use-shadow: "UIに影を使用"
+  rounded-corners: "UIの角を丸める"
   circle-icons: "円形のアイコンを使用"
-  gradient-window-header: "ウィンドウのタイトルバーにグラデーションを使用"
+  contrasted-acct: "ユーザー名にコントラストを付ける"
   post-form-on-timeline: "タイムライン上部に投稿フォームを表示する"
   suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する"
   show-clock-on-header: "右上に時計を表示する"
@@ -739,7 +828,6 @@ desktop/views/components/settings.vue:
   show-renoted-my-notes: "自分の投稿のRenoteをタイムラインに表示する"
   show-local-renotes: "ローカルの投稿のRenoteをタイムラインに表示する"
   show-maps: "マップの自動展開"
-  show-maps-desc: "位置情報が添付された投稿のマップを自動的に展開します。"
 
   sound: "サウンド"
   enable-sounds: "サウンドを有効にする"
@@ -845,7 +933,7 @@ desktop/views/components/settings.profile.vue:
   birthday: "誕生日"
   save: "保存"
   locked-account: "アカウントの保護"
-  is-locked: "投稿を非公開にする"
+  is-locked: "フォローを承認制にする"
   other: "その他"
   is-bot: "このアカウントはBotです"
   is-cat: "このアカウントはCatです"
@@ -865,7 +953,13 @@ desktop/views/components/timeline.vue:
   local: "ローカル"
   hybrid: "ソーシャル"
   global: "グローバル"
+  mentions: "あなた宛て"
+  messages: "メッセージ"
   list: "リスト"
+  hashtag: "ハッシュタグ"
+  add-tag-timeline: "ハッシュタグを追加"
+  add-list: "リストを追加"
+  list-name: "リスト名"
 
 desktop/views/components/ui.header.vue:
   welcome-back: "おかえりなさい、"
@@ -984,7 +1078,10 @@ desktop/views/pages/welcome.vue:
   signin-button: "やってる"
   signup-button: "やる"
   timeline: "タイムライン"
+  announcements: "お知らせ"
+  photos: "最近の画像"
   powered-by-misskey: "Powered by <b>Misskey</b>."
+  info: "情報"
 
 desktop/views/pages/drive.vue:
   title: "Misskey Drive"
@@ -1145,6 +1242,7 @@ mobile/views/components/follow-button.vue:
   following: "フォロー中"
   follow: "フォロー"
   request-pending: "フォロー許可待ち"
+  follow-processing: "フォロー処理中"
   follow-request: "フォロー申請"
 
 mobile/views/components/friends-maker.vue:
@@ -1156,8 +1254,6 @@ mobile/views/components/friends-maker.vue:
 
 mobile/views/components/note.vue:
   reposted-by: "{}がRenote"
-  more: "もっと見る"
-  less: "隠す"
   private: "この投稿は非公開です"
   deleted: "この投稿は削除されました"
   location: "位置情報"
@@ -1265,6 +1361,8 @@ mobile/views/pages/home.vue:
   local: "ローカル"
   hybrid: "ソーシャル"
   global: "グローバル"
+  mentions: "あなた宛て"
+  messages: "メッセージ"
 
 mobile/views/pages/tag.vue:
   no-posts-found: "ハッシュタグ「{}」が付けられた投稿は見つかりませんでした。"
@@ -1317,6 +1415,9 @@ mobile/views/pages/settings/settings.profile.vue:
   avatar: "アイコン"
   banner: "バナー"
   is-cat: "このアカウントはCatです"
+  is-locked: "フォローを承認制にする"
+  advanced: "その他"
+  privacy: "プライバシー"
   save: "保存"
   saved: "プロフィールを保存しました"
   uploading: "アップロード中"
@@ -1341,6 +1442,7 @@ mobile/views/pages/settings.vue:
   dark-mode: "ダークモード"
   i-am-under-limited-internet: "私は通信を制限されている"
   circle-icons: "円形のアイコンを使用"
+  contrasted-acct: "ユーザー名にコントラストを付ける"
   timeline: "タイムライン"
   show-reply-target: "リプライ先を表示する"
   show-my-renotes: "自分の行ったRenoteを表示する"
@@ -1349,8 +1451,15 @@ mobile/views/pages/settings.vue:
   post-style: "投稿の表示スタイル"
   post-style-standard: "標準"
   post-style-smart: "スマート"
+  notification-position: "通知の表示"
+  notification-position-bottom: "下"
+  notification-position-top: "上"
+  theme: "テーマ"
   behavior: "動作"
   fetch-on-scroll: "スクロールで自動読み込み"
+  note-visibility: "投稿の公開範囲"
+  default-note-visibility: "デフォルトの公開範囲"
+  remember-note-visibility: "投稿の公開範囲を記憶する"
   disable-via-mobile: "「モバイルからの投稿」フラグを付けない"
   load-raw-images: "添付された画像を高画質で表示する"
   load-remote-media: "リモートサーバーのメディアを表示する"
@@ -1370,7 +1479,7 @@ mobile/views/pages/settings.vue:
   settings: "設定"
   signout: "サインアウト"
   sound: "サウンド"
-  enableSounds: "サウンドを有効にする"
+  enable-sounds: "サウンドを有効にする"
 
 mobile/views/pages/user.vue:
   follows-you: "フォローされています"
diff --git a/package.json b/package.json
index eea3f363c3..63ab3854f5 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
 {
 	"name": "misskey",
 	"author": "syuilo <i@syuilo.com>",
-	"version": "8.15.0",
-	"clientVersion": "1.0.9031",
+	"version": "9.7.1",
+	"clientVersion": "1.0.10090",
 	"codename": "nighthike",
 	"main": "./built/index.js",
 	"private": true,
@@ -20,16 +20,16 @@
 		"format": "gulp format"
 	},
 	"dependencies": {
-		"@fortawesome/fontawesome": "1.1.8",
-		"@fortawesome/fontawesome-free-brands": "5.0.13",
-		"@fortawesome/fontawesome-free-regular": "5.0.13",
-		"@fortawesome/fontawesome-free-solid": "5.0.13",
+		"@fortawesome/fontawesome-svg-core": "1.2.4",
+		"@fortawesome/free-brands-svg-icons": "5.3.1",
+		"@fortawesome/free-regular-svg-icons": "5.3.1",
+		"@fortawesome/free-solid-svg-icons": "5.3.1",
 		"@koa/cors": "2.2.2",
 		"@prezzemolo/rap": "0.1.2",
 		"@prezzemolo/zip": "0.0.3",
-		"@types/bcryptjs": "2.4.1",
+		"@types/bcryptjs": "2.4.2",
 		"@types/dateformat": "1.0.1",
-		"@types/debug": "0.0.30",
+		"@types/debug": "0.0.31",
 		"@types/deep-equal": "1.0.1",
 		"@types/double-ended-queue": "2.1.0",
 		"@types/elasticsearch": "5.0.26",
@@ -51,19 +51,19 @@
 		"@types/koa-logger": "3.1.0",
 		"@types/koa-mount": "3.0.1",
 		"@types/koa-multer": "1.0.0",
-		"@types/koa-router": "7.0.31",
+		"@types/koa-router": "7.0.32",
 		"@types/koa-send": "4.1.1",
 		"@types/koa-views": "2.0.3",
 		"@types/koa__cors": "2.2.3",
-		"@types/minio": "6.0.2",
+		"@types/minio": "7.0.0",
 		"@types/mkdirp": "0.5.2",
 		"@types/mocha": "5.2.3",
-		"@types/mongodb": "3.1.4",
+		"@types/mongodb": "3.1.10",
 		"@types/ms": "0.7.30",
-		"@types/node": "10.9.3",
+		"@types/node": "10.11.4",
 		"@types/portscanner": "2.1.0",
 		"@types/pug": "2.0.4",
-		"@types/qrcode": "1.2.0",
+		"@types/qrcode": "1.3.0",
 		"@types/ratelimiter": "2.1.28",
 		"@types/redis": "2.8.6",
 		"@types/request": "2.47.1",
@@ -75,13 +75,15 @@
 		"@types/single-line-log": "1.1.0",
 		"@types/speakeasy": "2.0.2",
 		"@types/systeminformation": "3.23.0",
+		"@types/tinycolor2": "1.4.1",
 		"@types/tmp": "0.0.33",
-		"@types/uuid": "3.4.3",
-		"@types/webpack": "4.4.11",
+		"@types/uuid": "3.4.4",
+		"@types/webpack": "4.4.14",
 		"@types/webpack-stream": "3.2.10",
-		"@types/websocket": "0.0.39",
-		"@types/ws": "6.0.0",
+		"@types/websocket": "0.0.40",
+		"@types/ws": "6.0.1",
 		"animejs": "2.2.0",
+		"autobind-decorator": "2.1.0",
 		"autosize": "4.0.2",
 		"autwh": "0.1.0",
 		"bcryptjs": "2.4.3",
@@ -94,26 +96,25 @@
 		"crc-32": "1.2.0",
 		"css-loader": "1.0.0",
 		"dateformat": "3.0.3",
-		"debug": "3.1.0",
+		"debug": "4.0.1",
 		"deep-equal": "1.0.1",
 		"deepcopy": "0.6.3",
-		"diskusage": "0.2.4",
+		"diskusage": "0.2.5",
 		"dompurify": "1.0.5",
 		"double-ended-queue": "2.1.0-0",
 		"elasticsearch": "15.1.1",
-		"element-ui": "2.4.6",
 		"emojilib": "2.3.0",
 		"escape-regexp": "0.0.1",
 		"eslint": "5.0.1",
 		"eslint-plugin-vue": "4.7.1",
 		"eventemitter3": "3.1.0",
 		"exif-js": "2.3.0",
-		"file-loader": "1.1.11",
-		"file-type": "9.0.0",
+		"file-loader": "2.0.0",
+		"file-type": "10.0.0",
 		"fuckadblock": "3.2.1",
 		"gulp": "3.9.1",
 		"gulp-cssnano": "2.1.3",
-		"gulp-htmlmin": "4.0.0",
+		"gulp-htmlmin": "5.0.1",
 		"gulp-imagemin": "4.1.0",
 		"gulp-mocha": "6.0.0",
 		"gulp-pug": "4.0.1",
@@ -132,16 +133,17 @@
 		"insert-text-at-cursor": "0.1.1",
 		"is-root": "2.0.0",
 		"is-url": "1.2.4",
-		"jquery": "3.3.1",
 		"js-yaml": "3.12.0",
-		"jsdom": "11.12.0",
+		"jsdom": "12.2.0",
+		"json5": "2.1.0",
+		"json5-loader": "1.0.1",
 		"koa": "2.5.1",
 		"koa-bodyparser": "4.2.1",
 		"koa-compress": "3.0.0",
 		"koa-favicon": "2.0.1",
 		"koa-json-body": "5.3.0",
 		"koa-logger": "3.2.0",
-		"koa-mount": "3.0.0",
+		"koa-mount": "4.0.0",
 		"koa-multer": "1.0.2",
 		"koa-router": "7.4.0",
 		"koa-send": "5.0.0",
@@ -151,17 +153,15 @@
 		"lodash.assign": "4.2.0",
 		"mecab-async": "0.1.2",
 		"merge-options": "1.0.1",
-		"minio": "7.0.0",
+		"minio": "7.0.1",
 		"mkdirp": "0.5.1",
 		"mocha": "5.2.0",
 		"moji": "0.5.1",
 		"mongodb": "3.1.1",
 		"monk": "6.0.6",
 		"ms": "2.1.1",
-		"nan": "2.11.0",
+		"nan": "2.11.1",
 		"nested-property": "0.0.7",
-		"node-sass": "4.9.3",
-		"node-sass-json-importer": "3.3.1",
 		"nprogress": "0.2.0",
 		"object-assign-deep": "0.4.0",
 		"on-build-webpack": "0.1.0",
@@ -172,13 +172,14 @@
 		"promise-sequential": "1.1.1",
 		"pug": "2.0.3",
 		"punycode": "2.1.1",
-		"qrcode": "1.2.2",
+		"qrcode": "1.3.0",
 		"ratelimiter": "3.2.0",
 		"recaptcha-promise": "0.1.3",
-		"reconnecting-websocket": "3.2.2",
+		"reconnecting-websocket": "4.1.5",
 		"redis": "2.8.0",
 		"request": "2.88.0",
 		"request-promise-native": "1.0.5",
+		"request-stats": "3.0.0",
 		"rimraf": "2.6.2",
 		"rndstr": "1.0.0",
 		"s-age": "1.1.2",
@@ -193,38 +194,42 @@
 		"style-loader": "0.23.0",
 		"stylus": "0.54.5",
 		"stylus-loader": "3.0.2",
-		"summaly": "2.1.4",
-		"systeminformation": "3.44.2",
+		"summaly": "2.2.0",
+		"systeminformation": "3.45.7",
 		"syuilo-password-strength": "0.0.1",
 		"textarea-caret": "3.1.0",
+		"tinycolor2": "1.4.1",
 		"tmp": "0.0.33",
 		"ts-loader": "4.4.1",
 		"ts-node": "7.0.1",
 		"tslint": "5.10.0",
 		"typescript": "2.9.2",
-		"typescript-eslint-parser": "18.0.0",
+		"typescript-eslint-parser": "19.0.2",
 		"uglify-es": "3.3.9",
 		"url-loader": "1.1.1",
 		"uuid": "3.3.2",
 		"v-animate-css": "0.0.2",
 		"vue": "2.5.17",
 		"vue-chartjs": "3.4.0",
-		"vue-cropperjs": "2.2.1",
-		"vue-js-modal": "1.3.23",
+		"vue-color": "2.6.0",
+		"vue-cropperjs": "2.2.2",
+		"vue-js-modal": "1.3.26",
 		"vue-json-tree-view": "2.1.4",
-		"vue-loader": "15.4.1",
+		"vue-loader": "15.4.2",
 		"vue-router": "3.0.1",
 		"vue-style-loader": "4.1.2",
+		"vue-svg-inline-loader": "1.2.0",
 		"vue-template-compiler": "2.5.17",
 		"vuedraggable": "2.16.0",
+		"vuewordcloud": "18.7.11",
 		"vuex": "3.0.1",
 		"vuex-persistedstate": "2.5.4",
-		"web-push": "3.3.2",
+		"web-push": "3.3.3",
 		"webfinger.js": "2.6.6",
-		"webpack": "4.17.1",
-		"webpack-cli": "3.1.0",
-		"websocket": "1.0.26",
-		"ws": "6.0.0",
+		"webpack": "4.20.2",
+		"webpack-cli": "3.1.2",
+		"websocket": "1.0.28",
+		"ws": "6.1.0",
 		"xev": "2.0.1"
 	},
 	"greenkeeper": {
diff --git a/src/client/app/app.styl b/src/client/app/app.styl
index 431b9daa65..2f0095944c 100644
--- a/src/client/app/app.styl
+++ b/src/client/app/app.styl
@@ -6,6 +6,10 @@ html
 		&, *
 			cursor progress !important
 
+html
+	// iOSのため
+	overflow auto
+
 body
 	overflow-wrap break-word
 
@@ -23,7 +27,7 @@ body
 	z-index 65536
 
 	.bar
-		background $theme-color
+		background var(--primary)
 
 		position fixed
 		z-index 65537
@@ -40,7 +44,7 @@ body
 		right 0px
 		width 100px
 		height 100%
-		box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color
+		box-shadow 0 0 10px var(--primary), 0 0 5px var(--primary)
 		opacity 1
 
 		transform rotate(3deg) translate(0px, -4px)
@@ -60,8 +64,8 @@ body
 		box-sizing border-box
 
 		border solid 2px transparent
-		border-top-color $theme-color
-		border-left-color $theme-color
+		border-top-color var(--primary)
+		border-left-color var(--primary)
 		border-radius 50%
 
 		animation progress-spinner 400ms linear infinite
diff --git a/src/client/app/app.vue b/src/client/app/app.vue
index 7a46e7dea0..e639c9f9ac 100644
--- a/src/client/app/app.vue
+++ b/src/client/app/app.vue
@@ -1,3 +1,32 @@
 <template>
-<router-view id="app"></router-view>
+<router-view id="app" v-hotkey.global="keymap"></router-view>
 </template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { url, lang } from './config';
+
+export default Vue.extend({
+	computed: {
+		keymap(): any {
+			return {
+				'h|slash': this.help,
+				'd': this.dark
+			};
+		}
+	},
+
+	methods: {
+		help() {
+			window.open(`${url}/docs/${lang}/keyboard-shortcut`, '_blank');
+		},
+
+		dark() {
+			this.$store.commit('device/set', {
+				key: 'darkmode',
+				value: !this.$store.state.device.darkmode
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue
index 609e758994..ba7df911e5 100644
--- a/src/client/app/auth/views/index.vue
+++ b/src/client/app/auth/views/index.vue
@@ -80,7 +80,7 @@ export default Vue.extend({
 		accepted() {
 			this.state = 'accepted';
 			if (this.session.app.callbackUrl) {
-				location.href = this.session.app.callbackUrl + '?token=' + this.session.token;
+				location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
 			}
 		}
 	}
diff --git a/src/client/app/base.pug b/src/client/app/base.pug
index 11b150bc67..ee9d4b6f6d 100644
--- a/src/client/app/base.pug
+++ b/src/client/app/base.pug
@@ -34,9 +34,6 @@ html
 		//- FontAwesome style
 		style #{facss}
 
-		//- highlight.js style
-		style #{hljscss}
-
 	body
 		noscript: p
 			| JavaScriptを有効にしてください
diff --git a/src/client/app/boot.js b/src/client/app/boot.js
index 54397c98c6..6e06a88aa3 100644
--- a/src/client/app/boot.js
+++ b/src/client/app/boot.js
@@ -18,6 +18,17 @@
 		return;
 	}
 
+	const langs = LANGS;
+
+	//#region Apply theme
+	const theme = localStorage.getItem('theme');
+	if (theme) {
+		Object.entries(JSON.parse(theme)).forEach(([k, v]) => {
+			document.documentElement.style.setProperty(`--${k}`, v.toString());
+		});
+	}
+	//#endregion
+
 	//#region Load settings
 	let settings = null;
 	const vuex = localStorage.getItem('vuex');
@@ -40,10 +51,10 @@
 	//#region Detect the user language
 	let lang = null;
 
-	if (LANGS.includes(navigator.language)) {
+	if (langs.includes(navigator.language)) {
 		lang = navigator.language;
 	} else {
-		lang = LANGS.find(x => x.split('-')[0] == navigator.language);
+		lang = langs.find(x => x.split('-')[0] == navigator.language);
 
 		if (lang == null) {
 			// Fallback
@@ -52,7 +63,7 @@
 	}
 
 	if (settings && settings.device.lang &&
-		LANGS.includes(settings.device.lang)) {
+		langs.includes(settings.device.lang)) {
 		lang = settings.device.lang;
 	}
 	//#endregion
@@ -82,19 +93,12 @@
 		app = isMobile ? 'mobile' : 'desktop';
 	}
 
-	// Dark/Light
-	if (settings) {
-		if (settings.device.darkmode) {
-			document.documentElement.setAttribute('data-darkmode', 'true');
-		}
-	}
-
 	// Script version
 	const ver = localStorage.getItem('v') || VERSION;
 
 	// Get salt query
 	const salt = localStorage.getItem('salt')
-		? '?salt=' + localStorage.getItem('salt')
+		? `?salt=${localStorage.getItem('salt')}`
 		: '';
 
 	// Load an app script
@@ -140,7 +144,7 @@
 		// Random
 		localStorage.setItem('salt', Math.random().toString());
 
-		// Clear cache (serive worker)
+		// Clear cache (service worker)
 		try {
 			navigator.serviceWorker.controller.postMessage('clear');
 
diff --git a/src/client/app/common/hotkey.ts b/src/client/app/common/hotkey.ts
new file mode 100644
index 0000000000..dc1a34338a
--- /dev/null
+++ b/src/client/app/common/hotkey.ts
@@ -0,0 +1,110 @@
+import keyCode from './keycode';
+import { concat } from '../../../prelude/array';
+
+type pattern = {
+	which: string[];
+	ctrl?: boolean;
+	shift?: boolean;
+	alt?: boolean;
+};
+
+type action = {
+	patterns: pattern[];
+
+	callback: Function;
+};
+
+const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): action => {
+	const result = {
+		patterns: [],
+		callback: callback
+	} as action;
+
+	result.patterns = patterns.split('|').map(part => {
+		const pattern = {
+			which: [],
+			ctrl: false,
+			alt: false,
+			shift: false
+		} as pattern;
+
+		part.trim().split('+').forEach(key => {
+			key = key.trim().toLowerCase();
+			switch (key) {
+				case 'ctrl': pattern.ctrl = true; break;
+				case 'alt': pattern.alt = true; break;
+				case 'shift': pattern.shift = true; break;
+				default: pattern.which = keyCode(key).map(k => k.toLowerCase());
+			}
+		});
+
+		return pattern;
+	});
+
+	return result;
+});
+
+const ignoreElemens = ['input', 'textarea'];
+
+export default {
+	install(Vue) {
+		Vue.directive('hotkey', {
+			bind(el, binding) {
+				el._hotkey_global = binding.modifiers.global === true;
+
+				const actions = getKeyMap(binding.value);
+
+				// flatten
+				const reservedKeys = concat(concat(actions.map(a => a.patterns.map(p => p.which))));
+
+				el.dataset.reservedKeys = reservedKeys.map(key => `'${key}'`).join(' ');
+
+				el._keyHandler = (e: KeyboardEvent) => {
+					const key = e.code.toLowerCase();
+
+					const targetReservedKeys = document.activeElement ? ((document.activeElement as any).dataset || {}).reservedKeys || '' : '';
+					if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return;
+
+					for (const action of actions) {
+						if (el._hotkey_global && targetReservedKeys.includes(`'${key}'`)) break;
+
+						const matched = action.patterns.some(pattern => {
+							const matched = pattern.which.includes(key) &&
+								pattern.ctrl == e.ctrlKey &&
+								pattern.shift == e.shiftKey &&
+								pattern.alt == e.altKey &&
+								e.metaKey == false;
+
+							if (matched) {
+								e.preventDefault();
+								e.stopPropagation();
+								action.callback(e);
+								return true;
+							} else {
+								return false;
+							}
+						});
+
+						if (matched) {
+							break;
+						}
+					}
+				};
+
+				if (el._hotkey_global) {
+					document.addEventListener('keydown', el._keyHandler);
+				} else {
+					el.addEventListener('keydown', el._keyHandler);
+				}
+			},
+
+			unbind(el) {
+				if (el._hotkey_global) {
+					document.removeEventListener('keydown', el._keyHandler);
+				} else {
+					el.removeEventListener('keydown', el._keyHandler);
+				}
+			}
+		});
+	}
+};
diff --git a/src/client/app/common/keycode.ts b/src/client/app/common/keycode.ts
new file mode 100644
index 0000000000..5786c1dc0a
--- /dev/null
+++ b/src/client/app/common/keycode.ts
@@ -0,0 +1,33 @@
+export default (input: string): string[] => {
+	if (Object.keys(aliases).some(a => a.toLowerCase() == input.toLowerCase())) {
+		const codes = aliases[input];
+		return Array.isArray(codes) ? codes : [codes];
+	} else {
+		return [input];
+	}
+};
+
+export const aliases = {
+	'esc': 'Escape',
+	'enter': ['Enter', 'NumpadEnter'],
+	'up': 'ArrowUp',
+	'down': 'ArrowDown',
+	'left': 'ArrowLeft',
+	'right': 'ArrowRight',
+	'plus': ['NumpadAdd', 'Semicolon'],
+};
+
+/*!
+* Programatically add the following
+*/
+
+// lower case chars
+for (let i = 97; i < 123; i++) {
+	const char = String.fromCharCode(i);
+	aliases[char] = `Key${char.toUpperCase()}`;
+}
+
+// numbers
+for (let i = 0; i < 10; i++) {
+	aliases[i] = [`Numpad${i}`, `Digit${i}`];
+}
diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts
index 4445eefc39..91b165b45d 100644
--- a/src/client/app/common/scripts/check-for-update.ts
+++ b/src/client/app/common/scripts/check-for-update.ts
@@ -9,7 +9,7 @@ export default async function(mios: MiOS, force = false, silent = false) {
 		localStorage.setItem('should-refresh', 'true');
 		localStorage.setItem('v', newer);
 
-		// Clear cache (serive worker)
+		// Clear cache (service worker)
 		try {
 			if (navigator.serviceWorker.controller) {
 				navigator.serviceWorker.controller.postMessage('clear');
diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/app/common/scripts/compose-notification.ts
index f42af94370..65087cc98e 100644
--- a/src/client/app/common/scripts/compose-notification.ts
+++ b/src/client/app/common/scripts/compose-notification.ts
@@ -13,21 +13,21 @@ type Notification = {
 
 export default function(type, data): Notification {
 	switch (type) {
-		case 'drive_file_created':
+		case 'driveFileCreated':
 			return {
 				title: '%i18n:common.notification.file-uploaded%',
 				body: data.name,
 				icon: data.url
 			};
 
-		case 'unread_messaging_message':
+		case 'unreadMessagingMessage':
 			return {
 				title: '%i18n:common.notification.message-from%'.split("{}")[0] + `${getUserName(data.user)}` + '%i18n:common.notification.message-from%'.split("{}")[1] ,
 				body: data.text, // TODO: getMessagingMessageSummary(data),
 				icon: data.user.avatarUrl
 			};
 
-		case 'reversi_invited':
+		case 'reversiInvited':
 			return {
 				title: '%i18n:common.notification.reversi-invited%',
 				body: '%i18n:common.notification.reversi-invited-by%'.split("{}")[0] + `${getUserName(data.parent)}` + '%i18n:common.notification.reversi-invited-by%'.split("{}")[1],
diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts
index ed0904aeb3..0c802f1648 100644
--- a/src/client/app/common/scripts/fuck-ad-block.ts
+++ b/src/client/app/common/scripts/fuck-ad-block.ts
@@ -1,8 +1,8 @@
-require('fuckadblock');
-
 declare const fuckAdBlock: any;
 
 export default (os) => {
+	require('fuckadblock');
+
 	function adBlockDetected() {
 		os.apis.dialog({
 			title: '%fa:exclamation-triangle%%i18n:common.adblock.detected%',
diff --git a/src/client/app/common/scripts/gcd.ts b/src/client/app/common/scripts/gcd.ts
deleted file mode 100644
index 9a19f9da66..0000000000
--- a/src/client/app/common/scripts/gcd.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-const gcd = (a, b) => !b ? a : gcd(b, a % b);
-export default gcd;
diff --git a/src/client/app/common/scripts/get-md5.ts b/src/client/app/common/scripts/get-md5.ts
new file mode 100644
index 0000000000..24ac04c1ad
--- /dev/null
+++ b/src/client/app/common/scripts/get-md5.ts
@@ -0,0 +1,8 @@
+const crypto = require('crypto');
+
+export default (data: ArrayBuffer) => {
+  const buf = new Buffer(data);
+  const hash = crypto.createHash("md5");
+  hash.update(buf);
+  return hash.digest("hex");
+};
\ No newline at end of file
diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts
new file mode 100644
index 0000000000..c41897e70f
--- /dev/null
+++ b/src/client/app/common/scripts/note-subscriber.ts
@@ -0,0 +1,116 @@
+import Vue from 'vue';
+
+export default prop => ({
+	data() {
+		return {
+			connection: null
+		};
+	},
+
+	computed: {
+		$_ns_note_(): any {
+			return this[prop];
+		},
+
+		$_ns_isRenote(): boolean {
+			return (this.$_ns_note_.renote &&
+				this.$_ns_note_.text == null &&
+				this.$_ns_note_.fileIds.length == 0 &&
+				this.$_ns_note_.poll == null);
+		},
+
+		$_ns_target(): any {
+			return this._ns_isRenote ? this.$_ns_note_.renote : this.$_ns_note_;
+		},
+	},
+
+	created() {
+		if (this.$store.getters.isSignedIn) {
+			this.connection = (this as any).os.stream;
+		}
+	},
+
+	mounted() {
+		this.capture(true);
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection.on('_connected_', this.onStreamConnected);
+		}
+	},
+
+	beforeDestroy() {
+		this.decapture(true);
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection.off('_connected_', this.onStreamConnected);
+		}
+	},
+
+	methods: {
+		capture(withHandler = false) {
+			if (this.$store.getters.isSignedIn) {
+				const data = {
+					id: this.$_ns_target.id
+				} as any;
+
+				if (
+					(this.$_ns_target.visibleUserIds || []).includes(this.$store.state.i.id) ||
+					(this.$_ns_target.mentions || []).includes(this.$store.state.i.id)
+				) {
+					data.read = true;
+				}
+
+				this.connection.send('sn', data);
+				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+			}
+		},
+
+		decapture(withHandler = false) {
+			if (this.$store.getters.isSignedIn) {
+				this.connection.send('un', {
+					id: this.$_ns_target.id
+				});
+				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+			}
+		},
+
+		onStreamConnected() {
+			this.capture();
+		},
+
+		onStreamNoteUpdated(data) {
+			const { type, id, body } = data;
+
+			if (id !== this.$_ns_target.id) return;
+
+			switch (type) {
+				case 'reacted': {
+					const reaction = body.reaction;
+					if (this.$_ns_target.reactionCounts == null) Vue.set(this.$_ns_target, 'reactionCounts', {});
+					this.$_ns_target.reactionCounts[reaction] = (this.$_ns_target.reactionCounts[reaction] || 0) + 1;
+					break;
+				}
+
+				case 'pollVoted': {
+					if (body.userId == this.$store.state.i.id) return;
+					const choice = body.choice;
+					this.$_ns_target.poll.choices.find(c => c.id === choice).votes++;
+					break;
+				}
+
+				case 'deleted': {
+					Vue.set(this.$_ns_target, 'deletedAt', body.deletedAt);
+					this.$_ns_target.text = null;
+					this.$_ns_target.tags = [];
+					this.$_ns_target.fileIds = [];
+					this.$_ns_target.poll = null;
+					this.$_ns_target.geo = null;
+					this.$_ns_target.cw = null;
+					break;
+				}
+			}
+
+			this.$emit(`update:${prop}`, this.$_ns_note_);
+		},
+	}
+});
diff --git a/src/client/app/common/scripts/parse-search-query.ts b/src/client/app/common/scripts/parse-search-query.ts
deleted file mode 100644
index 5f6ae3320a..0000000000
--- a/src/client/app/common/scripts/parse-search-query.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-export default function(qs: string) {
-	const q = {
-		text: ''
-	};
-
-	qs.split(' ').forEach(x => {
-		if (/^([a-z_]+?):(.+?)$/.test(x)) {
-			const [key, value] = x.split(':');
-			switch (key) {
-				case 'user':
-					q['includeUserUsernames'] = value.split(',');
-					break;
-				case 'exclude_user':
-					q['excludeUserUsernames'] = value.split(',');
-					break;
-				case 'follow':
-					q['following'] = value == 'null' ? null : value == 'true';
-					break;
-				case 'reply':
-					q['reply'] = value == 'null' ? null : value == 'true';
-					break;
-				case 'renote':
-					q['renote'] = value == 'null' ? null : value == 'true';
-					break;
-				case 'media':
-					q['media'] = value == 'null' ? null : value == 'true';
-					break;
-				case 'poll':
-					q['poll'] = value == 'null' ? null : value == 'true';
-					break;
-				case 'until':
-				case 'since':
-					// YYYY-MM-DD
-					if (/^[0-9]+\-[0-9]+\-[0-9]+$/) {
-						const [yyyy, mm, dd] = value.split('-');
-						q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime();
-					}
-					break;
-				default:
-					q[key] = value;
-					break;
-			}
-		} else {
-			q.text += x + ' ';
-		}
-	});
-
-	if (q.text) {
-		q.text = q.text.trim();
-	}
-
-	return q;
-}
diff --git a/src/client/app/common/scripts/stream.ts b/src/client/app/common/scripts/stream.ts
new file mode 100644
index 0000000000..3b1a94adf9
--- /dev/null
+++ b/src/client/app/common/scripts/stream.ts
@@ -0,0 +1,318 @@
+import autobind from 'autobind-decorator';
+import { EventEmitter } from 'eventemitter3';
+import ReconnectingWebsocket from 'reconnecting-websocket';
+import { wsUrl } from '../../config';
+import MiOS from '../../mios';
+
+/**
+ * Misskey stream connection
+ */
+export default class Stream extends EventEmitter {
+	private stream: ReconnectingWebsocket;
+	private state: string;
+	private buffer: any[];
+	private sharedConnections: SharedConnection[] = [];
+	private nonSharedConnections: NonSharedConnection[] = [];
+
+	constructor(os: MiOS) {
+		super();
+
+		this.state = 'initializing';
+		this.buffer = [];
+
+		const user = os.store.state.i;
+
+		this.stream = new ReconnectingWebsocket(wsUrl + (user ? `?i=${user.token}` : ''));
+		this.stream.addEventListener('open', this.onOpen);
+		this.stream.addEventListener('close', this.onClose);
+		this.stream.addEventListener('message', this.onMessage);
+
+		if (user) {
+			const main = this.useSharedConnection('main');
+
+			// 自分の情報が更新されたとき
+			main.on('meUpdated', i => {
+				os.store.dispatch('mergeMe', i);
+			});
+
+			main.on('readAllNotifications', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadNotification: false
+				});
+			});
+
+			main.on('unreadNotification', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadNotification: true
+				});
+			});
+
+			main.on('readAllMessagingMessages', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadMessagingMessage: false
+				});
+			});
+
+			main.on('unreadMessagingMessage', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadMessagingMessage: true
+				});
+			});
+
+			main.on('unreadMention', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadMentions: true
+				});
+			});
+
+			main.on('readAllUnreadMentions', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadMentions: false
+				});
+			});
+
+			main.on('unreadSpecifiedNote', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadSpecifiedNotes: true
+				});
+			});
+
+			main.on('readAllUnreadSpecifiedNotes', () => {
+				os.store.dispatch('mergeMe', {
+					hasUnreadSpecifiedNotes: false
+				});
+			});
+
+			main.on('clientSettingUpdated', x => {
+				os.store.commit('settings/set', {
+					key: x.key,
+					value: x.value
+				});
+			});
+
+			main.on('homeUpdated', x => {
+				os.store.commit('settings/setHome', x);
+			});
+
+			main.on('mobileHomeUpdated', x => {
+				os.store.commit('settings/setMobileHome', x);
+			});
+
+			main.on('widgetUpdated', x => {
+				os.store.commit('settings/setWidget', {
+					id: x.id,
+					data: x.data
+				});
+			});
+
+			// トークンが再生成されたとき
+			// このままではMisskeyが利用できないので強制的にサインアウトさせる
+			main.on('myTokenRegenerated', () => {
+				alert('%i18n:common.my-token-regenerated%');
+				os.signout();
+			});
+		}
+	}
+
+	public useSharedConnection = (channel: string): SharedConnection => {
+		const existConnection = this.sharedConnections.find(c => c.channel === channel);
+
+		if (existConnection) {
+			existConnection.use();
+			return existConnection;
+		} else {
+			const connection = new SharedConnection(this, channel);
+			connection.use();
+			this.sharedConnections.push(connection);
+			return connection;
+		}
+	}
+
+	@autobind
+	public removeSharedConnection(connection: SharedConnection) {
+		this.sharedConnections = this.sharedConnections.filter(c => c.id !== connection.id);
+	}
+
+	public connectToChannel = (channel: string, params?: any): NonSharedConnection => {
+		const connection = new NonSharedConnection(this, channel, params);
+		this.nonSharedConnections.push(connection);
+		return connection;
+	}
+
+	@autobind
+	public disconnectToChannel(connection: NonSharedConnection) {
+		this.nonSharedConnections = this.nonSharedConnections.filter(c => c.id !== connection.id);
+	}
+
+	/**
+	 * Callback of when open connection
+	 */
+	@autobind
+	private onOpen() {
+		const isReconnect = this.state == 'reconnecting';
+
+		this.state = 'connected';
+		this.emit('_connected_');
+
+		// バッファーを処理
+		const _buffer = [].concat(this.buffer); // Shallow copy
+		this.buffer = []; // Clear buffer
+		_buffer.forEach(data => {
+			this.send(data); // Resend each buffered messages
+		});
+
+		// チャンネル再接続
+		if (isReconnect) {
+			this.sharedConnections.forEach(c => {
+				c.connect();
+			});
+			this.nonSharedConnections.forEach(c => {
+				c.connect();
+			});
+		}
+	}
+
+	/**
+	 * Callback of when close connection
+	 */
+	@autobind
+	private onClose() {
+		this.state = 'reconnecting';
+		this.emit('_disconnected_');
+	}
+
+	/**
+	 * Callback of when received a message from connection
+	 */
+	@autobind
+	private onMessage(message) {
+		const { type, body } = JSON.parse(message.data);
+
+		if (type == 'channel') {
+			const id = body.id;
+			const connection = this.sharedConnections.find(c => c.id === id) || this.nonSharedConnections.find(c => c.id === id);
+			connection.emit(body.type, body.body);
+		} else {
+			this.emit(type, body);
+		}
+	}
+
+	/**
+	 * Send a message to connection
+	 */
+	@autobind
+	public send(typeOrPayload, payload?) {
+		const data = payload === undefined ? typeOrPayload : {
+			type: typeOrPayload,
+			body: payload
+		};
+
+		// まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する
+		if (this.state != 'connected') {
+			this.buffer.push(data);
+			return;
+		}
+
+		this.stream.send(JSON.stringify(data));
+	}
+
+	/**
+	 * Close this connection
+	 */
+	@autobind
+	public close() {
+		this.stream.removeEventListener('open', this.onOpen);
+		this.stream.removeEventListener('message', this.onMessage);
+	}
+}
+
+abstract class Connection extends EventEmitter {
+	public channel: string;
+	public id: string;
+	protected params: any;
+	protected stream: Stream;
+
+	constructor(stream: Stream, channel: string, params?: any) {
+		super();
+
+		this.stream = stream;
+		this.channel = channel;
+		this.params = params;
+		this.id = Math.random().toString();
+		this.connect();
+	}
+
+	@autobind
+	public connect() {
+		this.stream.send('connect', {
+			channel: this.channel,
+			id: this.id,
+			params: this.params
+		});
+	}
+
+	@autobind
+	public send(typeOrPayload, payload?) {
+		const data = payload === undefined ? typeOrPayload : {
+			type: typeOrPayload,
+			body: payload
+		};
+
+		this.stream.send('channel', {
+			id: this.id,
+			body: data
+		});
+	}
+
+	public abstract dispose: () => void;
+}
+
+class SharedConnection extends Connection {
+	private users = 0;
+	private disposeTimerId: any;
+
+	constructor(stream: Stream, channel: string) {
+		super(stream, channel);
+	}
+
+	@autobind
+	public use() {
+		this.users++;
+
+		// タイマー解除
+		if (this.disposeTimerId) {
+			clearTimeout(this.disposeTimerId);
+			this.disposeTimerId = null;
+		}
+	}
+
+	@autobind
+	public dispose() {
+		this.users--;
+
+		// そのコネクションの利用者が誰もいなくなったら
+		if (this.users === 0) {
+			// また直ぐに再利用される可能性があるので、一定時間待ち、
+			// 新たな利用者が現れなければコネクションを切断する
+			this.disposeTimerId = setTimeout(() => {
+				this.disposeTimerId = null;
+				this.removeAllListeners();
+				this.stream.send('disconnect', { id: this.id });
+				this.stream.removeSharedConnection(this);
+			}, 3000);
+		}
+	}
+}
+
+class NonSharedConnection extends Connection {
+	constructor(stream: Stream, channel: string, params?: any) {
+		super(stream, channel, params);
+	}
+
+	@autobind
+	public dispose() {
+		this.removeAllListeners();
+		this.stream.send('disconnect', { id: this.id });
+		this.stream.disconnectToChannel(this);
+	}
+}
diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts
deleted file mode 100644
index 50fff05737..0000000000
--- a/src/client/app/common/scripts/streaming/drive.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Drive stream connection
- */
-export class DriveStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'drive', {
-			i: me.token
-		});
-	}
-}
-
-export class DriveStreamManager extends StreamManager<DriveStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new DriveStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts b/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts
deleted file mode 100644
index e6b02fcfdb..0000000000
--- a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import Stream from '../../stream';
-import MiOS from '../../../../../mios';
-
-export class ReversiGameStream extends Stream {
-	constructor(os: MiOS, me, game) {
-		super(os, 'games/reversi-game', {
-			i: me ? me.token : null,
-			game: game.id
-		});
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/games/reversi/reversi.ts b/src/client/app/common/scripts/streaming/games/reversi/reversi.ts
deleted file mode 100644
index 1f4fd8c63e..0000000000
--- a/src/client/app/common/scripts/streaming/games/reversi/reversi.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import StreamManager from '../../stream-manager';
-import Stream from '../../stream';
-import MiOS from '../../../../../mios';
-
-export class ReversiStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'games/reversi', {
-			i: me.token
-		});
-	}
-}
-
-export class ReversiStreamManager extends StreamManager<ReversiStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new ReversiStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/global-timeline.ts b/src/client/app/common/scripts/streaming/global-timeline.ts
deleted file mode 100644
index a639f1595c..0000000000
--- a/src/client/app/common/scripts/streaming/global-timeline.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Global timeline stream connection
- */
-export class GlobalTimelineStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'global-timeline', {
-			i: me.token
-		});
-	}
-}
-
-export class GlobalTimelineStreamManager extends StreamManager<GlobalTimelineStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new GlobalTimelineStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts
deleted file mode 100644
index dd18c70d70..0000000000
--- a/src/client/app/common/scripts/streaming/home.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Home stream connection
- */
-export class HomeStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, '', {
-			i: me.token
-		});
-
-		// 最終利用日時を更新するため定期的にaliveメッセージを送信
-		setInterval(() => {
-			this.send({ type: 'alive' });
-			me.lastUsedAt = new Date();
-		}, 1000 * 60);
-
-		// 自分の情報が更新されたとき
-		this.on('meUpdated', i => {
-			if (os.debug) {
-				console.log('I updated:', i);
-			}
-
-			os.store.dispatch('mergeMe', i);
-		});
-
-		this.on('read_all_notifications', () => {
-			os.store.dispatch('mergeMe', {
-				hasUnreadNotification: false
-			});
-		});
-
-		this.on('unread_notification', () => {
-			os.store.dispatch('mergeMe', {
-				hasUnreadNotification: true
-			});
-		});
-
-		this.on('read_all_messaging_messages', () => {
-			os.store.dispatch('mergeMe', {
-				hasUnreadMessagingMessage: false
-			});
-		});
-
-		this.on('unread_messaging_message', () => {
-			os.store.dispatch('mergeMe', {
-				hasUnreadMessagingMessage: true
-			});
-		});
-
-		this.on('clientSettingUpdated', x => {
-			os.store.commit('settings/set', {
-				key: x.key,
-				value: x.value
-			});
-		});
-
-		this.on('home_updated', x => {
-			os.store.commit('settings/setHome', x);
-		});
-
-		this.on('mobile_home_updated', x => {
-			os.store.commit('settings/setMobileHome', x);
-		});
-
-		this.on('widgetUpdated', x => {
-			os.store.commit('settings/setWidget', {
-				id: x.id,
-				data: x.data
-			});
-		});
-
-		// トークンが再生成されたとき
-		// このままではMisskeyが利用できないので強制的にサインアウトさせる
-		this.on('my_token_regenerated', () => {
-			alert('%i18n:common.my-token-regenerated%');
-			os.signout();
-		});
-	}
-}
-
-export class HomeStreamManager extends StreamManager<HomeStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new HomeStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/hybrid-timeline.ts b/src/client/app/common/scripts/streaming/hybrid-timeline.ts
deleted file mode 100644
index cd290797c4..0000000000
--- a/src/client/app/common/scripts/streaming/hybrid-timeline.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Hybrid timeline stream connection
- */
-export class HybridTimelineStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'hybrid-timeline', {
-			i: me.token
-		});
-	}
-}
-
-export class HybridTimelineStreamManager extends StreamManager<HybridTimelineStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new HybridTimelineStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/local-timeline.ts b/src/client/app/common/scripts/streaming/local-timeline.ts
deleted file mode 100644
index 2834262bdc..0000000000
--- a/src/client/app/common/scripts/streaming/local-timeline.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Local timeline stream connection
- */
-export class LocalTimelineStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'local-timeline', {
-			i: me.token
-		});
-	}
-}
-
-export class LocalTimelineStreamManager extends StreamManager<LocalTimelineStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new LocalTimelineStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts
deleted file mode 100644
index addcccb952..0000000000
--- a/src/client/app/common/scripts/streaming/messaging-index.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Messaging index stream connection
- */
-export class MessagingIndexStream extends Stream {
-	constructor(os: MiOS, me) {
-		super(os, 'messaging-index', {
-			i: me.token
-		});
-	}
-}
-
-export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> {
-	private me;
-	private os: MiOS;
-
-	constructor(os: MiOS, me) {
-		super();
-
-		this.me = me;
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new MessagingIndexStream(this.os, this.me);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts
deleted file mode 100644
index a59377d867..0000000000
--- a/src/client/app/common/scripts/streaming/messaging.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import Stream from './stream';
-import MiOS from '../../../mios';
-
-/**
- * Messaging stream connection
- */
-export class MessagingStream extends Stream {
-	constructor(os: MiOS, me, otherparty) {
-		super(os, 'messaging', {
-			i: me.token,
-			otherparty
-		});
-
-		(this as any).on('_connected_', () => {
-			this.send({
-				i: me.token
-			});
-		});
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/notes-stats.ts b/src/client/app/common/scripts/streaming/notes-stats.ts
deleted file mode 100644
index 9e3e78a709..0000000000
--- a/src/client/app/common/scripts/streaming/notes-stats.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Notes stats stream connection
- */
-export class NotesStatsStream extends Stream {
-	constructor(os: MiOS) {
-		super(os, 'notes-stats');
-	}
-}
-
-export class NotesStatsStreamManager extends StreamManager<NotesStatsStream> {
-	private os: MiOS;
-
-	constructor(os: MiOS) {
-		super();
-
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new NotesStatsStream(this.os);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/server-stats.ts b/src/client/app/common/scripts/streaming/server-stats.ts
deleted file mode 100644
index 9983dfcaf0..0000000000
--- a/src/client/app/common/scripts/streaming/server-stats.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import Stream from './stream';
-import StreamManager from './stream-manager';
-import MiOS from '../../../mios';
-
-/**
- * Server stats stream connection
- */
-export class ServerStatsStream extends Stream {
-	constructor(os: MiOS) {
-		super(os, 'server-stats');
-	}
-}
-
-export class ServerStatsStreamManager extends StreamManager<ServerStatsStream> {
-	private os: MiOS;
-
-	constructor(os: MiOS) {
-		super();
-
-		this.os = os;
-	}
-
-	public getConnection() {
-		if (this.connection == null) {
-			this.connection = new ServerStatsStream(this.os);
-		}
-
-		return this.connection;
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/stream-manager.ts b/src/client/app/common/scripts/streaming/stream-manager.ts
deleted file mode 100644
index 568b8b0372..0000000000
--- a/src/client/app/common/scripts/streaming/stream-manager.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { EventEmitter } from 'eventemitter3';
-import * as uuid from 'uuid';
-import Connection from './stream';
-
-/**
- * ストリーム接続を管理するクラス
- * 複数の場所から同じストリームを利用する際、接続をまとめたりする
- */
-export default abstract class StreamManager<T extends Connection> extends EventEmitter {
-	private _connection: T = null;
-
-	private disposeTimerId: any;
-
-	/**
-	 * コネクションを必要としているユーザー
-	 */
-	private users = [];
-
-	protected set connection(connection: T) {
-		this._connection = connection;
-
-		if (this._connection == null) {
-			this.emit('disconnected');
-		} else {
-			this.emit('connected', this._connection);
-
-			this._connection.on('_connected_', () => {
-				this.emit('_connected_');
-			});
-
-			this._connection.on('_disconnected_', () => {
-				this.emit('_disconnected_');
-			});
-
-			this._connection.user = 'Managed';
-		}
-	}
-
-	protected get connection() {
-		return this._connection;
-	}
-
-	/**
-	 * コネクションを持っているか否か
-	 */
-	public get hasConnection() {
-		return this._connection != null;
-	}
-
-	public get state(): string {
-		if (!this.hasConnection) return 'no-connection';
-		return this._connection.state;
-	}
-
-	/**
-	 * コネクションを要求します
-	 */
-	public abstract getConnection(): T;
-
-	/**
-	 * 現在接続しているコネクションを取得します
-	 */
-	public borrow() {
-		return this._connection;
-	}
-
-	/**
-	 * コネクションを要求するためのユーザーIDを発行します
-	 */
-	public use() {
-		// タイマー解除
-		if (this.disposeTimerId) {
-			clearTimeout(this.disposeTimerId);
-			this.disposeTimerId = null;
-		}
-
-		// ユーザーID生成
-		const userId = uuid();
-
-		this.users.push(userId);
-
-		this._connection.user = `Managed (${ this.users.length })`;
-
-		return userId;
-	}
-
-	/**
-	 * コネクションを利用し終わってもう必要ないことを通知します
-	 * @param userId use で発行したユーザーID
-	 */
-	public dispose(userId) {
-		this.users = this.users.filter(id => id != userId);
-
-		this._connection.user = `Managed (${ this.users.length })`;
-
-		// 誰もコネクションの利用者がいなくなったら
-		if (this.users.length == 0) {
-			// また直ぐに再利用される可能性があるので、一定時間待ち、
-			// 新たな利用者が現れなければコネクションを切断する
-			this.disposeTimerId = setTimeout(() => {
-				this.disposeTimerId = null;
-
-				this.connection.close();
-				this.connection = null;
-			}, 3000);
-		}
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts
deleted file mode 100644
index fefa8e5ced..0000000000
--- a/src/client/app/common/scripts/streaming/stream.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { EventEmitter } from 'eventemitter3';
-import * as uuid from 'uuid';
-import * as ReconnectingWebsocket from 'reconnecting-websocket';
-import { wsUrl } from '../../../config';
-import MiOS from '../../../mios';
-
-/**
- * Misskey stream connection
- */
-export default class Connection extends EventEmitter {
-	public state: string;
-	private buffer: any[];
-	public socket: ReconnectingWebsocket;
-	public name: string;
-	public connectedAt: Date;
-	public user: string = null;
-	public in: number = 0;
-	public out: number = 0;
-	public inout: Array<{
-		type: 'in' | 'out',
-		at: Date,
-		data: string
-	}> = [];
-	public id: string;
-	public isSuspended = false;
-	private os: MiOS;
-
-	constructor(os: MiOS, endpoint, params?) {
-		super();
-
-		//#region BIND
-		this.onOpen =    this.onOpen.bind(this);
-		this.onClose =   this.onClose.bind(this);
-		this.onMessage = this.onMessage.bind(this);
-		this.send =      this.send.bind(this);
-		this.close =     this.close.bind(this);
-		//#endregion
-
-		this.id = uuid();
-		this.os = os;
-		this.name = endpoint;
-		this.state = 'initializing';
-		this.buffer = [];
-
-		const query = params
-			? Object.keys(params)
-				.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
-				.join('&')
-			: null;
-
-		this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`);
-		this.socket.addEventListener('open', this.onOpen);
-		this.socket.addEventListener('close', this.onClose);
-		this.socket.addEventListener('message', this.onMessage);
-
-		// Register this connection for debugging
-		this.os.registerStreamConnection(this);
-	}
-
-	/**
-	 * Callback of when open connection
-	 */
-	private onOpen() {
-		this.state = 'connected';
-		this.emit('_connected_');
-
-		this.connectedAt = new Date();
-
-		// バッファーを処理
-		const _buffer = [].concat(this.buffer); // Shallow copy
-		this.buffer = []; // Clear buffer
-		_buffer.forEach(data => {
-			this.send(data); // Resend each buffered messages
-
-			if (this.os.debug) {
-				this.out++;
-				this.inout.push({ type: 'out', at: new Date(), data });
-			}
-		});
-	}
-
-	/**
-	 * Callback of when close connection
-	 */
-	private onClose() {
-		this.state = 'reconnecting';
-		this.emit('_disconnected_');
-	}
-
-	/**
-	 * Callback of when received a message from connection
-	 */
-	private onMessage(message) {
-		if (this.isSuspended) return;
-
-		if (this.os.debug) {
-			this.in++;
-			this.inout.push({ type: 'in', at: new Date(), data: message.data });
-		}
-
-		try {
-			const msg = JSON.parse(message.data);
-			if (msg.type) this.emit(msg.type, msg.body);
-		} catch (e) {
-			// noop
-		}
-	}
-
-	/**
-	 * Send a message to connection
-	 */
-	public send(data) {
-		if (this.isSuspended) return;
-
-		// まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する
-		if (this.state != 'connected') {
-			this.buffer.push(data);
-			return;
-		}
-
-		if (this.os.debug) {
-			this.out++;
-			this.inout.push({ type: 'out', at: new Date(), data });
-		}
-
-		this.socket.send(JSON.stringify(data));
-	}
-
-	/**
-	 * Close this connection
-	 */
-	public close() {
-		this.os.unregisterStreamConnection(this);
-		this.socket.removeEventListener('open', this.onOpen);
-		this.socket.removeEventListener('message', this.onMessage);
-	}
-}
diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts
deleted file mode 100644
index 30a52b98dd..0000000000
--- a/src/client/app/common/scripts/streaming/user-list.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import Stream from './stream';
-import MiOS from '../../mios';
-
-export class UserListStream extends Stream {
-	constructor(os: MiOS, me, listId) {
-		super(os, 'user-list', {
-			i: me.token,
-			listId
-		});
-
-		(this as any).on('_connected_', () => {
-			this.send({
-				i: me.token
-			});
-		});
-	}
-}
diff --git a/src/client/app/common/views/components/acct.vue b/src/client/app/common/views/components/acct.vue
index 1ad222afdd..542fbb4296 100644
--- a/src/client/app/common/views/components/acct.vue
+++ b/src/client/app/common/views/components/acct.vue
@@ -1,19 +1,25 @@
 <template>
 <span class="mk-acct">
 	<span class="name">@{{ user.username }}</span>
-	<span class="host" v-if="user.host">@{{ user.host }}</span>
+	<span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span>
 </span>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import { host } from '../../../config';
 export default Vue.extend({
-	props: ['user']
+	props: ['user', 'detail'],
+	data() {
+		return {
+			host
+		};
+	}
 });
 </script>
 
 <style lang="stylus" scoped>
 .mk-acct
-	> .host
+	> .host.fade
 		opacity 0.5
 </style>
diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue
index b274eaa0a0..bc0120c9ab 100644
--- a/src/client/app/common/views/components/autocomplete.vue
+++ b/src/client/app/common/views/components/autocomplete.vue
@@ -125,7 +125,7 @@ export default Vue.extend({
 			}
 
 			if (this.type == 'user') {
-				const cacheKey = 'autocomplete:user:' + this.q;
+				const cacheKey = `autocomplete:user:${this.q}`;
 				const cache = sessionStorage.getItem(cacheKey);
 				if (cache) {
 					const users = JSON.parse(cache);
@@ -148,7 +148,7 @@ export default Vue.extend({
 					this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
 					this.fetching = false;
 				} else {
-					const cacheKey = 'autocomplete:hashtag:' + this.q;
+					const cacheKey = `autocomplete:hashtag:${this.q}`;
 					const cache = sessionStorage.getItem(cacheKey);
 					if (cache) {
 						const hashtags = JSON.parse(cache);
@@ -259,15 +259,13 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-autocomplete
 	position fixed
 	z-index 65535
 	max-width 100%
 	margin-top calc(1em + 8px)
 	overflow hidden
-	background isDark ? #313543 : #fff
+	background var(--faceHeader)
 	border solid 1px rgba(#000, 0.1)
 	border-radius 4px
 	transition top 0.1s ease, left 0.1s ease
@@ -299,16 +297,16 @@ root(isDark)
 				text-overflow ellipsis
 
 			&:hover
-				background isDark ? rgba(#fff, 0.1) : rgba(#000, 0.1)
+				background var(--autocompleteItemHoverBg)
 
 			&[data-selected='true']
-				background $theme-color
+				background var(--primary)
 
 				&, *
 					color #fff !important
 
 			&:active
-				background darken($theme-color, 10%)
+				background var(--primaryDarken10)
 
 				&, *
 					color #fff !important
@@ -325,15 +323,15 @@ root(isDark)
 
 		.name
 			margin 0 8px 0 0
-			color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
+			color var(--autocompleteItemText)
 
 		.username
-			color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3)
+			color var(--autocompleteItemTextSub)
 
 	> .hashtags > li
 
 		.name
-			color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
+			color var(--autocompleteItemText)
 
 	> .emojis > li
 
@@ -343,15 +341,9 @@ root(isDark)
 			width 24px
 
 		.name
-			color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8)
+			color var(--autocompleteItemText)
 
 		.alias
 			margin 0 0 0 8px
-			color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3)
-
-.mk-autocomplete[data-darkmode]
-	root(true)
-
-.mk-autocomplete:not([data-darkmode])
-	root(false)
+			color var(--autocompleteItemTextSub)
 </style>
diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue
index c5ac74e537..ac018abcfc 100644
--- a/src/client/app/common/views/components/avatar.vue
+++ b/src/client/app/common/views/components/avatar.vue
@@ -1,15 +1,15 @@
 <template>
-	<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick">
-		<span class="inner" :style="style"></span>
+	<span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick">
+		<span class="inner" :style="icon"></span>
 	</span>
-	<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick">
-		<span class="inner" :style="style"></span>
+	<span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick">
+		<span class="inner" :style="icon"></span>
 	</span>
-	<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id">
-		<span class="inner" :style="style"></span>
+	<router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id">
+		<span class="inner" :style="icon"></span>
 	</router-link>
-	<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview">
-		<span class="inner" :style="style"></span>
+	<router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview">
+		<span class="inner" :style="icon"></span>
 	</router-link>
 </template>
 
@@ -42,6 +42,11 @@ export default Vue.extend({
 			return this.user.isCat && this.$store.state.settings.circleIcons;
 		},
 		style(): any {
+			return {
+				borderRadius: this.$store.state.settings.circleIcons ? '100%' : null
+			};
+		},
+		icon(): any {
 			return {
 				backgroundColor: this.lightmode
 					? `rgb(${this.user.avatarColor.slice(0, 3).join(',')})`
@@ -53,6 +58,11 @@ export default Vue.extend({
 			};
 		}
 	},
+	mounted() {
+		if (this.user.avatarColor) {
+			this.$el.style.color = `rgb(${this.user.avatarColor.slice(0, 3).join(',')})`;
+		}
+	},
 	methods: {
 		onClick(e) {
 			this.$emit('click', e);
@@ -62,8 +72,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-
-root(isDark)
+.mk-avatar
 	display inline-block
 	vertical-align bottom
 
@@ -74,7 +83,7 @@ root(isDark)
 	&.cat::before,
 	&.cat::after
 		background #df548f
-		border solid 4px isDark ? #e0eefd : #202224
+		border solid 4px currentColor
 		box-sizing border-box
 		content ''
 		display inline-block
@@ -100,9 +109,4 @@ root(isDark)
 		transition border-radius 1s ease
 		z-index 1
 
-.mk-avatar[data-darkmode]
-	root(true)
-
-.mk-avatar:not([data-darkmode])
-	root(false)
 </style>
diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue
index 6c23cc7969..f64cae6b4b 100644
--- a/src/client/app/common/views/components/connect-failed.troubleshooter.vue
+++ b/src/client/app/common/views/components/connect-failed.troubleshooter.vue
@@ -57,7 +57,7 @@ export default Vue.extend({
 		}
 
 		// Check internet connection
-		fetch('https://google.com?rand=' + Math.random(), {
+		fetch(`https://google.com?rand=${Math.random()}`, {
 			mode: 'no-cors'
 		}).then(() => {
 			this.internet = true;
diff --git a/src/client/app/common/views/components/connect-failed.vue b/src/client/app/common/views/components/connect-failed.vue
index 0f686926b0..36cae05665 100644
--- a/src/client/app/common/views/components/connect-failed.vue
+++ b/src/client/app/common/views/components/connect-failed.vue
@@ -39,7 +39,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-connect-failed
 	width 100%
@@ -70,17 +70,17 @@ export default Vue.extend({
 		display block
 		margin 1em auto 0 auto
 		padding 8px 10px
-		color $theme-color-foreground
-		background $theme-color
+		color var(--primaryForeground)
+		background var(--primary)
 
 		&:focus
-			outline solid 3px rgba($theme-color, 0.3)
+			outline solid 3px var(--primaryAlpha03)
 
 		&:hover
-			background lighten($theme-color, 10%)
+			background var(--primaryLighten10)
 
 		&:active
-			background darken($theme-color, 10%)
+			background var(--primaryDarken10)
 
 	> .thanks
 		display block
diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue
new file mode 100644
index 0000000000..79917f82ab
--- /dev/null
+++ b/src/client/app/common/views/components/cw-button.vue
@@ -0,0 +1,38 @@
+<template>
+<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">{{ value ? '%i18n:@hide%' : '%i18n:@show%' }}</button>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		value: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	methods: {
+		toggle() {
+			this.$emit('input', !this.value);
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.nrvgflfuaxwgkxoynpnumyookecqrrvh
+	display inline-block
+	padding 4px 8px
+	font-size 0.7em
+	color var(--cwButtonFg)
+	background var(--cwButtonBg)
+	border-radius 2px
+	cursor pointer
+	user-select none
+
+	&:hover
+		background var(--cwButtonHoverBg)
+
+</style>
diff --git a/src/client/app/common/views/components/forkit.vue b/src/client/app/common/views/components/forkit.vue
index de627181ef..b303b48b79 100644
--- a/src/client/app/common/views/components/forkit.vue
+++ b/src/client/app/common/views/components/forkit.vue
@@ -9,7 +9,7 @@
 </template>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .a
 	display block
@@ -18,8 +18,8 @@
 		display block
 		//fill #151513
 		//color #fff
-		fill $theme-color
-		color $theme-color-foreground
+		fill var(--primary)
+		color var(--primaryForeground)
 
 		.octo-arm
 			transform-origin 130px 106px
diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue
index b432a2308d..751abe2ecd 100644
--- a/src/client/app/common/views/components/games/reversi/reversi.game.vue
+++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue
@@ -50,15 +50,15 @@
 	</div>
 
 	<div class="player" v-if="game.isEnded">
-		<el-button-group>
-			<el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button>
-			<el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button>
-		</el-button-group>
+		<div>
+			<button @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</button>
+			<button @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</button>
+		</div>
 		<span>{{ logPos }} / {{ logs.length }}</span>
-		<el-button-group>
-			<el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button>
-			<el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button>
-		</el-button-group>
+		<div>
+			<button @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</button>
+			<button @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</button>
+		</div>
 	</div>
 
 	<div class="info">
@@ -159,11 +159,9 @@ export default Vue.extend({
 				canPutEverywhere: this.game.settings.canPutEverywhere,
 				loopedBoard: this.game.settings.loopedBoard
 			});
-			this.logs.forEach((log, i) => {
-				if (i < v) {
-					this.o.put(log.color, log.pos);
-				}
-			});
+			for (const log of this.logs.slice(0, v)) {
+				this.o.put(log.color, log.pos);
+			}
 			this.$forceUpdate();
 		}
 	},
@@ -306,9 +304,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.xqnhankfuuilcwvhgsopeqncafzsquya
 	text-align center
 
 	> .go-index
@@ -321,7 +317,7 @@ root(isDark)
 
 	> header
 		padding 8px
-		border-bottom dashed 1px isDark ? #4c5761 : #c4cdd4
+		border-bottom dashed 1px var(--reversiGameHeaderLine)
 
 		a
 			color inherit
@@ -388,30 +384,30 @@ root(isDark)
 						user-select none
 
 					&.empty
-						border solid 2px isDark ? #51595f : #eee
+						border solid 2px var(--reversiGameEmptyCell)
 
 					&.empty.can
-						background isDark ? #51595f : #eee
+						background var(--reversiGameEmptyCell)
 
 					&.empty.myTurn
-						border-color isDark ? #6a767f : #ddd
+						border-color var(--reversiGameEmptyCellMyTurn)
 
 						&.can
-							background isDark ? #51595f : #eee
+							background var(--reversiGameEmptyCellCanPut)
 							cursor pointer
 
 							&:hover
-								border-color darken($theme-color, 10%)
-								background $theme-color
+								border-color var(--primaryDarken10)
+								background var(--primary)
 
 							&:active
-								background darken($theme-color, 10%)
+								background var(--primaryDarken10)
 
 					&.prev
-						box-shadow 0 0 0 4px rgba($theme-color, 0.7)
+						box-shadow 0 0 0 4px var(--primaryAlpha07)
 
 					&.isEnded
-						border-color isDark ? #6a767f : #ddd
+						border-color var(--reversiGameEmptyCellMyTurn)
 
 					&.none
 						border-color transparent !important
@@ -460,10 +456,4 @@ root(isDark)
 			margin 0 8px
 			min-width 70px
 
-.xqnhankfuuilcwvhgsopeqncafzsquya[data-darkmode]
-	root(true)
-
-.xqnhankfuuilcwvhgsopeqncafzsquya:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue b/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue
index 1539c88de0..0a18e0b19a 100644
--- a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue
+++ b/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue
@@ -9,7 +9,6 @@
 import Vue from 'vue';
 import XGame from './reversi.game.vue';
 import XRoom from './reversi.room.vue';
-import { ReversiGameStream } from '../../../../scripts/streaming/games/reversi/reversi-game';
 
 export default Vue.extend({
 	components: {
@@ -34,12 +33,13 @@ export default Vue.extend({
 	},
 	created() {
 		this.g = this.game;
-		this.connection = new ReversiGameStream((this as any).os, this.$store.state.i, this.game);
+		this.connection = (this as any).os.stream.connectToChannel('gamesReversiGame', {
+			gameId: this.game.id
+		});
 		this.connection.on('started', this.onStarted);
 	},
 	beforeDestroy() {
-		this.connection.off('started', this.onStarted);
-		this.connection.close();
+		this.connection.dispose();
 	},
 	methods: {
 		onStarted(game) {
diff --git a/src/client/app/common/views/components/games/reversi/reversi.index.vue b/src/client/app/common/views/components/games/reversi/reversi.index.vue
index fa88aeaaf4..a040162802 100644
--- a/src/client/app/common/views/components/games/reversi/reversi.index.vue
+++ b/src/client/app/common/views/components/games/reversi/reversi.index.vue
@@ -3,7 +3,6 @@
 	<h1>%i18n:@title%</h1>
 	<p>%i18n:@sub-title%</p>
 	<div class="play">
-		<!--<el-button round>フリーマッチ(準備中)</el-button>-->
 		<form-button primary round @click="match">%i18n:@invite%</form-button>
 		<details>
 			<summary>%i18n:@rule%</summary>
@@ -60,15 +59,13 @@ export default Vue.extend({
 			myGames: [],
 			matching: null,
 			invitations: [],
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 
 	mounted() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.streams.reversiStream.getConnection();
-			this.connectionId = (this as any).os.streams.reversiStream.use();
+			this.connection = (this as any).os.stream.useSharedConnection('gamesReversi');
 
 			this.connection.on('invited', this.onInvited);
 
@@ -91,8 +88,7 @@ export default Vue.extend({
 
 	beforeDestroy() {
 		if (this.connection) {
-			this.connection.off('invited', this.onInvited);
-			(this as any).os.streams.reversiStream.dispose(this.connectionId);
+			this.connection.dispose();
 		}
 	},
 
@@ -139,9 +135,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx
 	> h1
 		margin 0
 		padding 24px
@@ -149,7 +143,7 @@ root(isDark)
 		text-align center
 		font-weight normal
 		color #fff
-		background linear-gradient(to bottom, isDark ? #45730e : #8bca3e, isDark ? #464300 : #d6cf31)
+		background linear-gradient(to bottom, var(--reversiBannerGradientStart), var(--reversiBannerGradientEnd))
 
 		& + p
 			margin 0
@@ -157,7 +151,7 @@ root(isDark)
 			margin-bottom 12px
 			text-align center
 			font-size 14px
-			border-bottom solid 1px isDark ? #535f65 : #d3d9dc
+			border-bottom solid 1px var(--faceDivider)
 
 	> .play
 		margin 0 auto
@@ -172,14 +166,14 @@ root(isDark)
 				padding 16px
 				font-size 14px
 				text-align left
-				background isDark ? #282c37 : #f5f5f5
+				background var(--reversiDescBg)
 				border-radius 8px
 
 	> section
 		margin 0 auto
 		padding 0 16px 16px 16px
 		max-width 500px
-		border-top solid 1px isDark ? #535f65 : #d3d9dc
+		border-top solid 1px var(--faceDivider)
 
 		> h2
 			margin 0
@@ -190,9 +184,9 @@ root(isDark)
 	.invitation
 		margin 8px 0
 		padding 8px
-		color isDark ? #fff : #677f84
-		background isDark ? #282c37 : #fff
-		box-shadow 0 2px 16px rgba(#000, isDark ? 0.7 : 0.15)
+		color var(--text)
+		background var(--face)
+		box-shadow 0 2px 16px var(--reversiListItemShadow)
 		border-radius 6px
 		cursor pointer
 
@@ -201,13 +195,13 @@ root(isDark)
 			user-select none
 
 		&:focus
-			border-color $theme-color
+			border-color var(--primary)
 
 		&:hover
-			background isDark ? #313543 : #f5f5f5
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
 		&:active
-			background isDark ? #1e222b : #eee
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 		> .avatar
 			width 32px
@@ -222,9 +216,9 @@ root(isDark)
 		display block
 		margin 8px 0
 		padding 8px
-		color isDark ? #fff : #677f84
-		background isDark ? #282c37 : #fff
-		box-shadow 0 2px 16px rgba(#000, isDark ? 0.7 : 0.15)
+		color var(--text)
+		background var(--face)
+		box-shadow 0 2px 16px var(--reversiListItemShadow)
 		border-radius 6px
 		cursor pointer
 
@@ -233,10 +227,10 @@ root(isDark)
 			user-select none
 
 		&:hover
-			background isDark ? #313543 : #f5f5f5
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
 		&:active
-			background isDark ? #1e222b : #eee
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 		> .avatar
 			width 32px
@@ -247,10 +241,4 @@ root(isDark)
 			margin 0 8px
 			line-height 32px
 
-.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx[data-darkmode]
-	root(true)
-
-.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/games/reversi/reversi.room.vue b/src/client/app/common/views/components/games/reversi/reversi.room.vue
index aed8718dd0..9f0d9c23fb 100644
--- a/src/client/app/common/views/components/games/reversi/reversi.room.vue
+++ b/src/client/app/common/views/components/games/reversi/reversi.room.vue
@@ -47,9 +47,9 @@
 			</header>
 
 			<div>
-				<mk-switch v-model="game.settings.isLlotheo" @change="updateSettings" text="%i18n:@is-llotheo%"/>
-				<mk-switch v-model="game.settings.loopedBoard" @change="updateSettings" text="%i18n:@looped-map%"/>
-				<mk-switch v-model="game.settings.canPutEverywhere" @change="updateSettings" text="%i18n:@can-put-everywhere%"/>
+				<ui-switch v-model="game.settings.isLlotheo" @change="updateSettings">%i18n:@is-llotheo%</ui-switch>
+				<ui-switch v-model="game.settings.loopedBoard" @change="updateSettings">%i18n:@looped-map%</ui-switch>
+				<ui-switch v-model="game.settings.canPutEverywhere" @change="updateSettings">%i18n:@can-put-everywhere%</ui-switch>
 			</div>
 		</div>
 
@@ -59,13 +59,8 @@
 			</header>
 
 			<div>
-				<el-alert v-for="message in messages"
-						:title="message.text"
-						:type="message.type"
-						:key="message.id"/>
-
 				<template v-for="item in form">
-					<mk-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</mk-switch>
+					<ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</ui-switch>
 
 					<div class="card" v-if="item.type == 'radio'" :key="item.id">
 						<header>
@@ -93,7 +88,7 @@
 						</header>
 
 						<div>
-							<el-input v-model="item.value" @change="onChangeForm(item)"/>
+							<input v-model="item.value" @change="onChangeForm(item)"/>
 						</div>
 					</div>
 				</template>
@@ -257,11 +252,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.urbixznjwwuukfsckrwzwsqzsxornqij
 	text-align center
-	background isDark ? #191b22 : #f9f9f9
+	background var(--bg)
 
 	> header
 		padding 8px
@@ -278,10 +271,10 @@ root(isDark)
 					> select
 						width 100%
 						padding 12px 14px
-						background isDark ? #282C37 : #fff
-						border 1px solid isDark ? #6a707d : #dcdfe6
+						background var(--face)
+						border 1px solid var(--reversiMapSelectBorder)
 						border-radius 4px
-						color isDark ? #fff : #606266
+						color var(--text)
 						cursor pointer
 						transition border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1)
 						-webkit-appearance none
@@ -289,17 +282,18 @@ root(isDark)
 						appearance none
 
 						&:hover
-							border-color isDark ? #a7aebd : #c0c4cc
+							border-color var(--reversiMapSelectHoverBorder)
 
 						&:focus
 						&:active
-							border-color $theme-color
+							border-color var(--primary)
 
 				> div
 					> .random
 						padding 32px 0
 						font-size 64px
-						color isDark ? #4e5961 : #d8d8d8
+						color var(--text)
+						opacity 0.7
 
 					> .board
 						display grid
@@ -307,11 +301,11 @@ root(isDark)
 						width 300px
 						height 300px
 						margin 0 auto
-						color isDark ? #fff : #444
+						color var(--text)
 
 						> div
 							background transparent
-							border solid 2px isDark ? #6a767f : #ddd
+							border solid 2px var(--faceDivider)
 							border-radius 6px
 							overflow hidden
 							cursor pointer
@@ -336,32 +330,26 @@ root(isDark)
 		.card
 			max-width 400px
 			border-radius 4px
-			background isDark ? #282C37 : #fff
-			color isDark ? #fff : #303133
-			box-shadow 0 2px 12px 0 rgba(#000, isDark ? 0.7 : 0.1)
+			background var(--face)
+			color var(--text)
+			box-shadow 0 2px 12px 0 var(--reversiRoomFormShadow)
 
 			> header
 				padding 18px 20px
-				border-bottom 1px solid isDark ? #1c2023 : #ebeef5
+				border-bottom 1px solid var(--faceDivider)
 
 			> div
 				padding 20px
-				color isDark ? #fff : #606266
+				color var(--text)
 
 	> footer
 		position sticky
 		bottom 0
 		padding 16px
-		background rgba(isDark ? #191b22 : #fff, 0.9)
-		border-top solid 1px isDark ? #606266 : #c4cdd4
+		background var(--reversiRoomFooterBg)
+		border-top solid 1px var(--faceDivider)
 
 		> .status
 			margin 0 0 16px 0
 
-.urbixznjwwuukfsckrwzwsqzsxornqij[data-darkmode]
-	root(true)
-
-.urbixznjwwuukfsckrwzwsqzsxornqij:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/games/reversi/reversi.vue b/src/client/app/common/views/components/games/reversi/reversi.vue
index 223ec4597a..f2156bc41b 100644
--- a/src/client/app/common/views/components/games/reversi/reversi.vue
+++ b/src/client/app/common/views/components/games/reversi/reversi.vue
@@ -47,7 +47,6 @@ export default Vue.extend({
 			game: null,
 			matching: null,
 			connection: null,
-			connectionId: null,
 			pingClock: null
 		};
 	},
@@ -66,8 +65,7 @@ export default Vue.extend({
 		this.fetch();
 
 		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.streams.reversiStream.getConnection();
-			this.connectionId = (this as any).os.streams.reversiStream.use();
+			this.connection = (this as any).os.stream.useSharedConnection('gamesReversi');
 
 			this.connection.on('matched', this.onMatched);
 
@@ -84,9 +82,7 @@ export default Vue.extend({
 
 	beforeDestroy() {
 		if (this.connection) {
-			this.connection.off('matched', this.onMatched);
-			(this as any).os.streams.reversiStream.dispose(this.connectionId);
-
+			this.connection.dispose();
 			clearInterval(this.pingClock);
 		}
 	},
@@ -156,11 +152,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	color isDark ? #fff : #677f84
-	background isDark ? #191b22 : #fff
+.vchtoekanapleubgzioubdtmlkribzfd
+	color var(--text)
+	background var(--bg)
 
 	> .matching
 		> h1
@@ -177,10 +171,4 @@ root(isDark)
 			text-align center
 			border-top dashed 1px #c4cdd4
 
-.vchtoekanapleubgzioubdtmlkribzfd[data-darkmode]
-	root(true)
-
-.vchtoekanapleubgzioubdtmlkribzfd:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue
index 8272961ef2..ac71a5e56d 100644
--- a/src/client/app/common/views/components/google.vue
+++ b/src/client/app/common/views/components/google.vue
@@ -26,7 +26,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-google
 	display flex
 	margin 8px 0
 
@@ -37,31 +37,25 @@ root(isDark)
 		height 40px
 		font-family sans-serif
 		font-size 16px
-		color isDark ? #dee4e8 : #55595c
-		background isDark ? #191b22 : #fff
-		border solid 1px isDark ? #495156 : #dadada
+		color var(--googleSearchFg)
+		background var(--googleSearchBg)
+		border solid 1px var(--googleSearchBorder)
 		border-radius 4px 0 0 4px
 
 		&:hover
-			border-color isDark ? #777c86 : #b0b0b0
+			border-color var(--googleSearchHoverBorder)
 
 	> button
 		flex-shrink 0
 		padding 0 16px
-		border solid 1px isDark ? #495156 : #dadada
+		border solid 1px var(--googleSearchBorder)
 		border-left none
 		border-radius 0 4px 4px 0
 
 		&:hover
-			background-color isDark ? #2e3440 : #eee
+			background-color var(--googleSearchHoverButton)
 
 		&:active
 			box-shadow 0 2px 4px rgba(#000, 0.15) inset
 
-.mk-google[data-darkmode]
-	root(true)
-
-.mk-google:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index 422a3da050..0dea38a7a1 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -1,5 +1,10 @@
 import Vue from 'vue';
 
+import theme from './theme.vue';
+import instance from './instance.vue';
+import cwButton from './cw-button.vue';
+import tagCloud from './tag-cloud.vue';
+import trends from './trends.vue';
 import analogClock from './analog-clock.vue';
 import menu from './menu.vue';
 import noteHeader from './note-header.vue';
@@ -26,7 +31,6 @@ import messagingRoom from './messaging-room.vue';
 import urlPreview from './url-preview.vue';
 import twitterSetting from './twitter-setting.vue';
 import fileTypeIcon from './file-type-icon.vue';
-import Switch from './switch.vue';
 import Reversi from './games/reversi/reversi.vue';
 import welcomeTimeline from './welcome-timeline.vue';
 import uiInput from './ui/input.vue';
@@ -40,6 +44,11 @@ import uiSelect from './ui/select.vue';
 import formButton from './ui/form/button.vue';
 import formRadio from './ui/form/radio.vue';
 
+Vue.component('mk-theme', theme);
+Vue.component('mk-instance', instance);
+Vue.component('mk-cw-button', cwButton);
+Vue.component('mk-tag-cloud', tagCloud);
+Vue.component('mk-trends', trends);
 Vue.component('mk-analog-clock', analogClock);
 Vue.component('mk-menu', menu);
 Vue.component('mk-note-header', noteHeader);
@@ -66,7 +75,6 @@ Vue.component('mk-messaging-room', messagingRoom);
 Vue.component('mk-url-preview', urlPreview);
 Vue.component('mk-twitter-setting', twitterSetting);
 Vue.component('mk-file-type-icon', fileTypeIcon);
-Vue.component('mk-switch', Switch);
 Vue.component('mk-reversi', Reversi);
 Vue.component('mk-welcome-timeline', welcomeTimeline);
 Vue.component('ui-input', uiInput);
diff --git a/src/client/app/common/views/components/instance.vue b/src/client/app/common/views/components/instance.vue
new file mode 100644
index 0000000000..c3935cce0e
--- /dev/null
+++ b/src/client/app/common/views/components/instance.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="nhasjydimbopojusarffqjyktglcuxjy" v-if="meta">
+	<div class="banner" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"></div>
+
+	<h1>{{ meta.name }}</h1>
+	<p v-html="meta.description || '%i18n:common.about%'"></p>
+	<router-link to="/">%i18n:@start%</router-link>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	data() {
+		return {
+			meta: null
+		}
+	},
+	created() {
+		(this as any).os.getMeta().then(meta => {
+			this.meta = meta;
+		});
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.nhasjydimbopojusarffqjyktglcuxjy
+	color var(--text)
+	background var(--face)
+	text-align center
+
+	> .banner
+		height 100px
+		background-position center
+		background-size cover
+
+	> h1
+		margin 16px
+		font-size 16px
+
+	> p
+		margin 16px
+		font-size 14px
+
+	> a
+		display block
+		padding-bottom 16px
+
+</style>
diff --git a/src/client/app/common/views/components/media-banner.vue b/src/client/app/common/views/components/media-banner.vue
new file mode 100644
index 0000000000..0f5981d3c4
--- /dev/null
+++ b/src/client/app/common/views/components/media-banner.vue
@@ -0,0 +1,85 @@
+<template>
+<div class="mk-media-banner">
+	<div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false">
+		<span class="icon">%fa:exclamation-triangle%</span>
+		<b>%i18n:@sensitive%</b>
+		<span>%i18n:@click-to-show%</span>
+	</div>
+	<div class="audio" v-else-if="media.type.startsWith('audio')">
+		<audio class="audio"
+			:src="media.url"
+			:title="media.name"
+			controls
+			ref="audio"
+			preload="metadata" />
+	</div>
+	<a class="download" v-else
+		:href="media.url"
+		:title="media.name"
+		:download="media.name"
+	>
+		<span class="icon">%fa:download%</span>
+		<b>{{ media.name }}</b>
+	</a>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		media: {
+			type: Object,
+			required: true
+		}
+	},
+	data() {
+		return {
+			hide: true
+		};
+	}
+})
+</script>
+
+<style lang="stylus" scoped>
+.mk-media-banner
+	width 100%
+	border-radius 4px
+	margin-top 4px
+	overflow hidden
+
+	> .download,
+	> .sensitive
+		display flex
+		align-items center
+		font-size 12px
+		padding 8px 12px
+		white-space nowrap
+
+		> *
+			display block
+
+		> b
+			overflow hidden
+			text-overflow ellipsis
+
+		> *:not(:last-child)
+			margin-right .2em
+
+		> .icon
+			font-size 1.6em
+
+	> .download
+		background var(--noteAttachedFile)
+
+	> .sensitive
+		background #111
+		color #fff
+
+	> .audio
+		.audio
+			display block
+			width 100%
+
+</style>
diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue
index cdfc2c8d3c..d83d6f85cd 100644
--- a/src/client/app/common/views/components/media-list.vue
+++ b/src/client/app/common/views/components/media-list.vue
@@ -1,18 +1,27 @@
 <template>
 <div class="mk-media-list">
-	<div :data-count="mediaList.length" ref="grid">
-		<template v-for="media in mediaList">
-			<mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/>
-			<mk-media-image :image="media" :key="media.id" v-else :raw="raw"/>
-		</template>
+	<template v-for="media in mediaList.filter(media => !previewable(media))">
+		<x-banner :media="media" :key="media.id"/>
+	</template>
+	<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
+		<div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid">
+			<template v-for="media in mediaList">
+				<mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/>
+				<mk-media-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/>
+			</template>
+		</div>
 	</div>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import XBanner from './media-banner.vue';
 
 export default Vue.extend({
+	components: {
+		XBanner
+	},
 	props: {
 		mediaList: {
 			required: true
@@ -22,70 +31,80 @@ export default Vue.extend({
 		}
 	},
 	mounted() {
-		// for Safari bug
-		this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px';
+		//#region for Safari bug
+		if (this.$refs.grid) {
+			this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px';
+		}
+		//#endregion
+	},
+	methods: {
+		previewable(file) {
+			return file.type.startsWith('video') || file.type.startsWith('image');
+		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
 .mk-media-list
-	width 100%
+	> .gird-container
+		width 100%
+		margin-top 4px
 
-	&:before
-		content ''
-		display block
-		padding-top 56.25% // 16:9
+		&:before
+			content ''
+			display block
+			padding-top 56.25% // 16:9
 
-	> div
-		position absolute
-		top 0
-		right 0
-		bottom 0
-		left 0
-		display grid
-		grid-gap 4px
+		> div
+			position absolute
+			top 0
+			right 0
+			bottom 0
+			left 0
+			display grid
+			grid-gap 4px
 
-		> *
-			overflow hidden
-			border-radius 4px
+			> *
+				overflow hidden
+				border-radius 4px
 
-		&[data-count="1"]
-			grid-template-rows 1fr
+			&[data-count="1"]
+				grid-template-rows 1fr
 
-		&[data-count="2"]
-			grid-template-columns 1fr 1fr
-			grid-template-rows 1fr
+			&[data-count="2"]
+				grid-template-columns 1fr 1fr
+				grid-template-rows 1fr
 
-		&[data-count="3"]
-			grid-template-columns 1fr 0.5fr
-			grid-template-rows 1fr 1fr
+			&[data-count="3"]
+				grid-template-columns 1fr 0.5fr
+				grid-template-rows 1fr 1fr
+
+				> *:nth-child(1)
+					grid-row 1 / 3
+
+				> *:nth-child(3)
+					grid-column 2 / 3
+					grid-row 2 / 3
+
+			&[data-count="4"]
+				grid-template-columns 1fr 1fr
+				grid-template-rows 1fr 1fr
 
 			> *:nth-child(1)
-				grid-row 1 / 3
+				grid-column 1 / 2
+				grid-row 1 / 2
+
+			> *:nth-child(2)
+				grid-column 2 / 3
+				grid-row 1 / 2
 
 			> *:nth-child(3)
+				grid-column 1 / 2
+				grid-row 2 / 3
+
+			> *:nth-child(4)
 				grid-column 2 / 3
 				grid-row 2 / 3
 
-		&[data-count="4"]
-			grid-template-columns 1fr 1fr
-			grid-template-rows 1fr 1fr
-
-		> *:nth-child(1)
-			grid-column 1 / 2
-			grid-row 1 / 2
-
-		> *:nth-child(2)
-			grid-column 2 / 3
-			grid-row 1 / 2
-
-		> *:nth-child(3)
-			grid-column 1 / 2
-			grid-row 2 / 3
-
-		> *:nth-child(4)
-			grid-column 2 / 3
-			grid-row 2 / 3
-
 </style>
diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue
index 9b16732b9a..be2c03f54c 100644
--- a/src/client/app/common/views/components/menu.vue
+++ b/src/client/app/common/views/components/menu.vue
@@ -1,10 +1,10 @@
 <template>
-<div class="mk-menu">
+<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv">
 	<div class="backdrop" ref="backdrop" @click="close"></div>
 	<div class="popover" :class="{ hukidasi }" ref="popover">
-		<template v-for="item in items">
+		<template v-for="item, i in items">
 			<div v-if="item === null"></div>
-			<button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text"></button>
+			<button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text" :tabindex="i"></button>
 		</template>
 	</div>
 </div>
@@ -108,7 +108,7 @@ export default Vue.extend({
 				easing: 'easeInBack',
 				complete: () => {
 					this.$emit('closed');
-					this.$destroy();
+					this.destroyDom();
 				}
 			});
 		}
@@ -117,11 +117,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+.onchrpzrvnoruiaenfcqvccjfuupzzwv
+	$bg-color = var(--popupBg)
+	$border-color = rgba(27, 31, 35, 0.15)
 
-$border-color = rgba(27, 31, 35, 0.15)
-
-.mk-menu
 	position initial
 
 	> .backdrop
@@ -131,14 +130,14 @@ $border-color = rgba(27, 31, 35, 0.15)
 		z-index 10000
 		width 100%
 		height 100%
-		background rgba(#000, 0.1)
+		background var(--modalBackdrop)
 		opacity 0
 
 	> .popover
 		position absolute
 		z-index 10001
 		padding 8px 0
-		background #fff
+		background $bg-color
 		border 1px solid $border-color
 		border-radius 4px
 		box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
@@ -172,25 +171,26 @@ $border-color = rgba(27, 31, 35, 0.15)
 				border-top solid $balloon-size transparent
 				border-left solid $balloon-size transparent
 				border-right solid $balloon-size transparent
-				border-bottom solid $balloon-size #fff
+				border-bottom solid $balloon-size $bg-color
 
 		> button
 			display block
 			padding 8px 16px
 			width 100%
+			color var(--popupFg)
 
 			&:hover
-				color $theme-color-foreground
-				background $theme-color
+				color var(--primaryForeground)
+				background var(--primary)
 				text-decoration none
 
 			&:active
-				color $theme-color-foreground
-				background darken($theme-color, 10%)
+				color var(--primaryForeground)
+				background var(--primaryDarken10)
 
 		> div
 			margin 8px 0
 			height 1px
-			background #eee
+			background var(--faceDivider)
 
 </style>
diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue
index f183749fad..c93fd7f78d 100644
--- a/src/client/app/common/views/components/messaging-room.form.vue
+++ b/src/client/app/common/views/components/messaging-room.form.vue
@@ -195,9 +195,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-messaging-form
 	> textarea
 		cursor auto
 		display block
@@ -209,10 +207,10 @@ root(isDark)
 		padding 8px
 		resize none
 		font-size 1em
-		color isDark ? #fff : #000
+		color var(--inputText)
 		outline none
 		border none
-		border-top solid 1px isDark ? #4b5056 : #eee
+		border-top solid 1px var(--faceDivider)
 		border-radius 0
 		box-shadow none
 		background transparent
@@ -234,10 +232,10 @@ root(isDark)
 		transition color 0.1s ease
 
 		&:hover
-			color $theme-color
+			color var(--primary)
 
 		&:active
-			color darken($theme-color, 10%)
+			color var(--primaryDarken10)
 			transition color 0s ease
 
 	.files
@@ -293,19 +291,13 @@ root(isDark)
 		transition color 0.1s ease
 
 		&:hover
-			color $theme-color
+			color var(--primary)
 
 		&:active
-			color darken($theme-color, 10%)
+			color var(--primaryDarken10)
 			transition color 0s ease
 
 	input[type=file]
 		display none
 
-.mk-messaging-form[data-darkmode]
-	root(true)
-
-.mk-messaging-form:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue
index 648d0eee18..77bf55c52c 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -59,10 +59,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	$me-balloon-color = $theme-color
+.message
+	$me-balloon-color = var(--primary)
 
 	padding 10px 12px 10px 12px
 	background-color transparent
@@ -179,7 +177,7 @@ root(isDark)
 			display block
 			margin 2px 0 0 0
 			font-size 10px
-			color isDark ? rgba(#fff, 0.4) : rgba(#000, 0.4)
+			color var(--messagingRoomMessageInfo)
 
 			> [data-fa]
 				margin-left 4px
@@ -192,7 +190,7 @@ root(isDark)
 			padding-left 66px
 
 			> .balloon
-				$color = isDark ? #2d3338 : #eee
+				$color = var(--messagingRoomMessageBg)
 				float left
 				background $color
 
@@ -208,8 +206,7 @@ root(isDark)
 
 				> .content
 					> .text
-						if isDark
-							color #fff
+							color var(--messagingRoomMessageFg)
 
 			> footer
 				text-align left
@@ -250,18 +247,9 @@ root(isDark)
 
 				> .read
 					user-select none
-					margin 0 4px 0 0
-					color isDark ? rgba(#fff, 0.5) : rgba(#000, 0.5)
-					font-size 11px
 
 	&[data-is-deleted]
 		> .balloon
 			opacity 0.5
 
-.message[data-darkmode]
-	root(true)
-
-.message:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue
index 30143b4f1d..488dff528f 100644
--- a/src/client/app/common/views/components/messaging-room.vue
+++ b/src/client/app/common/views/components/messaging-room.vue
@@ -3,7 +3,7 @@
 	@dragover.prevent.stop="onDragover"
 	@drop.prevent.stop="onDrop"
 >
-	<div class="stream">
+	<div class="body">
 		<p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p>
 		<p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:@empty%</p>
 		<p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:@no-history%</p>
@@ -30,7 +30,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { MessagingStream } from '../../scripts/streaming/messaging';
 import XMessage from './messaging-room.message.vue';
 import XForm from './messaging-room.form.vue';
 import { url } from '../../../config';
@@ -72,11 +71,17 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = new MessagingStream((this as any).os, this.$store.state.i, this.user.id);
+		this.connection =((this as any).os.stream.connectToChannel('messaging', { otherparty: this.user.id });
 
 		this.connection.on('message', this.onMessage);
 		this.connection.on('read', this.onRead);
 
+		if (this.isNaked) {
+			window.addEventListener('scroll', this.onScroll, { passive: true });
+		} else {
+			this.$el.addEventListener('scroll', this.onScroll, { passive: true });
+		}
+
 		document.addEventListener('visibilitychange', this.onVisibilitychange);
 
 		this.fetchMessages().then(() => {
@@ -86,9 +91,13 @@ export default Vue.extend({
 	},
 
 	beforeDestroy() {
-		this.connection.off('message', this.onMessage);
-		this.connection.off('read', this.onRead);
-		this.connection.close();
+		this.connection.dispose();
+
+		if (this.isNaked) {
+			window.removeEventListener('scroll', this.onScroll);
+		} else {
+			this.$el.removeEventListener('scroll', this.onScroll);
+		}
 
 		document.removeEventListener('visibilitychange', this.onVisibilitychange);
 	},
@@ -226,6 +235,14 @@ export default Vue.extend({
 			}, 4000);
 		},
 
+		onScroll() {
+			const el = this.isNaked ? window.document.documentElement : this.$el;
+			const current = el.scrollTop + el.clientHeight;
+			if (current > el.scrollHeight - 1) {
+				this.showIndicator = false;
+			}
+		},
+
 		onVisibilitychange() {
 			if (document.hidden) return;
 			this.messages.forEach(message => {
@@ -242,39 +259,28 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-messaging-room
 	display flex
 	flex 1
 	flex-direction column
 	height 100%
-	background isDark ? #191b22 : #fff
+	background var(--messagingRoomBg)
 
-	> .stream
+	> .body
 		width 100%
 		max-width 600px
 		margin 0 auto
 		flex 1
 
-		> .init
-			width 100%
-			margin 0
-			padding 16px 8px 8px 8px
-			text-align center
-			font-size 0.8em
-			color rgba(isDark ? #fff : #000, 0.4)
-
-			[data-fa]
-				margin-right 4px
-
+		> .init,
 		> .empty
 			width 100%
 			margin 0
 			padding 16px 8px 8px 8px
 			text-align center
 			font-size 0.8em
-			color rgba(isDark ? #fff : #000, 0.4)
+			color var(--messagingRoomInfo)
+			opacity 0.5
 
 			[data-fa]
 				margin-right 4px
@@ -285,7 +291,8 @@ root(isDark)
 			padding 16px
 			text-align center
 			font-size 0.8em
-			color rgba(isDark ? #fff : #000, 0.4)
+			color var(--messagingRoomInfo)
+			opacity 0.5
 
 			[data-fa]
 				margin-right 4px
@@ -329,7 +336,7 @@ root(isDark)
 				left 0
 				right 0
 				margin 0 auto
-				background rgba(isDark ? #fff : #000, 0.1)
+				background var(--messagingRoomDateDividerLine)
 
 			> span
 				display inline-block
@@ -337,8 +344,8 @@ root(isDark)
 				padding 0 16px
 				//font-weight bold
 				line-height 32px
-				color rgba(isDark ? #fff : #000, 0.3)
-				background isDark ? #191b22 : #fff
+				color var(--messagingRoomDateDividerText)
+				background var(--messagingRoomBg)
 
 	> footer
 		position -webkit-sticky
@@ -349,7 +356,7 @@ root(isDark)
 		max-width 600px
 		margin 0 auto
 		padding 0
-		background rgba(isDark ? #282c37 : #fff, 0.95)
+		//background rgba(var(--face), 0.95)
 		background-clip content-box
 
 		> .new-message
@@ -366,15 +373,15 @@ root(isDark)
 				cursor pointer
 				line-height 32px
 				font-size 12px
-				color $theme-color-foreground
-				background $theme-color
+				color var(--primaryForeground)
+				background var(--primary)
 				border-radius 16px
 
 				&:hover
-					background lighten($theme-color, 10%)
+					background var(--primaryLighten10)
 
 				&:active
-					background darken($theme-color, 10%)
+					background var(--primaryDarken10)
 
 				> [data-fa]
 					position absolute
@@ -390,10 +397,4 @@ root(isDark)
 	transition opacity 0.5s
 	opacity 0
 
-.mk-messaging-room[data-darkmode]
-	root(true)
-
-.mk-messaging-room:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue
index 6abfc92dca..f5b5e232f6 100644
--- a/src/client/app/common/views/components/messaging.vue
+++ b/src/client/app/common/views/components/messaging.vue
@@ -71,13 +71,11 @@ export default Vue.extend({
 			messages: [],
 			q: null,
 			result: [],
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	mounted() {
-		this.connection = (this as any).os.streams.messagingIndexStream.getConnection();
-		this.connectionId = (this as any).os.streams.messagingIndexStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('messagingIndex');
 
 		this.connection.on('message', this.onMessage);
 		this.connection.on('read', this.onRead);
@@ -88,9 +86,7 @@ export default Vue.extend({
 		});
 	},
 	beforeDestroy() {
-		this.connection.off('message', this.onMessage);
-		this.connection.off('read', this.onRead);
-		(this as any).os.streams.messagingIndexStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		getAcct,
@@ -167,9 +163,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-messaging
 
 	&[data-compact]
 		font-size 0.8em
@@ -204,12 +198,10 @@ root(isDark)
 		left 0
 		z-index 1
 		width 100%
-		background #fff
 		box-shadow 0 0px 2px rgba(#000, 0.2)
 
 		> .form
-			padding 8px
-			background isDark ? #282c37 : #f7f7f7
+			background rgba(0, 0, 0, 0.02)
 
 			> label
 				display block
@@ -229,32 +221,22 @@ root(isDark)
 					bottom 0
 					left 0
 					width 1em
-					line-height 56px
+					line-height 48px
 					margin auto
 					color #555
 
 			> input
 				margin 0
-				padding 0 0 0 32px
+				padding 0 0 0 42px
 				width 100%
 				font-size 1em
-				line-height 38px
-				color #000
+				line-height 48px
+				color var(--faceText)
 				outline none
-				background isDark ? #191b22 : #fff
-				border solid 1px isDark ? #495156 : #eee
+				background transparent
+				border none
 				border-radius 5px
 				box-shadow none
-				transition color 0.5s ease, border 0.5s ease
-
-				&:hover
-					border solid 1px isDark ? #b0b0b0 : #ddd
-					transition border 0.2s ease
-
-				&:focus
-					color darken($theme-color, 20%)
-					border solid 1px $theme-color
-					transition color 0, border 0
 
 		> .result
 			display block
@@ -287,7 +269,7 @@ root(isDark)
 					&:hover
 					&:focus
 						color #fff
-						background $theme-color
+						background var(--primary)
 
 						.name
 							color #fff
@@ -297,7 +279,7 @@ root(isDark)
 
 					&:active
 						color #fff
-						background darken($theme-color, 10%)
+						background var(--primaryDarken10)
 
 						.name
 							color #fff
@@ -329,21 +311,21 @@ root(isDark)
 		> a
 			display block
 			text-decoration none
-			background isDark ? #282c37 : #fff
-			border-bottom solid 1px isDark ? #1c2023 : #eee
+			background var(--face)
+			border-bottom solid 1px var(--faceDivider)
 
 			*
 				pointer-events none
 				user-select none
 
 			&:hover
-				background isDark ? #1e2129 : #fafafa
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
-				> .avatar
+				.avatar
 					filter saturate(200%)
 
 			&:active
-				background isDark ? #14161b : #eee
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 			&[data-is-read]
 			&[data-is-me]
@@ -383,17 +365,17 @@ root(isDark)
 						overflow hidden
 						text-overflow ellipsis
 						font-size 1em
-						color isDark ? #fff : rgba(#000, 0.9)
+						color var(--noteHeaderName)
 						font-weight bold
 						transition all 0.1s ease
 
 					> .username
 						margin 0 8px
-						color isDark ? #606984 : rgba(#000, 0.5)
+						color var(--noteHeaderAcct)
 
 					> .mk-time
 						margin 0 0 0 auto
-						color isDark ? #606984 : rgba(#000, 0.5)
+						color var(--noteHeaderInfo)
 						font-size 80%
 
 				> .avatar
@@ -413,10 +395,10 @@ root(isDark)
 						overflow hidden
 						overflow-wrap break-word
 						font-size 1.1em
-						color isDark ? #fff : rgba(#000, 0.8)
+						color var(--faceText)
 
 						.me
-							color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.4)
+							opacity 0.7
 
 					> .image
 						display block
@@ -461,10 +443,4 @@ root(isDark)
 					> .avatar
 						margin 0 12px 0 0
 
-.mk-messaging[data-darkmode]
-	root(true)
-
-.mk-messaging:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/misskey-flavored-markdown.ts
index e97da4302c..224bd6f5de 100644
--- a/src/client/app/common/views/components/misskey-flavored-markdown.ts
+++ b/src/client/app/common/views/components/misskey-flavored-markdown.ts
@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import Vue, { VNode } from 'vue';
 import * as emojilib from 'emojilib';
 import { length } from 'stringz';
 import parse from '../../../../../mfm/parse';
@@ -6,10 +6,7 @@ import getAcct from '../../../../../misc/acct/render';
 import { url } from '../../../config';
 import MkUrl from './url.vue';
 import MkGoogle from './google.vue';
-
-const flatten = list => list.reduce(
-	(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
-);
+import { concat } from '../../../../../prelude/array';
 
 export default Vue.component('misskey-flavored-markdown', {
 	props: {
@@ -32,20 +29,20 @@ export default Vue.component('misskey-flavored-markdown', {
 	},
 
 	render(createElement) {
-		let ast;
+		let ast: any[];
 
 		if (this.ast == null) {
 			// Parse text to ast
 			ast = parse(this.text);
 		} else {
-			ast = this.ast;
+			ast = this.ast as any[];
 		}
 
 		let bigCount = 0;
 		let motionCount = 0;
 
 		// Parse ast to DOM
-		const els = flatten(ast.map(token => {
+		const els = concat(ast.map((token): VNode[] => {
 			switch (token.type) {
 				case 'text': {
 					const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
@@ -56,12 +53,12 @@ export default Vue.component('misskey-flavored-markdown', {
 						x[x.length - 1].pop();
 						return x;
 					} else {
-						return createElement('span', text.replace(/\n/g, ' '));
+						return [createElement('span', text.replace(/\n/g, ' '))];
 					}
 				}
 
 				case 'bold': {
-					return createElement('b', token.bold);
+					return [createElement('b', token.bold)];
 				}
 
 				case 'big': {
@@ -95,23 +92,23 @@ export default Vue.component('misskey-flavored-markdown', {
 				}
 
 				case 'url': {
-					return createElement(MkUrl, {
+					return [createElement(MkUrl, {
 						props: {
 							url: token.content,
 							target: '_blank'
 						}
-					});
+					})];
 				}
 
 				case 'link': {
-					return createElement('a', {
+					return [createElement('a', {
 						attrs: {
 							class: 'link',
 							href: token.url,
 							target: '_blank',
 							title: token.url
 						}
-					}, token.title);
+					}, token.title)];
 				}
 
 				case 'mention': {
@@ -129,16 +126,16 @@ export default Vue.component('misskey-flavored-markdown', {
 				}
 
 				case 'hashtag': {
-					return createElement('a', {
+					return [createElement('a', {
 						attrs: {
 							href: `${url}/tags/${encodeURIComponent(token.hashtag)}`,
 							target: '_blank'
 						}
-					}, token.content);
+					}, token.content)];
 				}
 
 				case 'code': {
-					return createElement('pre', {
+					return [createElement('pre', {
 						class: 'code'
 					}, [
 						createElement('code', {
@@ -146,15 +143,15 @@ export default Vue.component('misskey-flavored-markdown', {
 								innerHTML: token.html
 							}
 						})
-					]);
+					])];
 				}
 
 				case 'inline-code': {
-					return createElement('code', {
+					return [createElement('code', {
 						domProps: {
 							innerHTML: token.html
 						}
-					});
+					})];
 				}
 
 				case 'quote': {
@@ -164,58 +161,51 @@ export default Vue.component('misskey-flavored-markdown', {
 						const x = text2.split('\n')
 							.map(t => [createElement('span', t), createElement('br')]);
 						x[x.length - 1].pop();
-						return createElement('div', {
+						return [createElement('div', {
 							attrs: {
 								class: 'quote'
 							}
-						}, x);
+						}, x)];
 					} else {
-						return createElement('span', {
+						return [createElement('span', {
 							attrs: {
 								class: 'quote'
 							}
-						}, text2.replace(/\n/g, ' '));
+						}, text2.replace(/\n/g, ' '))];
 					}
 				}
 
 				case 'title': {
-					return createElement('div', {
+					return [createElement('div', {
 						attrs: {
 							class: 'title'
 						}
-					}, token.title);
+					}, token.title)];
 				}
 
 				case 'emoji': {
 					const emoji = emojilib.lib[token.emoji];
-					return createElement('span', emoji ? emoji.char : token.content);
+					return [createElement('span', emoji ? emoji.char : token.content)];
 				}
 
 				case 'search': {
-					return createElement(MkGoogle, {
+					return [createElement(MkGoogle, {
 						props: {
 							q: token.query
 						}
-					});
+					})];
 				}
 
 				default: {
 					console.log('unknown ast type:', token.type);
+
+					return [];
 				}
 			}
 		}));
 
-		const _els = [];
-		els.forEach((el, i) => {
-			if (el.tag == 'br') {
-				if (!['div', 'pre'].includes(els[i - 1].tag)) {
-					_els.push(el);
-				}
-			} else {
-				_els.push(el);
-			}
-		});
-
+		// el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない
+		const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag)));
 		return createElement('span', _els);
 	}
 });
diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue
index 27e66358e4..d52c8e27a4 100644
--- a/src/client/app/common/views/components/nav.vue
+++ b/src/client/app/common/views/components/nav.vue
@@ -2,6 +2,8 @@
 <span class="mk-nav">
 	<a :href="aboutUrl">%i18n:@about%</a>
 	<i>・</i>
+	<a href="/stats">%i18n:@stats%</a>
+	<i>・</i>
 	<a :href="repositoryUrl">%i18n:@repository%</a>
 	<i>・</i>
 	<a :href="feedbackUrl" target="_blank">%i18n:@feedback%</a>
diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue
index d25bd430f2..8192d88412 100644
--- a/src/client/app/common/views/components/note-header.vue
+++ b/src/client/app/common/views/components/note-header.vue
@@ -42,9 +42,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.bvonvjxbwzaiskogyhbwgyxvcgserpmu
 	display flex
 	align-items baseline
 	white-space nowrap
@@ -61,7 +59,7 @@ root(isDark)
 		margin 0 .5em 0 0
 		padding 0
 		overflow hidden
-		color isDark ? #fff : #627079
+		color var(--noteHeaderName)
 		font-size 1em
 		font-weight bold
 		text-decoration none
@@ -82,19 +80,19 @@ root(isDark)
 		margin 0 .5em 0 0
 		padding 1px 6px
 		font-size 80%
-		color isDark ? #758188 : #aaa
-		border solid 1px isDark ? #57616f : #ddd
+		color var(--noteHeaderBadgeFg)
+		background var(--noteHeaderBadgeBg)
 		border-radius 3px
 
 		&.is-admin
-			border-color isDark ? #d42c41 : #f56a7b
-			color isDark ? #d42c41 : #f56a7b
+			background var(--noteHeaderAdminBg)
+			color var(--noteHeaderAdminFg)
 
 	> .username
 		margin 0 .5em 0 0
 		overflow hidden
 		text-overflow ellipsis
-		color isDark ? #606984 : #ccc
+		color var(--noteHeaderAcct)
 		flex-shrink 2147483647
 
 	> .info
@@ -102,7 +100,7 @@ root(isDark)
 		font-size 0.9em
 
 		> *
-			color isDark ? #606984 : #c0c0c0
+			color var(--noteHeaderInfo)
 
 		> .mobile
 			margin-right 8px
@@ -110,15 +108,9 @@ root(isDark)
 		> .app
 			margin-right 8px
 			padding-right 8px
-			border-right solid 1px isDark ? #1c2023 : #eaeaea
+			border-right solid 1px var(--faceDivider)
 
 		> .visibility
 			margin-left 8px
 
-.bvonvjxbwzaiskogyhbwgyxvcgserpmu[data-darkmode]
-	root(true)
-
-.bvonvjxbwzaiskogyhbwgyxvcgserpmu:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue
index 27a49a6536..c8ed1225cc 100644
--- a/src/client/app/common/views/components/note-menu.vue
+++ b/src/client/app/common/views/components/note-menu.vue
@@ -6,29 +6,51 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { url } from '../../../config';
+import copyToClipboard from '../../../common/scripts/copy-to-clipboard';
 
 export default Vue.extend({
 	props: ['note', 'source', 'compact'],
 	computed: {
 		items() {
-			const items = [];
-			items.push({
+			const items = [{
+				icon: '%fa:info-circle%',
+				text: '%i18n:@detail%',
+				action: this.detail
+			}, {
+				icon: '%fa:link%',
+				text: '%i18n:@copy-link%',
+				action: this.copyLink
+			}, null, {
 				icon: '%fa:star%',
 				text: '%i18n:@favorite%',
 				action: this.favorite
-			});
+			}];
+
 			if (this.note.userId == this.$store.state.i.id) {
-				items.push({
-					icon: '%fa:thumbtack%',
-					text: '%i18n:@pin%',
-					action: this.pin
-				});
+				if ((this.$store.state.i.pinnedNoteIds || []).includes(this.note.id)) {
+					items.push({
+						icon: '%fa:thumbtack%',
+						text: '%i18n:@unpin%',
+						action: this.unpin
+					});
+				} else {
+					items.push({
+						icon: '%fa:thumbtack%',
+						text: '%i18n:@pin%',
+						action: this.pin
+					});
+				}
+			}
+
+			if (this.note.userId == this.$store.state.i.id || this.$store.state.i.isAdmin) {
 				items.push({
 					icon: '%fa:trash-alt R%',
 					text: '%i18n:@delete%',
 					action: this.del
 				});
 			}
+
 			if (this.note.uri) {
 				items.push({
 					icon: '%fa:external-link-square-alt%',
@@ -38,15 +60,33 @@ export default Vue.extend({
 					}
 				});
 			}
+
 			return items;
 		}
 	},
+
 	methods: {
+		detail() {
+			this.$router.push(`/notes/${ this.note.id }`);
+		},
+
+		copyLink() {
+			copyToClipboard(`${url}/notes/${ this.note.id }`);
+		},
+
 		pin() {
 			(this as any).api('i/pin', {
 				noteId: this.note.id
 			}).then(() => {
-				this.$destroy();
+				this.destroyDom();
+			});
+		},
+
+		unpin() {
+			(this as any).api('i/unpin', {
+				noteId: this.note.id
+			}).then(() => {
+				this.destroyDom();
 			});
 		},
 
@@ -55,7 +95,7 @@ export default Vue.extend({
 			(this as any).api('notes/delete', {
 				noteId: this.note.id
 			}).then(() => {
-				this.$destroy();
+				this.destroyDom();
 			});
 		},
 
@@ -63,13 +103,13 @@ export default Vue.extend({
 			(this as any).api('notes/favorites/create', {
 				noteId: this.note.id
 			}).then(() => {
-				this.$destroy();
+				this.destroyDom();
 			});
 		},
 
 		closed() {
 			this.$nextTick(() => {
-				this.$destroy();
+				this.destroyDom();
 			});
 		}
 	}
diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue
index 115c934c8b..b5c57d48a5 100644
--- a/src/client/app/common/views/components/poll-editor.vue
+++ b/src/client/app/common/views/components/poll-editor.vue
@@ -20,6 +20,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { erase } from '../../../../../prelude/array';
 export default Vue.extend({
 	data() {
 		return {
@@ -53,7 +54,7 @@ export default Vue.extend({
 
 		get() {
 			return {
-				choices: this.choices.filter(choice => choice != '')
+				choices: erase('', this.choices)
 			}
 		},
 
@@ -67,9 +68,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-poll-editor
 	padding 8px
 
 	> .caution
@@ -102,49 +101,43 @@ root(isDark)
 				padding 6px 8px
 				width 300px
 				font-size 14px
-				color isDark ? #fff : #000
-				background isDark ? #191b22 : #fff
-				border solid 1px rgba($theme-color, 0.1)
+				color var(--inputText)
+				background var(--pollEditorInputBg)
+				border solid 1px var(--primaryAlpha01)
 				border-radius 4px
 
 				&:hover
-					border-color rgba($theme-color, 0.2)
+					border-color var(--primaryAlpha02)
 
 				&:focus
-					border-color rgba($theme-color, 0.5)
+					border-color var(--primaryAlpha05)
 
 			> button
 				padding 4px 8px
-				color rgba($theme-color, 0.4)
+				color var(--primaryAlpha04)
 
 				&:hover
-					color rgba($theme-color, 0.6)
+					color var(--primaryAlpha06)
 
 				&:active
-					color darken($theme-color, 30%)
+					color var(--primaryDarken30)
 
 	> .add
 		margin 8px 0 0 0
 		vertical-align top
-		color $theme-color
+		color var(--primary)
 
 	> .destroy
 		position absolute
 		top 0
 		right 0
 		padding 4px 8px
-		color rgba($theme-color, 0.4)
+		color var(--primaryAlpha04)
 
 		&:hover
-			color rgba($theme-color, 0.6)
+			color var(--primaryAlpha06)
 
 		&:active
-			color darken($theme-color, 30%)
-
-.mk-poll-editor[data-darkmode]
-	root(true)
-
-.mk-poll-editor:not([data-darkmode])
-	root(false)
+			color var(--primaryDarken30)
 
 </style>
diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue
index 660247edbc..0dc2622f9b 100644
--- a/src/client/app/common/views/components/poll.vue
+++ b/src/client/app/common/views/components/poll.vue
@@ -21,6 +21,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { sum } from '../../../../../prelude/array';
 export default Vue.extend({
 	props: ['note'],
 	data() {
@@ -33,7 +34,7 @@ export default Vue.extend({
 			return this.note.poll;
 		},
 		total(): number {
-			return this.poll.choices.reduce((a, b) => a + b.votes, 0);
+			return sum(this.poll.choices.map(x => x.votes));
 		},
 		isVoted(): boolean {
 			return this.poll.choices.some(c => c.isVoted);
@@ -66,10 +67,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-
+.mk-poll
 	> ul
 		display block
 		margin 0
@@ -81,8 +79,8 @@ root(isDark)
 			margin 4px 0
 			padding 4px 8px
 			width 100%
-			color isDark ? #fff : #000
-			border solid 1px isDark ? #5e636f : #eee
+			color var(--pollChoiceText)
+			border solid 1px var(--pollChoiceBorder)
 			border-radius 4px
 			overflow hidden
 			cursor pointer
@@ -98,7 +96,7 @@ root(isDark)
 				top 0
 				left 0
 				height 100%
-				background $theme-color
+				background var(--primary)
 				transition width 1s ease
 
 			> span
@@ -109,7 +107,7 @@ root(isDark)
 					margin-left 4px
 
 	> p
-		color isDark ? #a3aebf : #000
+		color var(--text)
 
 		a
 			color inherit
@@ -124,10 +122,4 @@ root(isDark)
 			&:active
 				background transparent
 
-.mk-poll[data-darkmode]
-	root(true)
-
-.mk-poll:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue
index 46886b8ab2..c668efac6b 100644
--- a/src/client/app/common/views/components/reaction-icon.vue
+++ b/src/client/app/common/views/components/reaction-icon.vue
@@ -1,17 +1,17 @@
 <template>
 <span class="mk-reaction-icon">
-	<img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%">
-	<img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%">
-	<img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%">
-	<img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%">
-	<img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%">
-	<img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%">
-	<img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%">
-	<img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%">
-	<img v-if="reaction == 'rip'" src="/assets/reactions/rip.png" alt="%i18n:common.reactions.rip%">
+	<img v-if="reaction == 'like'" src="https://twemoji.maxcdn.com/2/svg/1f44d.svg" alt="%i18n:common.reactions.like%">
+	<img v-if="reaction == 'love'" src="https://twemoji.maxcdn.com/2/svg/2764.svg" alt="%i18n:common.reactions.love%">
+	<img v-if="reaction == 'laugh'" src="https://twemoji.maxcdn.com/2/svg/1f606.svg" alt="%i18n:common.reactions.laugh%">
+	<img v-if="reaction == 'hmm'" src="https://twemoji.maxcdn.com/2/svg/1f914.svg" alt="%i18n:common.reactions.hmm%">
+	<img v-if="reaction == 'surprise'" src="https://twemoji.maxcdn.com/2/svg/1f62e.svg" alt="%i18n:common.reactions.surprise%">
+	<img v-if="reaction == 'congrats'" src="https://twemoji.maxcdn.com/2/svg/1f389.svg" alt="%i18n:common.reactions.congrats%">
+	<img v-if="reaction == 'angry'" src="https://twemoji.maxcdn.com/2/svg/1f4a2.svg" alt="%i18n:common.reactions.angry%">
+	<img v-if="reaction == 'confused'" src="https://twemoji.maxcdn.com/2/svg/1f625.svg" alt="%i18n:common.reactions.confused%">
+	<img v-if="reaction == 'rip'" src="https://twemoji.maxcdn.com/2/svg/1f607.svg" alt="%i18n:common.reactions.rip%">
 	<template v-if="reaction == 'pudding'">
-		<img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="/assets/reactions/sushi.png" alt="%i18n:common.reactions.pudding%">
-		<img v-else src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%">
+		<img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="https://twemoji.maxcdn.com/2/svg/1f363.svg" alt="%i18n:common.reactions.pudding%">
+		<img v-else src="https://twemoji.maxcdn.com/2/svg/1f36e.svg" alt="%i18n:common.reactions.pudding%">
 	</template>
 </span>
 </template>
diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue
index a455afbf7d..13e8cf1f07 100644
--- a/src/client/app/common/views/components/reaction-picker.vue
+++ b/src/client/app/common/views/components/reaction-picker.vue
@@ -1,9 +1,9 @@
 <template>
-<div class="mk-reaction-picker">
+<div class="mk-reaction-picker" v-hotkey.global="keymap">
 	<div class="backdrop" ref="backdrop" @click="close"></div>
 	<div class="popover" :class="{ compact, big }" ref="popover">
 		<p v-if="!compact">{{ title }}</p>
-		<div>
+		<div ref="buttons" :class="{ showFocus }">
 			<button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button>
 			<button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button>
 			<button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button>
@@ -31,30 +31,84 @@ export default Vue.extend({
 			type: Object,
 			required: true
 		},
+
 		source: {
 			required: true
 		},
+
 		compact: {
 			type: Boolean,
 			required: false,
 			default: false
 		},
+
 		cb: {
 			required: false
 		},
+
 		big: {
 			type: Boolean,
 			required: false,
 			default: false
+		},
+
+		showFocus: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+
+		animation: {
+			type: Boolean,
+			required: false,
+			default: true
 		}
 	},
+
 	data() {
 		return {
-			title: placeholder
+			title: placeholder,
+			focus: null
 		};
 	},
+
+	computed: {
+		keymap(): any {
+			return {
+				'esc': this.close,
+				'enter|space|plus': this.choose,
+				'up|k': this.focusUp,
+				'left|h|shift+tab': this.focusLeft,
+				'right|l|tab': this.focusRight,
+				'down|j': this.focusDown,
+				'1': () => this.react('like'),
+				'2': () => this.react('love'),
+				'3': () => this.react('laugh'),
+				'4': () => this.react('hmm'),
+				'5': () => this.react('surprise'),
+				'6': () => this.react('congrats'),
+				'7': () => this.react('angry'),
+				'8': () => this.react('confused'),
+				'9': () => this.react('rip'),
+				'0': () => this.react('pudding'),
+			};
+		}
+	},
+
+	watch: {
+		focus(i) {
+			this.$refs.buttons.children[i].focus();
+
+			if (this.showFocus) {
+				this.title = this.$refs.buttons.children[i].title;
+			}
+		}
+	},
+
 	mounted() {
 		this.$nextTick(() => {
+			this.focus = 0;
+
 			const popover = this.$refs.popover as any;
 
 			const rect = this.source.getBoundingClientRect();
@@ -76,7 +130,7 @@ export default Vue.extend({
 			anime({
 				targets: this.$refs.backdrop,
 				opacity: 1,
-				duration: 100,
+				duration: this.animation ? 100 : 0,
 				easing: 'linear'
 			});
 
@@ -84,10 +138,11 @@ export default Vue.extend({
 				targets: this.$refs.popover,
 				opacity: 1,
 				scale: [0.5, 1],
-				duration: 500
+				duration: this.animation ? 500 : 0
 			});
 		});
 	},
+
 	methods: {
 		react(reaction) {
 			(this as any).api('notes/reactions/create', {
@@ -95,21 +150,25 @@ export default Vue.extend({
 				reaction: reaction
 			}).then(() => {
 				if (this.cb) this.cb();
-				this.$destroy();
+				this.$emit('closed');
+				this.destroyDom();
 			});
 		},
+
 		onMouseover(e) {
 			this.title = e.target.title;
 		},
+
 		onMouseout(e) {
 			this.title = placeholder;
 		},
+
 		close() {
 			(this.$refs.backdrop as any).style.pointerEvents = 'none';
 			anime({
 				targets: this.$refs.backdrop,
 				opacity: 0,
-				duration: 200,
+				duration: this.animation ? 200 : 0,
 				easing: 'linear'
 			});
 
@@ -118,21 +177,42 @@ export default Vue.extend({
 				targets: this.$refs.popover,
 				opacity: 0,
 				scale: 0.5,
-				duration: 200,
+				duration: this.animation ? 200 : 0,
 				easing: 'easeInBack',
-				complete: () => this.$destroy()
+				complete: () => {
+					this.$emit('closed');
+					this.destroyDom();
+				}
 			});
+		},
+
+		focusUp() {
+			this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5);
+		},
+
+		focusDown() {
+			this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5);
+		},
+
+		focusRight() {
+			this.focus = this.focus == 9 ? 0 : (this.focus + 1);
+		},
+
+		focusLeft() {
+			this.focus = this.focus == 0 ? 9 : (this.focus - 1);
+		},
+
+		choose() {
+			this.$refs.buttons.childNodes[this.focus].click();
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
 $border-color = rgba(27, 31, 35, 0.15)
 
-root(isDark)
+.mk-reaction-picker
 	position initial
 
 	> .backdrop
@@ -142,11 +222,11 @@ root(isDark)
 		z-index 10000
 		width 100%
 		height 100%
-		background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1)
+		background var(--modalBackdrop)
 		opacity 0
 
 	> .popover
-		$bgcolor = isDark ? #2c303c : #fff
+		$bgcolor = var(--popupBg)
 		position absolute
 		z-index 10001
 		background $bgcolor
@@ -199,14 +279,29 @@ root(isDark)
 			margin 0
 			padding 8px 10px
 			font-size 14px
-			color isDark ? #d6dce2 : #586069
-			border-bottom solid 1px isDark ? #1c2023 : #e1e4e8
+			color var(--popupFg)
+			border-bottom solid 1px var(--faceDivider)
 
 		> div
 			padding 4px
 			width 240px
 			text-align center
 
+			&.showFocus
+				> button:focus
+					z-index 1
+
+					&:after
+						content ""
+						pointer-events none
+						position absolute
+						top 0
+						right 0
+						bottom 0
+						left 0
+						border 2px solid var(--primaryAlpha03)
+						border-radius 4px
+
 			> button
 				padding 0
 				width 40px
@@ -215,16 +310,10 @@ root(isDark)
 				border-radius 2px
 
 				&:hover
-					background isDark ? #252731 : #eee
+					background var(--reactionPickerButtonHoverBg)
 
 				&:active
-					background $theme-color
+					background var(--primary)
 					box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
 
-.mk-reaction-picker[data-darkmode]
-	root(true)
-
-.mk-reaction-picker:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue
index c30fa2a1dc..9212a84b31 100644
--- a/src/client/app/common/views/components/reactions-viewer.vue
+++ b/src/client/app/common/views/components/reactions-viewer.vue
@@ -39,10 +39,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	$borderColor = isDark ? #5e6673 : #eee
-	border-top dashed 1px $borderColor
-	border-bottom dashed 1px $borderColor
+.mk-reactions-viewer
+	border-top dashed 1px var(--reactionViewerBorder)
+	border-bottom dashed 1px var(--reactionViewerBorder)
 	margin 4px 0
 
 	&:empty
@@ -60,12 +59,6 @@ root(isDark)
 		> span
 			margin-left 4px
 			font-size 1.2em
-			color isDark ? #d1d5dc : #444
-
-.mk-reactions-viewer[data-darkmode]
-	root(true)
-
-.mk-reactions-viewer:not([data-darkmode])
-	root(false)
+			color var(--text)
 
 </style>
diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue
index 5230ac371a..9224f82cb9 100644
--- a/src/client/app/common/views/components/signin.vue
+++ b/src/client/app/common/views/components/signin.vue
@@ -1,16 +1,16 @@
 <template>
 <form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit">
 	<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
-	<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
+	<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange" styl="fill">
 		<span>%i18n:@username%</span>
 		<span slot="prefix">@</span>
 		<span slot="suffix">@{{ host }}</span>
 	</ui-input>
-	<ui-input v-model="password" type="password" required>
+	<ui-input v-model="password" type="password" required styl="fill">
 		<span>%i18n:@password%</span>
 		<span slot="prefix">%fa:lock%</span>
 	</ui-input>
-	<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required/>
+	<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required styl="fill"/>
 	<ui-button type="submit" :disabled="signing">{{ signing ? '%i18n:@signing-in%' : '%i18n:@signin%' }}</ui-button>
 	<p style="margin: 8px 0;">%i18n:@or% <a :href="`${apiUrl}/signin/twitter`">%i18n:@signin-with-twitter%</a></p>
 </form>
@@ -56,7 +56,7 @@ export default Vue.extend({
 				username: this.username,
 				password: this.password,
 				token: this.user && this.user.twoFactorEnabled ? this.token : undefined
-			}).then(() => {
+			}, true).then(() => {
 				location.reload();
 			}).catch(() => {
 				alert('%i18n:@login-failed%');
@@ -68,7 +68,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-signin
 	color #555
@@ -78,7 +78,7 @@ export default Vue.extend({
 			cursor wait !important
 
 	> .avatar
-		margin 16px auto 0 auto
+		margin 0 auto 0 auto
 		width 64px
 		height 64px
 		background #ddd
diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue
index f603b9545c..8e06b13491 100644
--- a/src/client/app/common/views/components/signup.vue
+++ b/src/client/app/common/views/components/signup.vue
@@ -1,12 +1,12 @@
 <template>
 <form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()">
 	<template v-if="meta">
-		<ui-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required>
+		<ui-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required styl="fill">
 			<span>%i18n:@invitation-code%</span>
 			<span slot="prefix">%fa:id-card-alt%</span>
 			<p slot="text" v-html="'%i18n:@invitation-info%'.replace('{}', meta.maintainer.url)"></p>
 		</ui-input>
-		<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername">
+		<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername" styl="fill">
 			<span>%i18n:@username%</span>
 			<span slot="prefix">@</span>
 			<span slot="suffix">@{{ host }}</span>
@@ -18,7 +18,7 @@
 			<p slot="text" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw% %i18n:@too-short%</p>
 			<p slot="text" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw% %i18n:@too-long%</p>
 		</ui-input>
-		<ui-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword" :with-password-meter="true">
+		<ui-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword" :with-password-meter="true" styl="fill">
 			<span>%i18n:@password%</span>
 			<span slot="prefix">%fa:lock%</span>
 			<div slot="text">
@@ -27,7 +27,7 @@
 				<p slot="text" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw% %i18n:@strong-password%</p>
 			</div>
 		</ui-input>
-		<ui-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype">
+		<ui-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype" styl="fill">
 			<span>%i18n:@password% (%i18n:@retype%)</span>
 			<span slot="prefix">%fa:lock%</span>
 			<div slot="text">
@@ -131,11 +131,11 @@ export default Vue.extend({
 				password: this.password,
 				invitationCode: this.invitationCode,
 				'g-recaptcha-response': this.meta.recaptchaSitekey != null ? (window as any).grecaptcha.getResponse() : null
-			}).then(() => {
+			}, true).then(() => {
 				(this as any).api('signin', {
 					username: this.username,
 					password: this.password
-				}).then(() => {
+				}, true).then(() => {
 					location.href = '/';
 				});
 			}).catch(() => {
@@ -151,7 +151,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-signup
 	min-width 302px
diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue
index d573db32e6..12bf78f130 100644
--- a/src/client/app/common/views/components/stream-indicator.vue
+++ b/src/client/app/common/views/components/stream-indicator.vue
@@ -1,14 +1,14 @@
 <template>
 <div class="mk-stream-indicator">
-	<p v-if=" stream.state == 'initializing' ">
+	<p v-if="stream.state == 'initializing'">
 		%fa:spinner .pulse%
 		<span>%i18n:@connecting%<mk-ellipsis/></span>
 	</p>
-	<p v-if=" stream.state == 'reconnecting' ">
+	<p v-if="stream.state == 'reconnecting'">
 		%fa:spinner .pulse%
 		<span>%i18n:@reconnecting%<mk-ellipsis/></span>
 	</p>
-	<p v-if=" stream.state == 'connected' ">
+	<p v-if="stream.state == 'connected'">
 		%fa:check%
 		<span>%i18n:@connected%</span>
 	</p>
diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue
deleted file mode 100644
index 32caab638a..0000000000
--- a/src/client/app/common/views/components/switch.vue
+++ /dev/null
@@ -1,199 +0,0 @@
-<template>
-<div
-	class="mk-switch"
-	:class="{ disabled, checked }"
-	role="switch"
-	:aria-checked="checked"
-	:aria-disabled="disabled"
-	@click="switchValue"
-	@mouseover="mouseenter"
->
-	<input
-		type="checkbox"
-		@change="handleChange"
-		ref="input"
-		:disabled="disabled"
-		@keydown.enter="switchValue"
-	>
-	<span class="button">
-		<span :style="{ transform }"></span>
-	</span>
-	<span class="label">
-		<span :aria-hidden="!checked">{{ text }}</span>
-		<p :aria-hidden="!checked">
-			<slot></slot>
-		</p>
-	</span>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({
-	props: {
-		value: {
-			type: Boolean,
-			default: false
-		},
-		disabled: {
-			type: Boolean,
-			default: false
-		},
-		text: String
-	},/*
-	created() {
-		if (!~[true, false].indexOf(this.value)) {
-			this.$emit('input', false);
-		}
-	},*/
-	computed: {
-		checked(): boolean {
-			return this.value;
-		},
-		transform(): string {
-			return this.checked ? 'translate3d(20px, 0, 0)' : '';
-		}
-	},
-	watch: {
-		value() {
-			(this.$el).style.transition = 'all 0.3s';
-			(this.$refs.input as any).checked = this.checked;
-		}
-	},
-	mounted() {
-		(this.$refs.input as any).checked = this.checked;
-	},
-	methods: {
-		mouseenter() {
-			(this.$el).style.transition = 'all 0s';
-		},
-		handleChange() {
-			(this.$el).style.transition = 'all 0.3s';
-			this.$emit('input', !this.checked);
-			this.$emit('change', !this.checked);
-			this.$nextTick(() => {
-				// set input's checked property
-				// in case parent refuses to change component's value
-				(this.$refs.input as any).checked = this.checked;
-			});
-		},
-		switchValue() {
-			!this.disabled && this.handleChange();
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	display flex
-	margin 12px 0
-	cursor pointer
-	transition all 0.3s
-
-	> *
-		user-select none
-
-	&.disabled
-		opacity 0.6
-		cursor not-allowed
-
-	&.checked
-		> .button
-			background-color $theme-color
-			border-color $theme-color
-
-		> .label
-			> span
-				color $theme-color
-
-		&:hover
-			> .label
-				> span
-					color darken($theme-color, 10%)
-
-			> .button
-				background darken($theme-color, 10%)
-				border-color darken($theme-color, 10%)
-
-	&:hover
-		> .label
-			> span
-				color isDark ? #fff : #2e3338
-
-		> .button
-			$color = isDark ? #15181d : #ced2da
-			background $color
-			border-color $color
-
-	> input
-		position absolute
-		width 0
-		height 0
-		opacity 0
-		margin 0
-
-		&:focus + .button
-			&:after
-				content ""
-				pointer-events none
-				position absolute
-				top -5px
-				right -5px
-				bottom -5px
-				left -5px
-				border 2px solid rgba($theme-color, 0.3)
-				border-radius 14px
-
-	> .button
-		$color = isDark ? #1c1f25 : #dcdfe6
-
-		display inline-block
-		margin 0
-		width 40px
-		min-width 40px
-		height 20px
-		min-height 20px
-		background $color
-		border 1px solid $color
-		outline none
-		border-radius 10px
-		transition inherit
-
-		> *
-			position absolute
-			top 1px
-			left 1px
-			border-radius 100%
-			transition transform 0.3s
-			width 16px
-			height 16px
-			background-color #fff
-
-	> .label
-		margin-left 8px
-		display block
-		font-size 15px
-		cursor pointer
-		transition inherit
-
-		> span
-			display block
-			line-height 20px
-			color isDark ? #c4ccd2 : #4a535a
-			transition inherit
-
-		> p
-			margin 0
-			//font-size 90%
-			color isDark ? #78858e : #9daab3
-
-.mk-switch[data-darkmode]
-	root(true)
-
-.mk-switch:not([data-darkmode])
-	root(false)
-
-</style>
diff --git a/src/client/app/common/views/components/tag-cloud.vue b/src/client/app/common/views/components/tag-cloud.vue
new file mode 100644
index 0000000000..5cc828082f
--- /dev/null
+++ b/src/client/app/common/views/components/tag-cloud.vue
@@ -0,0 +1,84 @@
+<template>
+<div class="jtivnzhfwquxpsfidertopbmwmchmnmo">
+	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+	<p class="empty" v-else-if="tags.length == 0">%fa:exclamation-circle%%i18n:@empty%</p>
+	<div v-else>
+		<vue-word-cloud
+				:words="tags.slice(0, 20).map(x => [x.name, x.count])"
+				:color="color"
+				:spacing="1">
+			<template slot-scope="{word, text, weight}">
+				<div style="cursor: pointer;" :title="weight">
+					{{ text }}
+				</div>
+			</template>
+		</vue-word-cloud>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as VueWordCloud from 'vuewordcloud';
+
+export default Vue.extend({
+	components: {
+		[VueWordCloud.name]: VueWordCloud
+	},
+	data() {
+		return {
+			tags: [],
+			fetching: true,
+			clock: null
+		};
+	},
+	mounted() {
+		this.fetch();
+		this.clock = setInterval(this.fetch, 1000 * 60);
+	},
+	beforeDestroy() {
+		clearInterval(this.clock);
+	},
+	methods: {
+		fetch() {
+			(this as any).api('aggregation/hashtags').then(tags => {
+				this.tags = tags;
+				this.fetching = false;
+			});
+		},
+		color([, weight]) {
+			const peak = Math.max.apply(null, this.tags.map(x => x.count));
+			const w = weight / peak;
+
+			if (w > 0.9) {
+				return this.$store.state.device.darkmode ? '#ff4e69' : '#ff4e69';
+			} else if (w > 0.5) {
+				return this.$store.state.device.darkmode ? '#3bc4c7' : '#3bc4c7';
+			} else {
+				return this.$store.state.device.darkmode ? '#fff' : '#555';
+			}
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.jtivnzhfwquxpsfidertopbmwmchmnmo
+	height 100%
+	width 100%
+
+	> .fetching
+	> .empty
+		margin 0
+		padding 16px
+		text-align center
+		color #aaa
+
+		> [data-fa]
+			margin-right 4px
+
+	> div
+		height 100%
+		width 100%
+
+</style>
diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue
new file mode 100644
index 0000000000..9eda3c5796
--- /dev/null
+++ b/src/client/app/common/views/components/theme.vue
@@ -0,0 +1,308 @@
+<template>
+<div class="nicnklzforebnpfgasiypmpdaaglujqm">
+	<label>
+		<span>%i18n:@light-theme%</span>
+		<ui-select v-model="light" placeholder="%i18n:@light-theme%">
+			<optgroup label="%i18n:@light-themes%">
+				<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</optgroup>
+			<optgroup label="%i18n:@dark-themes%">
+				<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</optgroup>
+		</ui-select>
+	</label>
+
+	<label>
+		<span>%i18n:@dark-theme%</span>
+		<ui-select v-model="dark" placeholder="%i18n:@dark-theme%">
+			<optgroup label="%i18n:@dark-themes%">
+				<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</optgroup>
+			<optgroup label="%i18n:@light-themes%">
+				<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</optgroup>
+		</ui-select>
+	</label>
+
+	<details class="creator">
+		<summary>%fa:palette% %i18n:@create-a-theme%</summary>
+		<div>
+			<span>%i18n:@base-theme%:</span>
+			<ui-radio v-model="myThemeBase" value="light">%i18n:@base-theme-light%</ui-radio>
+			<ui-radio v-model="myThemeBase" value="dark">%i18n:@base-theme-dark%</ui-radio>
+		</div>
+		<div>
+			<ui-input v-model="myThemeName">
+				<span>%i18n:@theme-name%</span>
+			</ui-input>
+			<ui-textarea v-model="myThemeDesc">
+				<span>%i18n:@desc%</span>
+			</ui-textarea>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@primary-color%:</div>
+			<color-picker v-model="myThemePrimary"/>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@secondary-color%:</div>
+			<color-picker v-model="myThemeSecondary"/>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@text-color%:</div>
+			<color-picker v-model="myThemeText"/>
+		</div>
+		<ui-button @click="preview()">%fa:eye% %i18n:@preview-created-theme%</ui-button>
+		<ui-button primary @click="gen()">%fa:save R% %i18n:@save-created-theme%</ui-button>
+	</details>
+
+	<details>
+		<summary>%fa:download% %i18n:@install-a-theme%</summary>
+		<ui-button @click="import_()">%fa:file-import% %i18n:@import%</ui-button>
+		<input ref="file" type="file" accept=".misskeytheme" style="display:none;" @change="onUpdateImportFile"/>
+		<p>%i18n:@import-by-code%:</p>
+		<ui-textarea v-model="installThemeCode">
+			<span>%i18n:@theme-code%</span>
+		</ui-textarea>
+		<ui-button @click="() => install(this.installThemeCode)">%fa:check% %i18n:@install%</ui-button>
+	</details>
+
+	<details>
+		<summary>%fa:folder-open% %i18n:@installed-themes%</summary>
+		<ui-select v-model="selectedInstalledThemeId" placeholder="%i18n:@select-theme%">
+			<option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</ui-select>
+		<template v-if="selectedInstalledTheme">
+			<ui-input readonly :value="selectedInstalledTheme.author">
+				<span>%i18n:@author%</span>
+			</ui-input>
+			<ui-textarea v-if="selectedInstalledTheme.desc" readonly :value="selectedInstalledTheme.desc">
+				<span>%i18n:@desc%</span>
+			</ui-textarea>
+			<ui-textarea readonly :value="selectedInstalledThemeCode">
+				<span>%i18n:@theme-code%</span>
+			</ui-textarea>
+			<ui-button @click="export_()" link :download="`${selectedInstalledTheme.name}.misskeytheme`" ref="export">%fa:box% %i18n:@export%</ui-button>
+			<ui-button @click="uninstall()">%fa:trash-alt R% %i18n:@uninstall%</ui-button>
+		</template>
+	</details>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { lightTheme, darkTheme, builtinThemes, applyTheme, Theme } from '../../../theme';
+import { Chrome } from 'vue-color';
+import * as uuid from 'uuid';
+import * as tinycolor from 'tinycolor2';
+import * as JSON5 from 'json5';
+
+// 後方互換性のため
+function convertOldThemedefinition(t) {
+	const t2 = {
+		id: t.meta.id,
+		name: t.meta.name,
+		author: t.meta.author,
+		base: t.meta.base,
+		vars: t.meta.vars,
+		props: t
+	};
+	delete t2.props.meta;
+	return t2;
+}
+
+export default Vue.extend({
+	components: {
+		ColorPicker: Chrome
+	},
+
+	data() {
+		return {
+			installThemeCode: null,
+			selectedInstalledThemeId: null,
+			myThemeBase: 'light',
+			myThemeName: '',
+			myThemeDesc: '',
+			myThemePrimary: lightTheme.vars.primary,
+			myThemeSecondary: lightTheme.vars.secondary,
+			myThemeText: lightTheme.vars.text
+		};
+	},
+
+	computed: {
+		themes(): Theme[] {
+			return builtinThemes.concat(this.$store.state.device.themes);
+		},
+
+		darkThemes(): Theme[] {
+			return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark');
+		},
+
+		lightThemes(): Theme[] {
+			return this.themes.filter(t => t.base == 'light' || t.kind == 'light');
+		},
+
+		installedThemes(): Theme[] {
+			return this.$store.state.device.themes;
+		},
+
+		light: {
+			get() { return this.$store.state.device.lightTheme; },
+			set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); }
+		},
+
+		dark: {
+			get() { return this.$store.state.device.darkTheme; },
+			set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); }
+		},
+
+		selectedInstalledTheme() {
+			if (this.selectedInstalledThemeId == null) return null;
+			return this.installedThemes.find(x => x.id == this.selectedInstalledThemeId);
+		},
+
+		selectedInstalledThemeCode() {
+			if (this.selectedInstalledTheme == null) return null;
+			return JSON5.stringify(this.selectedInstalledTheme, null, '\t');
+		},
+
+		myTheme(): any {
+			return {
+				name: this.myThemeName,
+				author: this.$store.state.i.username,
+				desc: this.myThemeDesc,
+				base: this.myThemeBase,
+				vars: {
+					primary: tinycolor(typeof this.myThemePrimary == 'string' ? this.myThemePrimary : this.myThemePrimary.rgba).toRgbString(),
+					secondary: tinycolor(typeof this.myThemeSecondary == 'string' ? this.myThemeSecondary : this.myThemeSecondary.rgba).toRgbString(),
+					text: tinycolor(typeof this.myThemeText == 'string' ? this.myThemeText : this.myThemeText.rgba).toRgbString()
+				}
+			};
+		}
+	},
+
+	watch: {
+		myThemeBase(v) {
+			const theme = v == 'light' ? lightTheme : darkTheme;
+			this.myThemePrimary = theme.vars.primary;
+			this.myThemeSecondary = theme.vars.secondary;
+			this.myThemeText = theme.vars.text;
+		}
+	},
+
+	beforeCreate() {
+		// migrate old theme definitions
+		// 後方互換性のため
+		this.$store.commit('device/set', {
+			key: 'themes', value: this.$store.state.device.themes.map(t => {
+				if (t.id == null) {
+					return convertOldThemedefinition(t);
+				} else {
+					return t;
+				}
+			})
+		});
+	},
+
+	methods: {
+		install(code) {
+			let theme;
+
+			try {
+				theme = JSON5.parse(code);
+			} catch (e) {
+				alert('%i18n:@invalid-theme%');
+				return;
+			}
+
+			// 後方互換性のため
+			if (theme.id == null && theme.meta != null) {
+				theme = convertOldThemedefinition(theme);
+			}
+
+			if (theme.id == null) {
+				alert('%i18n:@invalid-theme%');
+				return;
+			}
+
+			if (this.$store.state.device.themes.some(t => t.id == theme.id)) {
+				alert('%i18n:@already-installed%');
+				return;
+			}
+
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+
+			alert('%i18n:@installed%'.replace('{}', theme.name));
+		},
+
+		uninstall() {
+			const theme = this.selectedInstalledTheme;
+			const themes = this.$store.state.device.themes.filter(t => t.id != theme.id);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			alert('%i18n:@uninstalled%'.replace('{}', theme.name));
+		},
+
+		import_() {
+			(this.$refs.file as any).click();
+		}
+
+		export_() {
+			const blob = new Blob([this.selectedInstalledThemeCode], {
+				type: 'application/json5'
+			});
+			this.$refs.export.$el.href = window.URL.createObjectURL(blob);
+		},
+
+		onUpdateImportFile() {
+			const f = (this.$refs.file as any).files[0];
+
+			const reader = new FileReader();
+
+			reader.onload = e => {
+				this.install(e.target.result);
+			};
+
+			reader.readAsText(f);
+		},
+
+		preview() {
+			applyTheme(this.myTheme, false);
+		},
+
+		gen() {
+			const theme = this.myTheme;
+			if (theme.name == null || theme.name.trim() == '') {
+				alert('%i18n:@theme-name-required%');
+				return;
+			}
+			theme.id = uuid();
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			alert('%i18n:@saved%');
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.nicnklzforebnpfgasiypmpdaaglujqm
+	> details
+		border-top solid 1px var(--faceDivider)
+
+		> summary
+			padding 16px 0
+
+		> *:last-child
+			margin-bottom 16px
+
+	> .creator
+		> div
+			padding 16px 0
+			border-bottom solid 1px var(--faceDivider)
+</style>
diff --git a/src/client/app/common/views/widgets/hashtags.chart.vue b/src/client/app/common/views/components/trends.chart.vue
similarity index 100%
rename from src/client/app/common/views/widgets/hashtags.chart.vue
rename to src/client/app/common/views/components/trends.chart.vue
diff --git a/src/client/app/common/views/components/trends.vue b/src/client/app/common/views/components/trends.vue
new file mode 100644
index 0000000000..3d36d7449c
--- /dev/null
+++ b/src/client/app/common/views/components/trends.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="csqvmxybqbycalfhkxvyfrgbrdalkaoc">
+	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+	<p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p>
+	<!-- トランジションを有効にするとなぜかメモリリークする -->
+	<transition-group v-else tag="div" name="chart">
+		<div v-for="stat in stats" :key="stat.tag">
+			<div class="tag">
+				<router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
+				<p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p>
+			</div>
+			<x-chart class="chart" :src="stat.chart"/>
+		</div>
+	</transition-group>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XChart from './trends.chart.vue';
+
+export default Vue.extend({
+	components: {
+		XChart
+	},
+	data() {
+		return {
+			stats: [],
+			fetching: true,
+			clock: null
+		};
+	},
+	mounted() {
+		this.fetch();
+		this.clock = setInterval(this.fetch, 1000 * 60);
+	},
+	beforeDestroy() {
+		clearInterval(this.clock);
+	},
+	methods: {
+		fetch() {
+			(this as any).api('hashtags/trend').then(stats => {
+				this.stats = stats;
+				this.fetching = false;
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.csqvmxybqbycalfhkxvyfrgbrdalkaoc
+	> .fetching
+	> .empty
+		margin 0
+		padding 16px
+		text-align center
+		color var(--text)
+		opacity 0.7
+
+		> [data-fa]
+			margin-right 4px
+
+	> div
+		.chart-move
+			transition transform 1s ease
+
+		> div
+			display flex
+			align-items center
+			padding 14px 16px
+
+			&:not(:last-child)
+				border-bottom solid 1px var(--faceDivider)
+
+			> .tag
+				flex 1
+				overflow hidden
+				font-size 14px
+				color var(--text)
+
+				> a
+					display block
+					width 100%
+					white-space nowrap
+					overflow hidden
+					text-overflow ellipsis
+					color inherit
+
+				> p
+					margin 0
+					font-size 75%
+					opacity 0.7
+
+			> .chart
+				height 30px
+
+</style>
diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue
index e778750354..a509632520 100644
--- a/src/client/app/common/views/components/ui/button.vue
+++ b/src/client/app/common/views/components/ui/button.vue
@@ -1,9 +1,7 @@
 <template>
-<div class="ui-button" :class="[styl]">
-	<button :type="type" @click="$emit('click')">
-		<slot></slot>
-	</button>
-</div>
+<component class="dmtdnykelhudezerjlfpbhgovrgnqqgr" :is="link ? 'a' : 'button'" :class="[styl, { inline, primary }]" :type="type" @click="$emit('click')">
+	<slot></slot>
+</component>
 </template>
 
 <script lang="ts">
@@ -13,70 +11,100 @@ export default Vue.extend({
 		type: {
 			type: String,
 			required: false
+		},
+		primary: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		inline: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		link: {
+			type: Boolean,
+			required: false,
+			default: false
 		}
 	},
 	data() {
 		return {
 			styl: 'fill'
 		};
-	},
-	inject: {
-		isCardChild: { default: false }
-	},
-	created() {
-		if (this.isCardChild) {
-			this.styl = 'line';
-		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+.dmtdnykelhudezerjlfpbhgovrgnqqgr
+	display block
+	width 100%
+	margin 0
+	padding 8px
+	text-align center
+	font-weight normal
+	font-size 16px
+	border none
+	border-radius 6px
+	outline none
+	box-shadow none
+	text-decoration none
+	user-select none
 
-root(isDark, fill)
-	> button
-		display block
-		width 100%
-		margin 0
-		padding 0
+	*
+		pointer-events none
+
+	&:focus
+		&:after
+			content ""
+			pointer-events none
+			position absolute
+			top -5px
+			right -5px
+			bottom -5px
+			left -5px
+			border 2px solid var(--primaryAlpha03)
+			border-radius 10px
+
+	&:not(.inline) + .dmtdnykelhudezerjlfpbhgovrgnqqgr
+		margin-top 16px
+
+	&.inline
+		display inline-block
+		width auto
+
+	&.primary
 		font-weight bold
-		font-size 16px
-		line-height 44px
-		border none
-		border-radius 6px
-		outline none
-		box-shadow none
 
-		if fill
-			color $theme-color-foreground
-			background $theme-color
+	&.fill
+		color var(--text)
+		background var(--buttonBg)
+
+		&:hover
+			background var(--buttonHoverBg)
+
+		&:active
+			background var(--buttonActiveBg)
+
+		&.primary
+			color var(--primaryForeground)
+			background var(--primary)
 
 			&:hover
-				background lighten($theme-color, 5%)
+				background var(--primaryLighten5)
 
 			&:active
-				background darken($theme-color, 5%)
-		else
-			color $theme-color
-			background none
+				background var(--primaryDarken5)
 
-			&:hover
-				color darken($theme-color, 5%)
-
-			&:active
-				background rgba($theme-color, 0.3)
-
-.ui-button[data-darkmode]
-	&.fill
-		root(true, true)
 	&:not(.fill)
-		root(true, false)
+		color var(--primary)
+		background none
 
-.ui-button:not([data-darkmode])
-	&.fill
-		root(false, true)
-	&:not(.fill)
-		root(false, false)
+		&:hover
+			color var(--primaryDarken5)
+
+		&:active
+			background var(--primaryAlpha03)
 
 </style>
diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue
index 05c51bca6b..a37a38d340 100644
--- a/src/client/app/common/views/components/ui/card.vue
+++ b/src/client/app/common/views/components/ui/card.vue
@@ -20,27 +20,33 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.ui-card
 	margin 16px
-	padding 16px
-	color isDark ? #fff : #000
-	background isDark ? #282C37 : #fff
+	color var(--faceText)
+	background var(--face)
 	box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12)
 
-	@media (min-width 500px)
-		padding 32px
-
 	> header
-		font-weight normal
-		font-size 24px
-		color isDark ? #fff : #444
+		padding 16px
+		font-weight bold
+		font-size 20px
+		color var(--faceText)
 
-.ui-card[data-darkmode]
-	root(true)
+		@media (min-width 500px)
+			padding 24px 32px
 
-.ui-card:not([data-darkmode])
-	root(false)
+	> section
+		padding 20px 16px
+		border-top solid 1px var(--faceDivider)
 
+		@media (min-width 500px)
+			padding 32px
+
+		&.fit-top
+			padding-top 0
+
+		> header
+			margin-bottom 16px
+			font-weight bold
+			color var(--faceText)
 </style>
diff --git a/src/client/app/common/views/components/ui/form.vue b/src/client/app/common/views/components/ui/form.vue
index fc8fdad9c4..5c5bbd7256 100644
--- a/src/client/app/common/views/components/ui/form.vue
+++ b/src/client/app/common/views/components/ui/form.vue
@@ -19,7 +19,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .ui-form
 	> fieldset
diff --git a/src/client/app/common/views/components/ui/form/button.vue b/src/client/app/common/views/components/ui/form/button.vue
index 9c37b3118b..3fd7b47629 100644
--- a/src/client/app/common/views/components/ui/form/button.vue
+++ b/src/client/app/common/views/components/ui/form/button.vue
@@ -25,9 +25,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg
 	display inline-block
 
 	& + .nvemkhtwcnnpkdrwfcbzuwhfulejhmzg
@@ -38,11 +36,11 @@ root(isDark)
 		margin 0
 		padding 12px 20px
 		font-size 14px
-		border 1px solid isDark ? #6d727d : #dcdfe6
+		border 1px solid var(--formButtonBorder)
 		border-radius 4px
 		outline none
 		box-shadow none
-		color isDark ? #fff : #606266
+		color var(--text)
 		transition 0.1s
 
 		*
@@ -50,40 +48,34 @@ root(isDark)
 
 		&:hover
 		&:focus
-			color $theme-color
-			background rgba($theme-color, isDark ? 0.2 : 0.12)
-			border-color rgba($theme-color, isDark ? 0.5 : 0.3)
+			color var(--primary)
+			background var(--formButtonHoverBg)
+			border-color var(--formButtonHoverBorder)
 
 		&:active
-			color darken($theme-color, 20%)
-			background rgba($theme-color, 0.12)
-			border-color $theme-color
+			color var(--primaryDarken20)
+			background var(--formButtonActiveBg)
+			border-color var(--primary)
 			transition all 0s
 
 	&.primary
 		> button
-			border 1px solid $theme-color
-			background $theme-color
-			color $theme-color-foreground
+			border 1px solid var(--primary)
+			background var(--primary)
+			color var(--primaryForeground)
 
 			&:hover
 			&:focus
-				background lighten($theme-color, 20%)
-				border-color lighten($theme-color, 20%)
+				background var(--primaryLighten20)
+				border-color var(--primaryLighten20)
 
 			&:active
-				background darken($theme-color, 20%)
-				border-color darken($theme-color, 20%)
+				background var(--primaryDarken20)
+				border-color var(--primaryDarken20)
 				transition all 0s
 
 	&.round
 		> button
 			border-radius 64px
 
-.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg[data-darkmode]
-	root(true)
-
-.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/ui/form/radio.vue b/src/client/app/common/views/components/ui/form/radio.vue
index 831981bb3e..396b2997e5 100644
--- a/src/client/app/common/views/components/ui/form/radio.vue
+++ b/src/client/app/common/views/components/ui/form/radio.vue
@@ -49,9 +49,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.uywduthvrdnlpsvsjkqigicixgyfctto
 	display inline-flex
 	margin 0 16px 0 0
 	cursor pointer
@@ -62,7 +60,7 @@ root(isDark)
 
 	&:hover
 		> .button
-			border solid 2px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+			border solid 2px var(--inputLabel)
 
 	&.disabled
 		opacity 0.6
@@ -70,15 +68,15 @@ root(isDark)
 
 	&.checked
 		> .button
-			border-color $theme-color
+			border-color var(--primary)
 
 			&:after
-				background-color $theme-color
+				background-color var(--primary)
 				transform scale(1)
 				opacity 1
 
 		> .label
-			color $theme-color
+			color var(--primary)
 
 	> input
 		position absolute
@@ -93,7 +91,7 @@ root(isDark)
 		width 20px
 		height 20px
 		background none
-		border solid 2px isDark ? rgba(#fff, 0.6) : rgba(#000, 0.4)
+		border solid 2px var(--radioBorder)
 		border-radius 100%
 		transition inherit
 
@@ -117,10 +115,4 @@ root(isDark)
 		line-height 20px
 		cursor pointer
 
-.uywduthvrdnlpsvsjkqigicixgyfctto[data-darkmode]
-	root(true)
-
-.uywduthvrdnlpsvsjkqigicixgyfctto:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue
index ce28bfb12a..abbd5a2feb 100644
--- a/src/client/app/common/views/components/ui/input.vue
+++ b/src/client/app/common/views/components/ui/input.vue
@@ -71,14 +71,18 @@ export default Vue.extend({
 			type: Boolean,
 			required: false,
 			default: false
+		},
+		styl: {
+			type: String,
+			required: false,
+			default: 'line'
 		}
 	},
 	data() {
 		return {
 			v: this.value,
 			focused: false,
-			passwordStrength: '',
-			styl: 'fill'
+			passwordStrength: ''
 		};
 	},
 	computed: {
@@ -117,14 +121,6 @@ export default Vue.extend({
 			}
 		}
 	},
-	inject: {
-		isCardChild: { default: false }
-	},
-	created() {
-		if (this.isCardChild) {
-			this.styl = 'line';
-		}
-	},
 	mounted() {
 		if (this.$refs.prefix) {
 			this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
@@ -155,9 +151,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark, fill)
+root(fill)
 	margin 32px 0
 
 	> .icon
@@ -167,7 +161,7 @@ root(isDark, fill)
 		width 24px
 		text-align center
 		line-height 32px
-		color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+		color var(--inputLabel)
 
 		&:not(:empty) + .input
 			margin-left 28px
@@ -183,7 +177,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				height 1px
-				background isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42)
+				background var(--inputBorder)
 
 			&:after
 				content ''
@@ -193,7 +187,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				height 2px
-				background $theme-color
+				background var(--primary)
 				opacity 0
 				transform scaleX(0.12)
 				transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)
@@ -242,7 +236,7 @@ root(isDark, fill)
 			transition-duration 0.3s
 			font-size 16px
 			line-height 32px
-			color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+			color var(--inputLabel)
 			pointer-events none
 			//will-change transform
 			transform-origin top left
@@ -257,7 +251,7 @@ root(isDark, fill)
 			font-weight fill ? bold : normal
 			font-size 16px
 			line-height 32px
-			color isDark ? #fff : #000
+			color var(--inputText)
 			background transparent
 			border none
 			border-radius 0
@@ -280,7 +274,7 @@ root(isDark, fill)
 			top 0
 			font-size 16px
 			line-height fill ? 44px : 32px
-			color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+			color var(--inputLabel)
 			pointer-events none
 
 			&:empty
@@ -325,7 +319,7 @@ root(isDark, fill)
 					transform scaleX(1)
 
 			> .label
-				color $theme-color
+				color var(--primary)
 
 	&.focused
 	&.filled
@@ -335,16 +329,10 @@ root(isDark, fill)
 				left 0 !important
 				transform scale(0.75)
 
-.ui-input[data-darkmode]
+.ui-input
 	&.fill
-		root(true, true)
+		root(true)
 	&:not(.fill)
-		root(true, false)
-
-.ui-input:not([data-darkmode])
-	&.fill
-		root(false, true)
-	&:not(.fill)
-		root(false, false)
+		root(false)
 
 </style>
diff --git a/src/client/app/common/views/components/ui/radio.vue b/src/client/app/common/views/components/ui/radio.vue
index 04a46c5a96..868a339aa4 100644
--- a/src/client/app/common/views/components/ui/radio.vue
+++ b/src/client/app/common/views/components/ui/radio.vue
@@ -51,11 +51,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.ui-radio
 	display inline-block
-	margin 32px 32px 32px 0
+	margin 0 32px 0 0
 	cursor pointer
 	transition all 0.3s
 
@@ -68,10 +66,10 @@ root(isDark)
 
 	&.checked
 		> .button
-			border-color $theme-color
+			border-color var(--primary)
 
 			&:after
-				background-color $theme-color
+				background-color var(--primary)
 				transform scale(1)
 				opacity 1
 
@@ -87,7 +85,7 @@ root(isDark)
 		width 20px
 		height 20px
 		background none
-		border solid 2px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+		border solid 2px var(--inputLabel)
 		border-radius 100%
 		transition inherit
 
@@ -111,10 +109,4 @@ root(isDark)
 		line-height 20px
 		cursor pointer
 
-.ui-radio[data-darkmode]
-	root(true)
-
-.ui-radio:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue
index 4273a4a0de..da6f9696b5 100644
--- a/src/client/app/common/views/components/ui/select.vue
+++ b/src/client/app/common/views/components/ui/select.vue
@@ -29,13 +29,17 @@ export default Vue.extend({
 		required: {
 			type: Boolean,
 			required: false
+		},
+		styl: {
+			type: String,
+			required: false,
+			default: 'line'
 		}
 	},
 	data() {
 		return {
 			v: this.value,
-			focused: false,
-			styl: 'fill'
+			focused: false
 		};
 	},
 	computed: {
@@ -48,14 +52,6 @@ export default Vue.extend({
 			this.v = v;
 		}
 	},
-	inject: {
-		isCardChild: { default: false }
-	},
-	created() {
-		if (this.isCardChild) {
-			this.styl = 'line';
-		}
-	},
 	mounted() {
 		if (this.$refs.prefix) {
 			this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
@@ -70,9 +66,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark, fill)
+root(fill)
 	margin 32px 0
 
 	> .icon
@@ -103,7 +97,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				height 1px
-				background isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42)
+				background var(--inputBorder)
 
 			&:after
 				content ''
@@ -113,7 +107,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				height 2px
-				background $theme-color
+				background var(--primary)
 				opacity 0
 				transform scaleX(0.12)
 				transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)
@@ -143,7 +137,7 @@ root(isDark, fill)
 			font-weight fill ? bold : normal
 			font-size 16px
 			height 32px
-			color isDark ? #fff : #000
+			color var(--inputText)
 			background transparent
 			border none
 			border-radius 0
@@ -190,7 +184,7 @@ root(isDark, fill)
 					transform scaleX(1)
 
 			> .label
-				color $theme-color
+				color var(--primary)
 
 	&.focused
 	&.filled
@@ -200,16 +194,10 @@ root(isDark, fill)
 				left 0 !important
 				transform scale(0.75)
 
-.ui-select[data-darkmode]
+.ui-select
 	&.fill
-		root(true, true)
+		root(true)
 	&:not(.fill)
-		root(true, false)
-
-.ui-select:not([data-darkmode])
-	&.fill
-		root(false, true)
-	&:not(.fill)
-		root(false, false)
+		root(false)
 
 </style>
diff --git a/src/client/app/common/views/components/ui/switch.vue b/src/client/app/common/views/components/ui/switch.vue
index a9e00d73d2..935f219833 100644
--- a/src/client/app/common/views/components/ui/switch.vue
+++ b/src/client/app/common/views/components/ui/switch.vue
@@ -19,7 +19,7 @@
 	<span class="label">
 		<span :aria-hidden="!checked"><slot></slot></span>
 		<p :aria-hidden="!checked">
-			<slot name="text"></slot>
+			<slot name="desc"></slot>
 		</p>
 	</span>
 </div>
@@ -56,14 +56,18 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.ui-switch
 	display flex
 	margin 32px 0
 	cursor pointer
 	transition all 0.3s
 
+	&:first-child
+		margin-top 0
+
+	&:last-child
+		margin-bottom 0
+
 	> *
 		user-select none
 
@@ -73,11 +77,11 @@ root(isDark)
 
 	&.checked
 		> .button
-			background-color rgba($theme-color, 0.4)
-			border-color rgba($theme-color, 0.4)
+			background-color var(--primaryAlpha04)
+			border-color var(--primaryAlpha04)
 
 			> *
-				background-color $theme-color
+				background-color var(--primary)
 				transform translateX(14px)
 
 	> input
@@ -89,10 +93,11 @@ root(isDark)
 
 	> .button
 		display inline-block
+		flex-shrink 0
 		margin 3px 0 0 0
 		width 34px
 		height 14px
-		background isDark ? rgba(#fff, 0.15) : rgba(#000, 0.25)
+		background var(--switchTrack)
 		outline none
 		border-radius 14px
 		transition inherit
@@ -118,18 +123,11 @@ root(isDark)
 		> span
 			display block
 			line-height 20px
-			color isDark ? #c4ccd2 : rgba(#000, 0.75)
+			color currentColor
 			transition inherit
 
 		> p
 			margin 0
-			//font-size 90%
-			color isDark ? #78858e : #9daab3
-
-.ui-switch[data-darkmode]
-	root(true)
-
-.ui-switch:not([data-darkmode])
-	root(false)
+			opacity 0.7
 
 </style>
diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue
index 60fe1cdd82..67898ee059 100644
--- a/src/client/app/common/views/components/ui/textarea.vue
+++ b/src/client/app/common/views/components/ui/textarea.vue
@@ -63,9 +63,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark, fill)
+root(fill)
 	margin 42px 0 32px 0
 
 	> .input
@@ -84,7 +82,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				background none
-				border solid 1px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42)
+				border solid 1px var(--inputBorder)
 				border-radius 3px
 				pointer-events none
 
@@ -97,7 +95,7 @@ root(isDark, fill)
 				left 0
 				right 0
 				background none
-				border solid 2px $theme-color
+				border solid 2px var(--primary)
 				border-radius 3px
 				opacity 0
 				transition opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)
@@ -112,7 +110,7 @@ root(isDark, fill)
 			transition-duration 0.3s
 			font-size 16px
 			line-height 32px
-			color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54)
+			color var(--inputLabel)
 			pointer-events none
 			//will-change transform
 			transform-origin top left
@@ -126,7 +124,7 @@ root(isDark, fill)
 			font inherit
 			font-weight fill ? bold : normal
 			font-size 16px
-			color isDark ? #fff : #000
+			color var(--inputText)
 			background transparent
 			border none
 			border-radius 0
@@ -149,7 +147,7 @@ root(isDark, fill)
 					opacity 1
 
 			> .label
-				color $theme-color
+				color var(--primary)
 
 	&.focused
 	&.filled
@@ -159,16 +157,10 @@ root(isDark, fill)
 				left 0 !important
 				transform scale(0.75)
 
-.ui-textarea[data-darkmode]
-	&.fill
-		root(true, true)
-	&:not(.fill)
-		root(true, false)
+.ui-textarea.fill
+	root(true)
 
-.ui-textarea:not([data-darkmode])
-	&.fill
-		root(false, true)
-	&:not(.fill)
-		root(false, false)
+.ui-textarea:not(.fill)
+	root(false)
 
 </style>
diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue
index f4797d89f7..b812064bbb 100644
--- a/src/client/app/common/views/components/uploader.vue
+++ b/src/client/app/common/views/components/uploader.vue
@@ -20,6 +20,7 @@
 <script lang="ts">
 import Vue from 'vue';
 import { apiUrl } from '../../../config';
+import getMD5 from '../../scripts/get-md5';
 
 export default Vue.extend({
 	data() {
@@ -28,61 +29,83 @@ export default Vue.extend({
 		};
 	},
 	methods: {
-		upload(file, folder) {
+		checkExistence(fileData: ArrayBuffer): Promise<any> {
+			return new Promise((resolve, reject) => {
+				const data = new FormData();
+				data.append('md5', getMD5(fileData));
+
+				(this as any).api('drive/files/check_existence', {
+					md5: getMD5(fileData)
+				}).then(resp => {
+					resolve(resp.file);
+				});
+			});
+		},
+
+		upload(file: File, folder: any) {
 			if (folder && typeof folder == 'object') folder = folder.id;
 
 			const id = Math.random();
 
-			const ctx = {
-				id: id,
-				name: file.name || 'untitled',
-				progress: undefined,
-				img: undefined
-			};
-
-			this.uploads.push(ctx);
-			this.$emit('change', this.uploads);
-
 			const reader = new FileReader();
 			reader.onload = (e: any) => {
-				ctx.img = e.target.result;
-			};
-			reader.readAsDataURL(file);
+				this.checkExistence(e.target.result).then(result => {
+					if (result !== null) {
+						this.$emit('uploaded', result);
+						return;
+					}
 
-			const data = new FormData();
-			data.append('i', this.$store.state.i.token);
-			data.append('file', file);
+					// Upload if the file didn't exist yet
+					const buf = new Uint8Array(e.target.result);
+					let bin = '';
+					// We use for-of loop instead of apply() to avoid RangeError
+					// SEE: https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string
+					for (const byte of buf) bin += String.fromCharCode(byte);
+					const ctx = {
+						id: id,
+						name: file.name || 'untitled',
+						progress: undefined,
+						img: 'data:*/*;base64,' + btoa(bin)
+					};
 
-			if (folder) data.append('folderId', folder);
+					this.uploads.push(ctx);
+					this.$emit('change', this.uploads);
 
-			const xhr = new XMLHttpRequest();
-			xhr.open('POST', apiUrl + '/drive/files/create', true);
-			xhr.onload = (e: any) => {
-				const driveFile = JSON.parse(e.target.response);
+					const data = new FormData();
+					data.append('i', this.$store.state.i.token);
+					data.append('file', file);
 
-				this.$emit('uploaded', driveFile);
+					if (folder) data.append('folderId', folder);
 
-				this.uploads = this.uploads.filter(x => x.id != id);
-				this.$emit('change', this.uploads);
-			};
+					const xhr = new XMLHttpRequest();
+					xhr.open('POST', apiUrl + '/drive/files/create', true);
+					xhr.onload = (e: any) => {
+						const driveFile = JSON.parse(e.target.response);
 
-			xhr.upload.onprogress = e => {
-				if (e.lengthComputable) {
-					if (ctx.progress == undefined) ctx.progress = {};
-					ctx.progress.max = e.total;
-					ctx.progress.value = e.loaded;
-				}
-			};
+						this.$emit('uploaded', driveFile);
 
-			xhr.send(data);
+						this.uploads = this.uploads.filter(x => x.id != id);
+						this.$emit('change', this.uploads);
+					};
+
+					xhr.upload.onprogress = e => {
+						if (e.lengthComputable) {
+							if (ctx.progress == undefined) ctx.progress = {};
+							ctx.progress.max = e.total;
+							ctx.progress.value = e.loaded;
+						}
+					};
+
+					xhr.send(data);
+				})
+			}
+			reader.readAsArrayBuffer(file);
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
 .mk-uploader
 	overflow auto
 
@@ -100,7 +123,7 @@ export default Vue.extend({
 			margin 8px 0 0 0
 			padding 0
 			height 36px
-			box-shadow 0 -1px 0 rgba($theme-color, 0.1)
+			box-shadow 0 -1px 0 var(--primaryAlpha01)
 			border-top solid 8px transparent
 
 			&:first-child
@@ -127,7 +150,7 @@ export default Vue.extend({
 				padding 0
 				max-width 256px
 				font-size 0.8em
-				color rgba($theme-color, 0.7)
+				color var(--primaryAlpha07)
 				white-space nowrap
 				text-overflow ellipsis
 				overflow hidden
@@ -145,17 +168,17 @@ export default Vue.extend({
 				font-size 0.8em
 
 				> .initing
-					color rgba($theme-color, 0.5)
+					color var(--primaryAlpha05)
 
 				> .kb
-					color rgba($theme-color, 0.5)
+					color var(--primaryAlpha05)
 
 				> .percentage
 					display inline-block
 					width 48px
 					text-align right
 
-					color rgba($theme-color, 0.7)
+					color var(--primaryAlpha07)
 
 					&:after
 						content '%'
@@ -174,10 +197,10 @@ export default Vue.extend({
 				overflow hidden
 
 				&::-webkit-progress-value
-					background $theme-color
+					background var(--primary)
 
 				&::-webkit-progress-bar
-					background rgba($theme-color, 0.1)
+					background var(--primaryAlpha01)
 
 			> .progress
 				display block
@@ -191,13 +214,13 @@ export default Vue.extend({
 				border-radius 4px
 				background linear-gradient(
 					45deg,
-					lighten($theme-color, 30%) 25%,
-					$theme-color               25%,
-					$theme-color               50%,
-					lighten($theme-color, 30%) 50%,
-					lighten($theme-color, 30%) 75%,
-					$theme-color               75%,
-					$theme-color
+					var(--primaryLighten30) 25%,
+					var(--primary)               25%,
+					var(--primary)               50%,
+					var(--primaryLighten30) 50%,
+					var(--primaryLighten30) 75%,
+					var(--primary)               75%,
+					var(--primary)
 				)
 				background-size 32px 32px
 				animation bg 1.5s linear infinite
diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue
index 242d9ba5c6..86489cf8be 100644
--- a/src/client/app/common/views/components/url-preview.vue
+++ b/src/client/app/common/views/components/url-preview.vue
@@ -8,13 +8,13 @@
 	</blockquote>
 </div>
 <div v-else class="mk-url-preview">
-	<a :href="url" target="_blank" :title="url" v-if="!fetching">
+	<a :class="{ mini }" :href="url" target="_blank" :title="url" v-if="!fetching">
 		<div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div>
 		<article>
 			<header>
 				<h1>{{ title }}</h1>
 			</header>
-			<p>{{ description }}</p>
+			<p>{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
 			<footer>
 				<img class="icon" v-if="icon" :src="icon"/>
 				<p>{{ sitename }}</p>
@@ -118,6 +118,12 @@ export default Vue.extend({
 			type: Boolean,
 			required: false,
 			default: false
+		},
+
+		mini: {
+			type: Boolean,
+			required: false,
+			default: false
 		}
 	},
 
@@ -164,7 +170,7 @@ export default Vue.extend({
 			return;
 		}
 
-		fetch('/url?url=' + encodeURIComponent(this.url)).then(res => {
+		fetch(`/url?url=${encodeURIComponent(this.url)}`).then(res => {
 			res.json().then(info => {
 				if (info.url == null) return;
 				this.title = info.title;
@@ -194,17 +200,17 @@ export default Vue.extend({
 		top 0
 		width 100%
 
-root(isDark)
+.mk-url-preview
 	> a
 		display block
 		font-size 14px
-		border solid 1px isDark ? #191b1f : #eee
+		border solid 1px var(--urlPreviewBorder)
 		border-radius 4px
 		overflow hidden
 
 		&:hover
 			text-decoration none
-			border-color isDark ? #4f5561 : #ddd
+			border-color var(--urlPreviewBorderHover)
 
 			> article > header > h1
 				text-decoration underline
@@ -229,11 +235,11 @@ root(isDark)
 				> h1
 					margin 0
 					font-size 1em
-					color isDark ? #d6dae0 : #555
+					color var(--urlPreviewTitle)
 
 			> p
 				margin 0
-				color isDark ? #a4aab3 : #777
+				color var(--urlPreviewText)
 				font-size 0.8em
 
 			> footer
@@ -250,7 +256,7 @@ root(isDark)
 				> p
 					display inline-block
 					margin 0
-					color isDark ? #b0b4bf : #666
+					color var(--urlPreviewInfo)
 					font-size 0.8em
 					line-height 16px
 					vertical-align top
@@ -293,10 +299,27 @@ root(isDark)
 						width 12px
 						height 12px
 
-.mk-url-preview[data-darkmode]
-	root(true)
+		&.mini
+			font-size 10px
 
-.mk-url-preview:not([data-darkmode])
-	root(false)
+			> .thumbnail
+				position relative
+				width 100%
+				height 60px
+
+			> article
+				left 0
+				width 100%
+				padding 8px
+
+				> header
+					margin-bottom 4px
+
+				> footer
+					margin-top 4px
+
+					> img
+						width 12px
+						height 12px
 
 </style>
diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue
index e6ffe4466d..04a1f30135 100644
--- a/src/client/app/common/views/components/url.vue
+++ b/src/client/app/common/views/components/url.vue
@@ -12,6 +12,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+import { toUnicode as decodePunycode } from 'punycode';
 export default Vue.extend({
 	props: ['url', 'target'],
 	data() {
@@ -27,11 +28,11 @@ export default Vue.extend({
 	created() {
 		const url = new URL(this.url);
 		this.schema = url.protocol;
-		this.hostname = url.hostname;
+		this.hostname = decodePunycode(url.hostname);
 		this.port = url.port;
-		this.pathname = url.pathname;
-		this.query = url.search;
-		this.hash = url.hash;
+		this.pathname = decodeURIComponent(url.pathname);
+		this.query = decodeURIComponent(url.search);
+		this.hash = decodeURIComponent(url.hash);
 	}
 });
 </script>
diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue
index 4691604e57..02f33bfbc0 100644
--- a/src/client/app/common/views/components/visibility-chooser.vue
+++ b/src/client/app/common/views/components/visibility-chooser.vue
@@ -47,7 +47,7 @@ export default Vue.extend({
 	props: ['source', 'compact'],
 	data() {
 		return {
-			v: this.$store.state.device.visibility || 'public'
+			v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility
 		}
 	},
 	mounted() {
@@ -97,9 +97,11 @@ export default Vue.extend({
 	},
 	methods: {
 		choose(visibility) {
-			this.$store.commit('device/setVisibility', visibility);
+			if (this.$store.state.settings.rememberNoteVisibility) {
+				this.$store.commit('device/setVisibility', visibility);
+			}
 			this.$emit('chosen', visibility);
-			this.$destroy();
+			this.destroyDom();
 		},
 		close() {
 			(this.$refs.backdrop as any).style.pointerEvents = 'none';
@@ -117,7 +119,7 @@ export default Vue.extend({
 				scale: 0.5,
 				duration: 200,
 				easing: 'easeInBack',
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		}
 	}
@@ -125,11 +127,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
 $border-color = rgba(27, 31, 35, 0.15)
 
-root(isDark)
+.mk-visibility-chooser
 	position initial
 
 	> .backdrop
@@ -139,11 +139,11 @@ root(isDark)
 		z-index 10000
 		width 100%
 		height 100%
-		background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1)
+		background var(--modalBackdrop)
 		opacity 0
 
 	> .popover
-		$bgcolor = isDark ? #2c303c : #fff
+		$bgcolor = var(--popupBg)
 		position absolute
 		z-index 10001
 		width 240px
@@ -187,18 +187,18 @@ root(isDark)
 			display flex
 			padding 8px 14px
 			font-size 12px
-			color isDark ? #fff : #666
+			color var(--popupFg)
 			cursor pointer
 
 			&:hover
-				background isDark ? #252731 : #eee
+				background var(--faceClearButtonHover)
 
 			&:active
-				background isDark ? #21242b : #ddd
+				background var(--faceClearButtonActive)
 
 			&.active
-				color $theme-color-foreground
-				background $theme-color
+				color var(--primaryForeground)
+				background var(--primary)
 
 			> *
 				user-select none
@@ -220,11 +220,4 @@ root(isDark)
 
 				> span:last-child:not(:first-child)
 					opacity 0.6
-
-.mk-visibility-chooser[data-darkmode]
-	root(true)
-
-.mk-visibility-chooser:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index 5a8b9df476..4a66db57b8 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -1,22 +1,24 @@
 <template>
 <div class="mk-welcome-timeline">
-	<div v-for="note in notes">
-		<mk-avatar class="avatar" :user="note.user" target="_blank"/>
-		<div class="body">
-			<header>
-				<router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link>
-				<span class="username">@{{ note.user | acct }}</span>
-				<div class="info">
-					<router-link class="created-at" :to="note | notePage">
-						<mk-time :time="note.createdAt"/>
-					</router-link>
+	<transition-group name="ldzpakcixzickvggyixyrhqwjaefknon" tag="div">
+		<div v-for="note in notes" :key="note.id">
+			<mk-avatar class="avatar" :user="note.user" target="_blank"/>
+			<div class="body">
+				<header>
+					<router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link>
+					<span class="username">@{{ note.user | acct }}</span>
+					<div class="info">
+						<router-link class="created-at" :to="note | notePage">
+							<mk-time :time="note.createdAt"/>
+						</router-link>
+					</div>
+				</header>
+				<div class="text">
+					<misskey-flavored-markdown v-if="note.text" :text="note.text"/>
 				</div>
-			</header>
-			<div class="text">
-				<misskey-flavored-markdown v-if="note.text" :text="note.text"/>
 			</div>
 		</div>
-	</div>
+	</transition-group>
 </div>
 </template>
 
@@ -31,15 +33,27 @@ export default Vue.extend({
 			default: undefined
 		}
 	},
+
 	data() {
 		return {
 			fetching: true,
-			notes: []
+			notes: [],
+			connection: null
 		};
 	},
+
 	mounted() {
 		this.fetch();
+
+		this.connection = (this as any).os.stream.useSharedConnection('localTimeline');
+
+		this.connection.on('note', this.onNote);
 	},
+
+	beforeDestroy() {
+		this.connection.dispose();
+	},
+
 	methods: {
 		fetch(cb?) {
 			this.fetching = true;
@@ -48,82 +62,92 @@ export default Vue.extend({
 				local: true,
 				reply: false,
 				renote: false,
-				media: false,
-				poll: false,
-				bot: false
+				file: false,
+				poll: false
 			}).then(notes => {
 				this.notes = notes;
 				this.fetching = false;
 			});
-		}
+		},
+
+		onNote(note) {
+			if (note.replyId != null) return;
+			if (note.renoteId != null) return;
+			if (note.poll != null) return;
+
+			this.notes.unshift(note);
+		},
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	background isDark ? #282C37 : #fff
+.ldzpakcixzickvggyixyrhqwjaefknon-enter
+.ldzpakcixzickvggyixyrhqwjaefknon-leave-to
+	opacity 0
+	transform translateY(-30px)
+
+.mk-welcome-timeline
+	background var(--face)
 
 	> div
-		padding 16px
-		overflow-wrap break-word
-		font-size .9em
-		color isDark ? #fff : #4C4C4C
-		border-bottom 1px solid isDark ? rgba(#000, 0.1) : rgba(#000, 0.05)
+		> *
+			transition transform .3s ease, opacity .3s ease
 
-		&:after
-			content ""
-			display block
-			clear both
+		> div
+			padding 16px
+			overflow-wrap break-word
+			font-size .9em
+			color var(--noteText)
+			border-bottom 1px solid var(--faceDivider)
 
-		> .avatar
-			display block
-			float left
-			position -webkit-sticky
-			position sticky
-			top 16px
-			width 42px
-			height 42px
-			border-radius 6px
+			&:after
+				content ""
+				display block
+				clear both
 
-		> .body
-			float right
-			width calc(100% - 42px)
-			padding-left 12px
+			> .avatar
+				display block
+				float left
+				position -webkit-sticky
+				position sticky
+				top 16px
+				width 42px
+				height 42px
+				border-radius 6px
 
-			> header
-				display flex
-				align-items center
-				margin-bottom 4px
-				white-space nowrap
+			> .body
+				float right
+				width calc(100% - 42px)
+				padding-left 12px
 
-				> .name
-					display block
-					margin 0 .5em 0 0
-					padding 0
-					overflow hidden
-					font-weight bold
-					text-overflow ellipsis
-					color isDark ? #fff : #627079
+				> header
+					display flex
+					align-items center
+					margin-bottom 4px
+					white-space nowrap
 
-				> .username
-					margin 0 .5em 0 0
-					color isDark ? #606984 : #ccc
+					> .name
+						display block
+						margin 0 .5em 0 0
+						padding 0
+						overflow hidden
+						font-weight bold
+						text-overflow ellipsis
+						color var(--noteHeaderName)
 
-				> .info
-					margin-left auto
-					font-size 0.9em
+					> .username
+						margin 0 .5em 0 0
+						color var(--noteHeaderAcct)
 
-					> .created-at
-						color isDark ? #606984 : #c0c0c0
+					> .info
+						margin-left auto
+						font-size 0.9em
 
-			> .text
-				text-align left
+						> .created-at
+							color var(--noteHeaderInfo)
 
-.mk-welcome-timeline[data-darkmode]
-	root(true)
-
-.mk-welcome-timeline:not([data-darkmode])
-	root(false)
+				> .text
+					text-align left
 
 </style>
diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts
index b252cf5c1f..f7f8e9bf16 100644
--- a/src/client/app/common/views/directives/autocomplete.ts
+++ b/src/client/app/common/views/directives/autocomplete.ts
@@ -167,7 +167,7 @@ class Autocomplete {
 	private close() {
 		if (this.suggestion == null) return;
 
-		this.suggestion.$destroy();
+		this.suggestion.destroyDom();
 		this.suggestion = null;
 
 		this.textarea.focus();
@@ -191,7 +191,7 @@ class Autocomplete {
 			const acct = renderAcct(value);
 
 			// 挿入
-			this.text = trimmedBefore + '@' + acct + ' ' + after;
+			this.text = `${trimmedBefore}@${acct} ${after}`;
 
 			// キャレットを戻す
 			this.vm.$nextTick(() => {
@@ -207,7 +207,7 @@ class Autocomplete {
 			const after = source.substr(caret);
 
 			// 挿入
-			this.text = trimmedBefore + '#' + value + ' ' + after;
+			this.text = `${trimmedBefore}#${value} ${after}`;
 
 			// キャレットを戻す
 			this.vm.$nextTick(() => {
diff --git a/src/client/app/common/views/filters/note.ts b/src/client/app/common/views/filters/note.ts
index a611dc8685..3c9c8b7485 100644
--- a/src/client/app/common/views/filters/note.ts
+++ b/src/client/app/common/views/filters/note.ts
@@ -1,5 +1,5 @@
 import Vue from 'vue';
 
 Vue.filter('notePage', note => {
-	return '/notes/' + note.id;
+	return `/notes/${note.id}`;
 });
diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts
index ca0910fc53..e5220229b7 100644
--- a/src/client/app/common/views/filters/user.ts
+++ b/src/client/app/common/views/filters/user.ts
@@ -11,5 +11,5 @@ Vue.filter('userName', user => {
 });
 
 Vue.filter('userPage', (user, path?) => {
-	return '/@' + Vue.filter('acct')(user) + (path ? '/' + path : '');
+	return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`;
 });
diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue
index 13d855d20a..92f24fb538 100644
--- a/src/client/app/common/views/pages/follow.vue
+++ b/src/client/app/common/views/pages/follow.vue
@@ -1,6 +1,6 @@
 <template>
-<div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching" :data-darkmode="$store.state.device.darkmode">
-	<div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + myName + '</b>')"></div>
+<div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching">
+	<div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${myName}`)"></div>
 
 	<main>
 		<div class="banner" :style="bannerStyle"></div>
@@ -19,7 +19,8 @@
 			@click="onClick"
 			:disabled="followWait">
 		<template v-if="!followWait">
-			<template v-if="user.hasPendingFollowRequestFromYou">%fa:hourglass-half% %i18n:@request-pending%</template>
+			<template v-if="user.hasPendingFollowRequestFromYou && user.isLocked">%fa:hourglass-half% %i18n:@request-pending%</template>
+			<template v-else-if="user.hasPendingFollowRequestFromYou && !user.isLocked">%fa:hourglass-start% %i18n:@follow-processing%</template>
 			<template v-else-if="user.isFollowing">%fa:minus% %i18n:@following%</template>
 			<template v-else-if="!user.isFollowing && user.isLocked">%fa:plus% %i18n:@follow-request%</template>
 			<template v-else-if="!user.isFollowing && !user.isLocked">%fa:plus% %i18n:@follow%</template>
@@ -32,7 +33,6 @@
 <script lang="ts">
 import Vue from 'vue';
 import parseAcct from '../../../../../misc/acct/parse';
-import getUserName from '../../../../../misc/get-user-name';
 import Progress from '../../../common/scripts/loading';
 
 export default Vue.extend({
@@ -83,7 +83,7 @@ export default Vue.extend({
 						userId: this.user.id
 					});
 				} else {
-					if (this.user.isLocked && this.user.hasPendingFollowRequestFromYou) {
+					if (this.user.hasPendingFollowRequestFromYou) {
 						this.user = await (this as any).api('following/requests/cancel', {
 							userId: this.user.id
 						});
@@ -108,16 +108,14 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.syxhndwprovvuqhmyvveewmbqayniwkv
 	padding 32px
 	max-width 500px
 	margin 0 auto
 	text-align center
-	color isDark ? #9baec8 : #868c8c
+	color var(--text)
 
-	$bg = isDark ? #282C37 : #fff
+	$bg = var(--face)
 
 	@media (max-width 400px)
 		padding 16px
@@ -125,7 +123,6 @@ root(isDark)
 	> .signed-in-as
 		margin-bottom 16px
 		font-size 14px
-		color isDark ? #9baec8 : #9daab3
 
 	> main
 		margin-bottom 16px
@@ -174,29 +171,29 @@ root(isDark)
 		min-width 150px
 		font-size 14px
 		font-weight bold
-		color $theme-color
+		color var(--primary)
 		background transparent
 		outline none
-		border solid 1px $theme-color
+		border solid 1px var(--primary)
 		border-radius 36px
 
 		&:hover
-			background rgba($theme-color, 0.1)
+			background var(--primaryAlpha01)
 
 		&:active
-			background rgba($theme-color, 0.2)
+			background var(--primaryAlpha02)
 
 		&.active
-			color $theme-color-foreground
-			background $theme-color
+			color var(--primaryForeground)
+			background var(--primary)
 
 			&:hover
-				background lighten($theme-color, 10%)
-				border-color lighten($theme-color, 10%)
+				background var(--primaryLighten10)
+				border-color var(--primaryLighten10)
 
 			&:active
-				background darken($theme-color, 10%)
-				border-color darken($theme-color, 10%)
+				background var(--primaryDarken10)
+				border-color var(--primaryDarken10)
 
 		&.wait
 			cursor wait !important
@@ -205,10 +202,4 @@ root(isDark)
 		*
 			pointer-events none
 
-.syxhndwprovvuqhmyvveewmbqayniwkv[data-darkmode]
-	root(true)
-
-.syxhndwprovvuqhmyvveewmbqayniwkv:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/analog-clock.vue b/src/client/app/common/views/widgets/analog-clock.vue
index 0de30228b3..cfcdd5a1b6 100644
--- a/src/client/app/common/views/widgets/analog-clock.vue
+++ b/src/client/app/common/views/widgets/analog-clock.vue
@@ -1,8 +1,8 @@
 <template>
 <div class="mkw-analog-clock">
-	<mk-widget-container :naked="!(props.design % 2)" :show-header="false">
+	<mk-widget-container :naked="props.style % 2 === 0" :show-header="false">
 		<div class="mkw-analog-clock--body">
-			<mk-analog-clock :dark="$store.state.device.darkmode" :smooth="!(props.design && ~props.design)"/>
+			<mk-analog-clock :dark="$store.state.device.darkmode" :smooth="props.style < 2"/>
 		</div>
 	</mk-widget-container>
 </div>
@@ -13,13 +13,12 @@ import define from '../../../common/define-widget';
 export default define({
 	name: 'analog-clock',
 	props: () => ({
-		design: -1
+		style: 0
 	})
 }).extend({
 	methods: {
 		func() {
-			if (++this.props.design > 2)
-				this.props.design = -1;
+			this.props.style = (this.props.style + 1) % 4;
 			this.save();
 		}
 	}
@@ -27,16 +26,8 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mkw-analog-clock
 	.mkw-analog-clock--body
 		padding 8px
 
-.mkw-analog-clock[data-darkmode]
-	root(true)
-
-.mkw-analog-clock:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue
index 69b2a54fe9..620b09ff0e 100644
--- a/src/client/app/common/views/widgets/broadcast.vue
+++ b/src/client/app/common/views/widgets/broadcast.vue
@@ -1,31 +1,34 @@
 <template>
-<div class="mkw-broadcast"
-	:data-found="broadcasts.length != 0"
-	:data-melt="props.design == 1"
-	:data-mobile="platform == 'mobile'"
->
-	<div class="icon">
-		<svg height="32" version="1.1" viewBox="0 0 32 32" width="32">
-			<path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path>
-			<path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path>
-			<path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path>
-			<path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path>
-			<path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path>
-		</svg>
-	</div>
-	<p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p>
-	<h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:@no-broadcasts%' : broadcasts[i].title }}</h1>
-	<p v-if="!fetching">
-		<span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span>
-		<template v-if="broadcasts.length == 0">%i18n:@have-a-nice-day%</template>
-	</p>
-	<a v-if="broadcasts.length > 1" @click="next">%i18n:@next% &gt;&gt;</a>
+<div class="anltbovirfeutcigvwgmgxipejaeozxi">
+	<mk-widget-container :show-header="false" :naked="props.design == 1">
+		<div class="anltbovirfeutcigvwgmgxipejaeozxi-body"
+			:data-found="announcements && announcements.length != 0"
+			:data-melt="props.design == 1"
+			:data-mobile="platform == 'mobile'"
+		>
+			<div class="icon">
+				<svg height="32" version="1.1" viewBox="0 0 32 32" width="32">
+					<path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path>
+					<path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path>
+					<path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path>
+					<path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path>
+					<path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path>
+				</svg>
+			</div>
+			<p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p>
+			<h1 v-if="!fetching">{{ announcements.length == 0 ? '%i18n:@no-broadcasts%' : announcements[i].title }}</h1>
+			<p v-if="!fetching">
+				<span v-if="announcements.length != 0" v-html="announcements[i].text"></span>
+				<template v-if="announcements.length == 0">%i18n:@have-a-nice-day%</template>
+			</p>
+			<a v-if="announcements.length > 1" @click="next">%i18n:@next% &gt;&gt;</a>
+		</div>
+	</mk-widget-container>
 </div>
 </template>
 
 <script lang="ts">
 import define from '../../../common/define-widget';
-import { lang } from '../../../config';
 
 export default define({
 	name: 'broadcast',
@@ -37,26 +40,18 @@ export default define({
 		return {
 			i: 0,
 			fetching: true,
-			broadcasts: []
+			announcements: []
 		};
 	},
 	mounted() {
 		(this as any).os.getMeta().then(meta => {
-			let broadcasts = [];
-			if (meta.broadcasts) {
-				meta.broadcasts.forEach(broadcast => {
-					if (broadcast[lang]) {
-						broadcasts.push(broadcast[lang]);
-					}
-				});
-			}
-			this.broadcasts = broadcasts;
+			this.announcements = meta.broadcasts;
 			this.fetching = false;
 		});
 	},
 	methods: {
 		next() {
-			if (this.i == this.broadcasts.length - 1) {
+			if (this.i == this.announcements.length - 1) {
 				this.i = 0;
 			} else {
 				this.i++;
@@ -75,13 +70,12 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-.mkw-broadcast
+.anltbovirfeutcigvwgmgxipejaeozxi-body
 	padding 10px
-	border solid 1px #4078c0
-	border-radius 6px
+	background var(--announcementsBg)
 
 	&[data-melt]
-		border none
+		background transparent
 
 	&[data-found]
 		padding-left 50px
@@ -135,22 +129,18 @@ export default define({
 		margin 0
 		font-size 0.95em
 		font-weight normal
-		color #4078c0
+		color var(--announcementsTitle)
 
 	> p
 		display block
 		z-index 1
 		margin 0
 		font-size 0.7em
-		color #555
+		color var(--announcementsText)
 
 		&.fetching
 			text-align center
 
-		a
-			color #555
-			text-decoration underline
-
 	> a
 		display block
 		font-size 0.7em
diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue
index eb15030370..308d73bad8 100644
--- a/src/client/app/common/views/widgets/calendar.vue
+++ b/src/client/app/common/views/widgets/calendar.vue
@@ -116,15 +116,13 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mkw-calendar
 	&[data-special='on-new-years-day']
 		border-color #ef95a0
 
 	.mkw-calendar--body
 		padding 16px 0
-		color isDark ? #c5ced6 : #777
+		color var(--calendarDay)
 
 		&:after
 			content ""
@@ -169,7 +167,8 @@ root(isDark)
 					margin 0 0 2px 0
 					font-size 12px
 					line-height 18px
-					color isDark ? #7a8692 : #888
+					color var(--text)
+					opacity 0.8
 
 					> b
 						margin-left 2px
@@ -177,12 +176,12 @@ root(isDark)
 				> .meter
 					width 100%
 					overflow hidden
-					background isDark ? #1c1f25 : #eee
+					background var(--materBg)
 					border-radius 8px
 
 					> .val
 						height 4px
-						background $theme-color
+						background var(--primary)
 						transition width .3s cubic-bezier(0.23, 1, 0.32, 1)
 
 				&:nth-child(1)
@@ -197,10 +196,4 @@ root(isDark)
 					> .meter > .val
 						background #41ddde
 
-.mkw-calendar[data-darkmode]
-	root(true)
-
-.mkw-calendar:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue
index 544ca1bd9d..b025b41e7d 100644
--- a/src/client/app/common/views/widgets/donation.vue
+++ b/src/client/app/common/views/widgets/donation.vue
@@ -1,13 +1,15 @@
 <template>
-<div class="mkw-donation" :data-mobile="platform == 'mobile'">
-	<article>
-		<h1>%fa:heart%%i18n:@title%</h1>
-		<p v-if="meta">
-			{{ '%i18n:@text%'.substr(0, '%i18n:@text%'.indexOf('{')) }}
-			<a :href="meta.maintainer.url">{{ meta.maintainer.name }}</a>
-			{{ '%i18n:@text%'.substr('%i18n:@text%'.indexOf('}') + 1) }}
-		</p>
-	</article>
+<div>
+	<mk-widget-container :show-header="false">
+		<article class="dolfvtibguprpxxhfndqaosjitixjohx">
+			<h1>%fa:heart%%i18n:@title%</h1>
+			<p v-if="meta">
+				{{ '%i18n:@text%'.substr(0, '%i18n:@text%'.indexOf('{')) }}
+				<a :href="meta.maintainer.url">{{ meta.maintainer.name }}</a>
+				{{ '%i18n:@text%'.substr('%i18n:@text%'.indexOf('}') + 1) }}
+			</p>
+		</article>
+	</mk-widget-container>
 </div>
 </template>
 
@@ -30,46 +32,22 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	background isDark ? #282c37 : #fff
-	border solid 1px isDark ? #c3831c : #ead8bb
-	border-radius 6px
+.dolfvtibguprpxxhfndqaosjitixjohx
+	padding 20px
+	background var(--donationBg)
+	color var(--donationFg)
 
-	> article
-		padding 20px
+	> h1
+		margin 0 0 5px 0
+		font-size 1em
 
-		> h1
-			margin 0 0 5px 0
-			font-size 1em
-			color isDark ? #b2bac1 : #888
+		> [data-fa]
+			margin-right 0.25em
 
-			> [data-fa]
-				margin-right 0.25em
-
-		> p
-			display block
-			z-index 1
-			margin 0
-			font-size 0.8em
-			color isDark ? #a1a6ab : #999
-
-	&[data-mobile]
-		border none
-		background #ead8bb
-		border-radius 8px
-		box-shadow 0 0 0 1px rgba(#000, 0.2)
-
-		> article
-			> h1
-				color #7b8871
-
-			> p
-				color #777d71
-
-.mkw-donation[data-darkmode]
-	root(true)
-
-.mkw-donation:not([data-darkmode])
-	root(false)
+	> p
+		display block
+		z-index 1
+		margin 0
+		font-size 0.8em
 
 </style>
diff --git a/src/client/app/common/views/widgets/hashtags.vue b/src/client/app/common/views/widgets/hashtags.vue
index 56520400b6..0cb6b2df10 100644
--- a/src/client/app/common/views/widgets/hashtags.vue
+++ b/src/client/app/common/views/widgets/hashtags.vue
@@ -4,20 +4,7 @@
 		<template slot="header">%fa:hashtag%%i18n:@title%</template>
 
 		<div class="mkw-hashtags--body" :data-mobile="platform == 'mobile'">
-			<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
-			<p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p>
-			<!-- トランジションを有効にするとなぜかメモリリークする -->
-			<!-- <transition-group v-else tag="div" name="chart"> -->
-			<div>
-				<div v-for="stat in stats" :key="stat.tag">
-					<div class="tag">
-						<router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
-						<p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p>
-					</div>
-					<x-chart class="chart" :src="stat.chart"/>
-				</div>
-			</div>
-			<!-- </transition-group> -->
+			<mk-trends/>
 		</div>
 	</mk-widget-container>
 </div>
@@ -25,7 +12,6 @@
 
 <script lang="ts">
 import define from '../../../common/define-widget';
-import XChart from './hashtags.chart.vue';
 
 export default define({
 	name: 'hashtags',
@@ -33,89 +19,11 @@ export default define({
 		compact: false
 	})
 }).extend({
-	components: {
-		XChart
-	},
-	data() {
-		return {
-			stats: [],
-			fetching: true,
-			clock: null
-		};
-	},
-	mounted() {
-		this.fetch();
-		this.clock = setInterval(this.fetch, 1000 * 60);
-	},
-	beforeDestroy() {
-		clearInterval(this.clock);
-	},
 	methods: {
 		func() {
 			this.props.compact = !this.props.compact;
 			this.save();
-		},
-		fetch() {
-			(this as any).api('hashtags/trend').then(stats => {
-				this.stats = stats;
-				this.fetching = false;
-			});
 		}
 	}
 });
 </script>
-
-<style lang="stylus" scoped>
-root(isDark)
-	.mkw-hashtags--body
-		> .fetching
-		> .empty
-			margin 0
-			padding 16px
-			text-align center
-			color #aaa
-
-			> [data-fa]
-				margin-right 4px
-
-		> div
-			.chart-move
-				transition transform 1s ease
-
-			> div
-				display flex
-				align-items center
-				padding 14px 16px
-
-				&:not(:last-child)
-					border-bottom solid 1px isDark ? #393f4f : #eee
-
-				> .tag
-					flex 1
-					overflow hidden
-					font-size 14px
-					color isDark ? #9baec8 : #65727b
-
-					> a
-						display block
-						width 100%
-						white-space nowrap
-						overflow hidden
-						text-overflow ellipsis
-						color inherit
-
-					> p
-						margin 0
-						font-size 75%
-						opacity 0.7
-
-				> .chart
-					height 30px
-
-.mkw-hashtags[data-darkmode]
-	root(true)
-
-.mkw-hashtags:not([data-darkmode])
-	root(false)
-
-</style>
diff --git a/src/client/app/common/views/widgets/memo.vue b/src/client/app/common/views/widgets/memo.vue
index 30f0d3b009..be8b18a4e9 100644
--- a/src/client/app/common/views/widgets/memo.vue
+++ b/src/client/app/common/views/widgets/memo.vue
@@ -57,9 +57,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mkw-memo
 	.mkw-memo--body
 		padding-bottom 28px + 16px
 
@@ -69,10 +67,10 @@ root(isDark)
 			max-width 100%
 			min-width 100%
 			padding 16px
-			color isDark ? #fff : #222
-			background isDark ? #282c37 : #fff
+			color var(--inputText)
+			background var(--face)
 			border none
-			border-bottom solid 1px isDark ? #1c2023 : #eee
+			border-bottom solid 1px var(--faceDivider)
 			border-radius 0
 
 		> button
@@ -83,8 +81,8 @@ root(isDark)
 			margin 0
 			padding 0 10px
 			height 28px
-			color $theme-color-foreground
-			background $theme-color !important
+			color var(--primaryForeground)
+			background var(--primary) !important
 			outline none
 			border none
 			border-radius 4px
@@ -92,20 +90,14 @@ root(isDark)
 			cursor pointer
 
 			&:hover
-				background lighten($theme-color, 10%) !important
+				background var(--primaryLighten10) !important
 
 			&:active
-				background darken($theme-color, 10%) !important
+				background var(--primaryDarken10) !important
 				transition background 0s ease
 
 			&:disabled
 				opacity 0.7
 				cursor default
 
-.mkw-memo[data-darkmode]
-	root(true)
-
-.mkw-memo:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue
index 0cbf7c158e..12003db3f2 100644
--- a/src/client/app/common/views/widgets/nav.vue
+++ b/src/client/app/common/views/widgets/nav.vue
@@ -16,23 +16,17 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mkw-nav
 	.mkw-nav--body
 		padding 16px
 		font-size 12px
-		color isDark ? #9aa4b3 : #aaa
-		background isDark ? #282c37 : #fff
+		color var(--text)
+		background var(--face)
 
 		a
-			color isDark ? #9aa4b3 : #999
+			color var(--text)
 
 		i
-			color isDark ? #9aa4b3 : #ccc
-
-.mkw-nav[data-darkmode]
-	root(true)
-
-.mkw-nav:not([data-darkmode])
-	root(false)
+			color var(--text)
 
 </style>
diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue
index 3e24c58e8e..047b01df4f 100644
--- a/src/client/app/common/views/widgets/photo-stream.vue
+++ b/src/client/app/common/views/widgets/photo-stream.vue
@@ -24,15 +24,13 @@ export default define({
 		return {
 			images: [],
 			fetching: true,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
-		this.connection.on('drive_file_created', this.onDriveFileCreated);
+		this.connection.on('driveFileCreated', this.onDriveFileCreated);
 
 		(this as any).api('drive/stream', {
 			type: 'image/*',
@@ -43,8 +41,7 @@ export default define({
 		});
 	},
 	beforeDestroy() {
-		this.connection.off('drive_file_created', this.onDriveFileCreated);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		onDriveFileCreated(file) {
diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue
index 801307be54..1c70e6dbc4 100644
--- a/src/client/app/common/views/widgets/posts-monitor.vue
+++ b/src/client/app/common/views/widgets/posts-monitor.vue
@@ -4,7 +4,7 @@
 		<template slot="header">%fa:chart-line%%i18n:@title%</template>
 		<button slot="func" @click="toggle" title="%i18n:@toggle%">%fa:sort%</button>
 
-		<div class="qpdmibaztplkylerhdbllwcokyrfxeyj" :class="{ dual: props.view == 0 }" :data-darkmode="$store.state.device.darkmode">
+		<div class="qpdmibaztplkylerhdbllwcokyrfxeyj" :class="{ dual: props.view == 0 }">
 			<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" v-show="props.view != 2">
 				<defs>
 					<linearGradient :id="localGradientId" x1="0" x2="0" y1="1" y2="0">
@@ -82,7 +82,6 @@ export default define({
 	data() {
 		return {
 			connection: null,
-			connectionId: null,
 			viewBoxY: 30,
 			stats: [],
 			fediGradientId: uuid(),
@@ -110,8 +109,7 @@ export default define({
 		}
 	},
 	mounted() {
-		this.connection = (this as any).os.streams.notesStatsStream.getConnection();
-		this.connectionId = (this as any).os.streams.notesStatsStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('notesStats');
 
 		this.connection.on('stats', this.onStats);
 		this.connection.on('statsLog', this.onStatsLog);
@@ -121,9 +119,7 @@ export default define({
 		});
 	},
 	beforeDestroy() {
-		this.connection.off('stats', this.onStats);
-		this.connection.off('statsLog', this.onStatsLog);
-		(this as any).os.streams.notesStatsStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		toggle() {
@@ -173,7 +169,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.qpdmibaztplkylerhdbllwcokyrfxeyj
 	&.dual
 		> svg
 			width 50%
@@ -192,7 +188,7 @@ root(isDark)
 
 		> text
 			font-size 5px
-			fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55)
+			fill var(--chartCaption)
 
 			> tspan
 				opacity 0.5
@@ -202,10 +198,4 @@ root(isDark)
 		display block
 		clear both
 
-.qpdmibaztplkylerhdbllwcokyrfxeyj[data-darkmode]
-	root(true)
-
-.qpdmibaztplkylerhdbllwcokyrfxeyj:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue
index a777388cdb..448eee9fb6 100644
--- a/src/client/app/common/views/widgets/rss.vue
+++ b/src/client/app/common/views/widgets/rss.vue
@@ -65,7 +65,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mkw-rss
 	.mkw-rss--body
 		.feed
 			padding 12px 16px
@@ -74,8 +74,8 @@ root(isDark)
 			> a
 				display block
 				padding 4px 0
-				color isDark ? #9aa4b3 : #666
-				border-bottom dashed 1px isDark ? #1c2023 : #eee
+				color var(--text)
+				border-bottom dashed 1px var(--faceDivider)
 
 				&:last-child
 					border-bottom none
@@ -90,7 +90,7 @@ root(isDark)
 				margin-right 4px
 
 		&[data-mobile]
-			background isDark ? #21242f : #f3f3f3
+			background var(--face)
 
 			.feed
 				padding 0
@@ -100,12 +100,6 @@ root(isDark)
 					border-bottom none
 
 					&:nth-child(even)
-						background isDark ? rgba(#000, 0.05) : rgba(#fff, 0.7)
-
-.mkw-rss[data-darkmode]
-	root(true)
-
-.mkw-rss:not([data-darkmode])
-	root(false)
+						background rgba(#000, 0.05)
 
 </style>
diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue
index b0421d6150..55aa1ea895 100644
--- a/src/client/app/common/views/widgets/server.cpu-memory.vue
+++ b/src/client/app/common/views/widgets/server.cpu-memory.vue
@@ -129,7 +129,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.cpu-memory
 	> svg
 		display block
 		padding 10px
@@ -144,7 +144,7 @@ root(isDark)
 
 		> text
 			font-size 5px
-			fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55)
+			fill var(--chartCaption)
 
 			> tspan
 				opacity 0.5
@@ -154,10 +154,4 @@ root(isDark)
 		display block
 		clear both
 
-.cpu-memory[data-darkmode]
-	root(true)
-
-.cpu-memory:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue
index b9748bdf7c..2034aee0eb 100644
--- a/src/client/app/common/views/widgets/server.cpu.vue
+++ b/src/client/app/common/views/widgets/server.cpu.vue
@@ -38,7 +38,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.cpu
 	> .pie
 		padding 10px
 		height 100px
@@ -52,7 +52,7 @@ root(isDark)
 		> p
 			margin 0
 			font-size 12px
-			color isDark ? #a8b4bd : #505050
+			color var(--chartCaption)
 
 			&:first-child
 				font-weight bold
@@ -65,10 +65,4 @@ root(isDark)
 		display block
 		clear both
 
-.cpu[data-darkmode]
-	root(true)
-
-.cpu:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue
index 99ce624051..667576ab76 100644
--- a/src/client/app/common/views/widgets/server.disk.vue
+++ b/src/client/app/common/views/widgets/server.disk.vue
@@ -46,7 +46,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.disk
 	> .pie
 		padding 10px
 		height 100px
@@ -60,7 +60,7 @@ root(isDark)
 		> p
 			margin 0
 			font-size 12px
-			color isDark ? #a8b4bd : #505050
+			color var(--chartCaption)
 
 			&:first-child
 				font-weight bold
@@ -73,10 +73,4 @@ root(isDark)
 		display block
 		clear both
 
-.disk[data-darkmode]
-	root(true)
-
-.disk:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue
index 8a60621343..9e12884cf9 100644
--- a/src/client/app/common/views/widgets/server.memory.vue
+++ b/src/client/app/common/views/widgets/server.memory.vue
@@ -46,7 +46,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.memory
 	> .pie
 		padding 10px
 		height 100px
@@ -60,7 +60,7 @@ root(isDark)
 		> p
 			margin 0
 			font-size 12px
-			color isDark ? #a8b4bd : #505050
+			color var(--chartCaption)
 
 			&:first-child
 				font-weight bold
@@ -73,10 +73,4 @@ root(isDark)
 		display block
 		clear both
 
-.memory[data-darkmode]
-	root(true)
-
-.memory:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue
index d557c52ea5..ce342fd41b 100644
--- a/src/client/app/common/views/widgets/server.pie.vue
+++ b/src/client/app/common/views/widgets/server.pie.vue
@@ -45,7 +45,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+svg
 	display block
 	height 100%
 
@@ -56,12 +56,6 @@ root(isDark)
 
 	> text
 		font-size 0.15px
-		fill isDark ? rgba(#fff, 0.6) : rgba(#000, 0.6)
-
-svg[data-darkmode]
-	root(true)
-
-svg:not([data-darkmode])
-	root(false)
+		fill var(--chartCaption)
 
 </style>
diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue
index d796a3ae05..62d75e2bf6 100644
--- a/src/client/app/common/views/widgets/server.vue
+++ b/src/client/app/common/views/widgets/server.vue
@@ -45,8 +45,7 @@ export default define({
 		return {
 			fetching: true,
 			meta: null,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	mounted() {
@@ -55,11 +54,10 @@ export default define({
 			this.fetching = false;
 		});
 
-		this.connection = (this as any).os.streams.serverStatsStream.getConnection();
-		this.connectionId = (this as any).os.streams.serverStatsStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('serverStats');
 	},
 	beforeDestroy() {
-		(this as any).os.streams.serverStatsStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		toggle() {
diff --git a/src/client/app/config.ts b/src/client/app/config.ts
index 74b9ea21c8..c3bc427eab 100644
--- a/src/client/app/config.ts
+++ b/src/client/app/config.ts
@@ -4,6 +4,7 @@ declare const _THEME_COLOR_: string;
 declare const _COPYRIGHT_: string;
 declare const _VERSION_: string;
 declare const _CODENAME_: string;
+declare const _ENV_: string;
 
 const address = new URL(location.href);
 
@@ -11,10 +12,11 @@ export const host = address.host;
 export const hostname = address.hostname;
 export const url = address.origin;
 export const apiUrl = url + '/api';
-export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://');
+export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
 export const lang = _LANG_;
 export const langs = _LANGS_;
 export const themeColor = _THEME_COLOR_;
 export const copyright = _COPYRIGHT_;
 export const version = _VERSION_;
 export const codename = _CODENAME_;
+export const env = _ENV_;
diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts
index e9d92d1eb1..f08e8a2b4e 100644
--- a/src/client/app/desktop/api/update-avatar.ts
+++ b/src/client/app/desktop/api/update-avatar.ts
@@ -16,7 +16,7 @@ export default (os: OS) => {
 					text: '%i18n:common.got-it%'
 				}]
 			});
-			reject();
+			return reject('invalid-filetype');
 		}
 
 		const w = os.new(CropWindow, {
diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts
index e8fa35149b..42c9d69349 100644
--- a/src/client/app/desktop/api/update-banner.ts
+++ b/src/client/app/desktop/api/update-banner.ts
@@ -16,7 +16,7 @@ export default (os: OS) => {
 					text: '%i18n:common.got-it%'
 				}]
 			});
-			reject();
+			return reject('invalid-filetype');
 		}
 
 		const w = os.new(CropWindow, {
diff --git a/src/client/app/desktop/assets/header-icon.light.svg b/src/client/app/desktop/assets/header-icon.light.svg
deleted file mode 100644
index 61e2026243..0000000000
--- a/src/client/app/desktop/assets/header-icon.light.svg
+++ /dev/null
@@ -1,150 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="512"
-   height="512"
-   viewBox="0 0 135.46667 135.46667"
-   version="1.1"
-   id="svg8"
-   inkscape:version="0.92.1 r15371"
-   sodipodi:docname="header-icon.light.svg"
-   inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png"
-   inkscape:export-xdpi="6"
-   inkscape:export-ydpi="6">
-  <defs
-     id="defs2">
-    <inkscape:path-effect
-       effect="simplify"
-       id="path-effect5115"
-       is_visible="true"
-       steps="1"
-       threshold="0.000408163"
-       smooth_angles="360"
-       helper_size="0"
-       simplify_individual_paths="false"
-       simplify_just_coalesce="false"
-       simplifyindividualpaths="false"
-       simplifyJustCoalesce="false" />
-    <inkscape:path-effect
-       effect="simplify"
-       id="path-effect5111"
-       is_visible="true"
-       steps="1"
-       threshold="0.000408163"
-       smooth_angles="360"
-       helper_size="0"
-       simplify_individual_paths="false"
-       simplify_just_coalesce="false"
-       simplifyindividualpaths="false"
-       simplifyJustCoalesce="false" />
-    <inkscape:path-effect
-       effect="simplify"
-       id="path-effect5104"
-       is_visible="true"
-       steps="1"
-       threshold="0.000408163"
-       smooth_angles="360"
-       helper_size="0"
-       simplify_individual_paths="false"
-       simplify_just_coalesce="false"
-       simplifyindividualpaths="false"
-       simplifyJustCoalesce="false" />
-  </defs>
-  <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="1.4142136"
-     inkscape:cx="114.309"
-     inkscape:cy="251.50613"
-     inkscape:document-units="px"
-     inkscape:current-layer="g4502"
-     showgrid="true"
-     units="px"
-     inkscape:snap-bbox="true"
-     inkscape:bbox-nodes="true"
-     inkscape:snap-bbox-edge-midpoints="false"
-     inkscape:snap-smooth-nodes="true"
-     inkscape:snap-center="true"
-     inkscape:snap-page="true"
-     inkscape:window-width="1920"
-     inkscape:window-height="1027"
-     inkscape:window-x="-8"
-     inkscape:window-y="1072"
-     inkscape:window-maximized="1"
-     inkscape:snap-object-midpoints="true"
-     inkscape:snap-midpoints="true"
-     inkscape:object-paths="true"
-     fit-margin-top="0"
-     fit-margin-left="0"
-     fit-margin-right="0"
-     fit-margin-bottom="0"
-     objecttolerance="1"
-     guidetolerance="1"
-     inkscape:snap-nodes="false"
-     inkscape:snap-others="false">
-    <inkscape:grid
-       type="xygrid"
-       id="grid4504"
-       spacingx="4.2333334"
-       spacingy="4.2333334"
-       empcolor="#ff3fff"
-       empopacity="0.25098039"
-       empspacing="4" />
-  </sodipodi:namedview>
-  <metadata
-     id="metadata5">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title />
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <g
-     inkscape:label="レイヤー 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(-30.809093,-111.78601)">
-    <g
-       id="g4502"
-       transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)">
-      <g
-         style="fill:#000000;fill-opacity:1"
-         transform="translate(-1.3333333e-6,-1.3439941e-6)"
-         id="g5125">
-        <g
-           transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)"
-           id="text4489"
-           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-           aria-label="Mi">
-          <path
-             sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz"
-             inkscape:connector-curvature="0"
-             id="path5210"
-             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:0.28950602px"
-             d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" />
-          <path
-             inkscape:connector-curvature="0"
-             id="path5212"
-             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:0.28950602px"
-             d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" />
-        </g>
-      </g>
-    </g>
-  </g>
-</svg>
diff --git a/src/client/app/desktop/assets/header-icon.dark.svg b/src/client/app/desktop/assets/header-icon.svg
similarity index 94%
rename from src/client/app/desktop/assets/header-icon.dark.svg
rename to src/client/app/desktop/assets/header-icon.svg
index fa42856fa5..d677d2d163 100644
--- a/src/client/app/desktop/assets/header-icon.dark.svg
+++ b/src/client/app/desktop/assets/header-icon.svg
@@ -124,24 +124,24 @@
        id="g4502"
        transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)">
       <g
-         style="fill:#ffffff;fill-opacity:1"
+         style="fill-opacity:1"
          transform="translate(-1.3333333e-6,-1.3439941e-6)"
          id="g5125">
         <g
            transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)"
            id="text4489"
-           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
            aria-label="Mi">
           <path
              sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz"
              inkscape:connector-curvature="0"
              id="path5210"
-             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#ffffff;fill-opacity:1;stroke-width:0.28950602px"
+             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px"
              d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" />
           <path
              inkscape:connector-curvature="0"
              id="path5212"
-             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#ffffff;fill-opacity:1;stroke-width:0.28950602px"
+             style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px"
              d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" />
         </g>
       </g>
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index f0e8a42662..85c81d73a2 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -6,11 +6,9 @@ import VueRouter from 'vue-router';
 
 // Style
 import './style.styl';
-import '../../element.scss';
 
 import init from '../init';
 import fuckAdBlock from '../common/scripts/fuck-ad-block';
-import { HomeStreamManager } from '../common/scripts/streaming/home';
 import composeNotification from '../common/scripts/compose-notification';
 
 import chooseDriveFolder from './api/choose-drive-folder';
@@ -30,7 +28,6 @@ import MkUser from './views/pages/user/user.vue';
 import MkFavorites from './views/pages/favorites.vue';
 import MkSelectDrive from './views/pages/selectdrive.vue';
 import MkDrive from './views/pages/drive.vue';
-import MkUserList from './views/pages/user-list.vue';
 import MkHomeCustomize from './views/pages/home-customize.vue';
 import MkMessagingRoom from './views/pages/messaging-room.vue';
 import MkNote from './views/pages/note.vue';
@@ -39,6 +36,7 @@ import MkTag from './views/pages/tag.vue';
 import MkReversi from './views/pages/games/reversi.vue';
 import MkShare from './views/pages/share.vue';
 import MkFollow from '../common/views/pages/follow.vue';
+import MiOS from '../mios';
 
 /**
  * init
@@ -64,7 +62,6 @@ init(async (launch) => {
 			{ path: '/i/messaging/:user', component: MkMessagingRoom },
 			{ path: '/i/drive', component: MkDrive },
 			{ path: '/i/drive/folder/:folder', component: MkDrive },
-			{ path: '/i/lists/:list', component: MkUserList },
 			{ path: '/selectdrive', component: MkSelectDrive },
 			{ path: '/search', component: MkSearch },
 			{ path: '/tags/:tag', component: MkTag },
@@ -88,10 +85,12 @@ init(async (launch) => {
 		updateBanner: updateBanner(os)
 	}));
 
-	/**
-	 * Fuck AD Block
-	 */
-	fuckAdBlock(os);
+	if (os.store.getters.isSignedIn) {
+		/**
+		 * Fuck AD Block
+		 */
+		fuckAdBlock(os);
+	}
 
 	/**
 	 * Init Notification
@@ -103,62 +102,56 @@ init(async (launch) => {
 		}
 
 		if ((Notification as any).permission == 'granted') {
-			registerNotifications(os.stream);
+			registerNotifications(os);
 		}
 	}
 }, true);
 
-function registerNotifications(stream: HomeStreamManager) {
+function registerNotifications(os: MiOS) {
+	const stream = os.stream;
+
 	if (stream == null) return;
 
-	if (stream.hasConnection) {
-		attach(stream.borrow());
-	}
+	const connection = stream.useSharedConnection('main');
 
-	stream.on('connected', connection => {
-		attach(connection);
+	connection.on('notification', notification => {
+		const _n = composeNotification('notification', notification);
+		const n = new Notification(_n.title, {
+			body: _n.body,
+			icon: _n.icon
+		});
+		setTimeout(n.close.bind(n), 6000);
 	});
 
-	function attach(connection) {
-		connection.on('notification', notification => {
-			const _n = composeNotification('notification', notification);
-			const n = new Notification(_n.title, {
-				body: _n.body,
-				icon: _n.icon
-			});
-			setTimeout(n.close.bind(n), 6000);
+	connection.on('driveFileCreated', file => {
+		const _n = composeNotification('driveFileCreated', file);
+		const n = new Notification(_n.title, {
+			body: _n.body,
+			icon: _n.icon
 		});
+		setTimeout(n.close.bind(n), 5000);
+	});
 
-		connection.on('drive_file_created', file => {
-			const _n = composeNotification('drive_file_created', file);
-			const n = new Notification(_n.title, {
-				body: _n.body,
-				icon: _n.icon
-			});
-			setTimeout(n.close.bind(n), 5000);
+	connection.on('unreadMessagingMessage', message => {
+		const _n = composeNotification('unreadMessagingMessage', message);
+		const n = new Notification(_n.title, {
+			body: _n.body,
+			icon: _n.icon
 		});
+		n.onclick = () => {
+			n.close();
+			/*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), {
+				user: message.user
+			});*/
+		};
+		setTimeout(n.close.bind(n), 7000);
+	});
 
-		connection.on('unread_messaging_message', message => {
-			const _n = composeNotification('unread_messaging_message', message);
-			const n = new Notification(_n.title, {
-				body: _n.body,
-				icon: _n.icon
-			});
-			n.onclick = () => {
-				n.close();
-				/*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), {
-					user: message.user
-				});*/
-			};
-			setTimeout(n.close.bind(n), 7000);
+	connection.on('reversiInvited', matching => {
+		const _n = composeNotification('reversiInvited', matching);
+		const n = new Notification(_n.title, {
+			body: _n.body,
+			icon: _n.icon
 		});
-
-		connection.on('reversi_invited', matching => {
-			const _n = composeNotification('reversi_invited', matching);
-			const n = new Notification(_n.title, {
-				body: _n.body,
-				icon: _n.icon
-			});
-		});
-	}
+	});
 }
diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl
index 3cd36482e4..96481a9808 100644
--- a/src/client/app/desktop/style.styl
+++ b/src/client/app/desktop/style.styl
@@ -1,8 +1,6 @@
 @import "../app"
 @import "../reset"
 
-@import "./ui"
-
 *::input-placeholder
 	color #D8CBC5
 
@@ -11,34 +9,21 @@
 
 html
 	height 100%
-	background #f7f7f7
+	background var(--bg)
 
 	&, *
 		&::-webkit-scrollbar
 			width 6px
 			height 6px
 
+		&::-webkit-scrollbar-track
+			background var(--scrollbarTrack)
+
 		&::-webkit-scrollbar-thumb
-			background rgba(0, 0, 0, 0.2)
+			background var(--scrollbarHandle)
 
 			&:hover
-				background rgba(0, 0, 0, 0.4)
+				background var(--scrollbarHandleHover)
 
 			&:active
-				background $theme-color
-
-	&[data-darkmode]
-		background #191B22
-
-		&, *
-			&::-webkit-scrollbar-track
-				background-color #282C37
-
-			&::-webkit-scrollbar-thumb
-				background-color #454954
-
-				&:hover
-					background-color #535660
-
-				&:active
-					background-color $theme-color
+				background var(--primary)
diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl
deleted file mode 100644
index b66c8f4025..0000000000
--- a/src/client/app/desktop/ui.styl
+++ /dev/null
@@ -1,181 +0,0 @@
-@import "../../const"
-
-button
-	font-family sans-serif
-
-	*
-		pointer-events none
-
-button.ui
-.button.ui
-	display inline-block
-	cursor pointer
-	padding 0 14px
-	margin 0
-	min-width 100px
-	line-height 38px
-	font-size 14px
-	color #888
-	text-decoration none
-	background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
-	border solid 1px #e2e2e2
-	border-radius 4px
-	outline none
-
-	&.block
-		display block
-
-	&:focus
-		&:after
-			content ""
-			pointer-events none
-			position absolute
-			top -5px
-			right -5px
-			bottom -5px
-			left -5px
-			border 2px solid rgba($theme-color, 0.3)
-			border-radius 8px
-
-	&:disabled
-		opacity 0.7
-		cursor default
-
-	&:hover
-		background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
-		border-color #dcdcdc
-
-	&:active
-		background #ececec
-		border-color #dcdcdc
-
-	&.primary
-		color $theme-color-foreground
-		background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-		border solid 1px lighten($theme-color, 15%)
-
-		&:not(:disabled)
-			font-weight bold
-
-		&:hover:not(:disabled)
-			background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-			border-color $theme-color
-
-		&:active:not(:disabled)
-			background $theme-color
-			border-color $theme-color
-
-input:not([type]).ui
-input[type='text'].ui
-input[type='password'].ui
-input[type='email'].ui
-input[type='date'].ui
-input[type='number'].ui
-textarea.ui
-	display block
-	padding 10px
-	width 100%
-	height 40px
-	font-family sans-serif
-	font-size 16px
-	color #55595c
-	border solid 1px #dadada
-	border-radius 4px
-
-	&:hover
-		border-color #b0b0b0
-
-	&:focus
-		border-color $theme-color
-
-textarea.ui
-	min-width 100%
-	max-width 100%
-	min-height 64px
-
-.ui.info
-	display block
-	margin 1em 0
-	padding 0 1em
-	font-size 90%
-	color rgba(#000, 0.87)
-	background #f8f8f9
-	border solid 1px rgba(34, 36, 38, 0.22)
-	border-radius 4px
-
-	> p
-		opacity 0.8
-
-		> [data-fa]:first-child
-			margin-right 0.25em
-
-	&.warn
-		color #573a08
-		background #FFFAF3
-		border-color #C9BA9B
-
-.ui.from.group
-	display block
-	margin 16px 0
-
-	> p:first-child
-		margin 0 0 6px 0
-		font-size 90%
-		font-weight bold
-		color rgba(#373a3c, 0.9)
-
-html[data-darkmode]
-	button.ui
-	.button.ui
-		color #fff
-		background linear-gradient(to bottom, #313543 0%, #282c37 100%)
-		border-color #1c2023
-
-		&:hover
-			background linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%)
-			border-color #151a1d
-
-		&:active
-			background #22262f
-			border-color #151a1d
-
-		&.primary
-			color $theme-color-foreground
-			background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-			border solid 1px lighten($theme-color, 15%)
-
-			&:hover:not(:disabled)
-				background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-				border-color $theme-color
-
-			&:active:not(:disabled)
-				background $theme-color
-				border-color $theme-color
-
-	input:not([type]).ui
-	input[type='text'].ui
-	input[type='password'].ui
-	input[type='email'].ui
-	input[type='date'].ui
-	input[type='number'].ui
-	textarea.ui
-		display block
-		padding 10px
-		width 100%
-		height 40px
-		font-family sans-serif
-		font-size 16px
-		color #dee4e8
-		background #191b22
-		border solid 1px #495156
-		border-radius 4px
-
-		&:hover
-			border-color #b0b0b0
-
-		&:focus
-			border-color $theme-color
-
-	.ui.from.group
-		> p:first-child
-			color #c0c7cc
diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue
index de9650b21b..e2f1329b3b 100644
--- a/src/client/app/desktop/views/components/calendar.vue
+++ b/src/client/app/desktop/views/components/calendar.vue
@@ -128,13 +128,11 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	color isDark ? #c5ced6 : #777
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.mk-calendar
+	color var(--calendarDay)
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 	overflow hidden
 
 	&[data-melt]
@@ -149,12 +147,10 @@ root(isDark)
 		line-height 42px
 		font-size 0.9em
 		font-weight bold
-		color isDark ? #c5ced6 : #888
+		color var(--faceHeaderText)
+		background var(--faceHeader)
 		box-shadow 0 1px rgba(#000, 0.07)
 
-		if isDark
-			background #313543
-
 		> [data-fa]
 			margin-right 4px
 
@@ -166,13 +162,13 @@ root(isDark)
 		width 42px
 		font-size 0.9em
 		line-height 42px
-		color isDark ? #9baec8 : #ccc
+		color var(--faceTextButton)
 
 		&:hover
-			color isDark ? #b2c1d5 : #aaa
+			color var(--faceTextButtonHover)
 
 		&:active
-			color isDark ? #b2c1d5 : #999
+			color var(--faceTextButtonActive)
 
 		&:first-of-type
 			left 0
@@ -195,65 +191,56 @@ root(isDark)
 			font-size 14px
 
 			&.weekday
-				color isDark ? #43d5dc : #19a2a9
+				color var(--calendarWeek)
 
 				&[data-is-donichi]
-					color isDark ? #ff6679 : #ef95a0
+					color var(--calendarSaturdayOrSunday)
 
 				&[data-today]
-					box-shadow 0 0 0 1px isDark ? #43d5dc : #19a2a9 inset
+					box-shadow 0 0 0 1px var(--calendarWeek) inset
 					border-radius 6px
 
 					&[data-is-donichi]
-						box-shadow 0 0 0 1px isDark ? #ff6679 : #ef95a0 inset
+						box-shadow 0 0 0 1px var(--calendarSaturdayOrSunday) inset
 
 			&.day
 				cursor pointer
-				color isDark ? #c5ced6 : #777
+				color var(--calendarDay)
 
 				> div
 					border-radius 6px
 
 				&:hover > div
-					background rgba(#000, isDark ? 0.1 : 0.025)
+					background var(--faceClearButtonHover)
 
 				&:active > div
-					background rgba(#000, isDark ? 0.2 : 0.05)
+					background var(--faceClearButtonActive)
 
 				&[data-is-donichi]
-					color isDark ? #ff6679 : #ef95a0
+					color var(--calendarSaturdayOrSunday)
 
 				&[data-is-out-of-range]
 					cursor default
-					color rgba(isDark ? #c5ced6 : #777, 0.5)
-
-					&[data-is-donichi]
-						color rgba(isDark ? #ff6679 : #ef95a0, 0.5)
+					opacity 0.5
 
 				&[data-selected]
 					font-weight bold
 
 					> div
-						background rgba(#000, isDark ? 0.1 : 0.025)
+						background var(--faceClearButtonHover)
 
 					&:active > div
-						background rgba(#000, isDark ? 0.2 : 0.05)
+						background var(--faceClearButtonActive)
 
 				&[data-today]
 					> div
-						color $theme-color-foreground
-						background $theme-color
+						color var(--primaryForeground)
+						background var(--primary)
 
 					&:hover > div
-						background lighten($theme-color, 10%)
+						background var(--primaryLighten10)
 
 					&:active > div
-						background darken($theme-color, 10%)
-
-.mk-calendar[data-darkmode]
-	root(true)
-
-.mk-calendar:not([data-darkmode])
-	root(false)
+						background var(--primaryDarken10)
 
 </style>
diff --git a/src/client/app/desktop/views/components/charts.vue b/src/client/app/desktop/views/components/charts.vue
index c4e92e429f..6d6f3a3596 100644
--- a/src/client/app/desktop/views/components/charts.vue
+++ b/src/client/app/desktop/views/components/charts.vue
@@ -19,6 +19,11 @@
 				<option value="drive">%i18n:@charts.drive%</option>
 				<option value="drive-total">%i18n:@charts.drive-total%</option>
 			</optgroup>
+			<optgroup label="%i18n:@network%">
+				<option value="network-requests">%i18n:@charts.network-requests%</option>
+				<option value="network-time">%i18n:@charts.network-time%</option>
+				<option value="network-usage">%i18n:@charts.network-usage%</option>
+			</optgroup>
 		</select>
 		<div>
 			<span @click="span = 'day'" :class="{ active: span == 'day' }">%i18n:@per-day%</span> | <span @click="span = 'hour'" :class="{ active: span == 'hour' }">%i18n:@per-hour%</span>
@@ -41,7 +46,10 @@ const colors = {
 	localPlus: 'rgb(52, 178, 118)',
 	remotePlus: 'rgb(158, 255, 209)',
 	localMinus: 'rgb(255, 97, 74)',
-	remoteMinus: 'rgb(255, 149, 134)'
+	remoteMinus: 'rgb(255, 149, 134)',
+
+	incoming: 'rgb(52, 178, 118)',
+	outgoing: 'rgb(255, 97, 74)',
 };
 
 const rgba = (color: string): string => {
@@ -75,6 +83,9 @@ export default Vue.extend({
 				case 'drive-total': return this.driveTotalChart();
 				case 'drive-files': return this.driveFilesChart();
 				case 'drive-files-total': return this.driveFilesTotalChart();
+				case 'network-requests': return this.networkRequestsChart();
+				case 'network-time': return this.networkTimeChart();
+				case 'network-usage': return this.networkUsageChart();
 			}
 		},
 
@@ -89,7 +100,7 @@ export default Vue.extend({
 
 	created() {
 		(this as any).api('chart', {
-			limit: 32
+			limit: 35
 		}).then(chart => {
 			this.chart = chart;
 		});
@@ -544,13 +555,101 @@ export default Vue.extend({
 					}
 				}
 			}];
-		}
+		},
+
+		networkRequestsChart(): any {
+			const data = this.stats.slice().reverse().map(x => ({
+				date: new Date(x.date),
+				requests: x.network.requests
+			}));
+
+			return [{
+				datasets: [{
+					label: 'Requests',
+					fill: true,
+					backgroundColor: rgba(colors.localPlus),
+					borderColor: colors.localPlus,
+					borderWidth: 2,
+					pointBackgroundColor: '#fff',
+					lineTension: 0,
+					data: data.map(x => ({ t: x.date, y: x.requests }))
+				}]
+			}];
+		},
+
+		networkTimeChart(): any {
+			const data = this.stats.slice().reverse().map(x => ({
+				date: new Date(x.date),
+				time: x.network.requests != 0 ? (x.network.totalTime / x.network.requests) : 0,
+			}));
+
+			return [{
+				datasets: [{
+					label: 'Avg time (ms)',
+					fill: true,
+					backgroundColor: rgba(colors.localPlus),
+					borderColor: colors.localPlus,
+					borderWidth: 2,
+					pointBackgroundColor: '#fff',
+					lineTension: 0,
+					data: data.map(x => ({ t: x.date, y: x.time }))
+				}]
+			}];
+		},
+
+		networkUsageChart(): any {
+			const data = this.stats.slice().reverse().map(x => ({
+				date: new Date(x.date),
+				incoming: x.network.incomingBytes,
+				outgoing: x.network.outgoingBytes
+			}));
+
+			return [{
+				datasets: [{
+					label: 'Incoming',
+					fill: true,
+					backgroundColor: rgba(colors.incoming),
+					borderColor: colors.incoming,
+					borderWidth: 2,
+					pointBackgroundColor: '#fff',
+					lineTension: 0,
+					data: data.map(x => ({ t: x.date, y: x.incoming }))
+				}, {
+					label: 'Outgoing',
+					fill: true,
+					backgroundColor: rgba(colors.outgoing),
+					borderColor: colors.outgoing,
+					borderWidth: 2,
+					pointBackgroundColor: '#fff',
+					lineTension: 0,
+					data: data.map(x => ({ t: x.date, y: x.outgoing }))
+				}]
+			}, {
+				scales: {
+					yAxes: [{
+						ticks: {
+							callback: value => {
+								return Vue.filter('bytes')(value, 1);
+							}
+						}
+					}]
+				},
+				tooltips: {
+					callbacks: {
+						label: (tooltipItem, data) => {
+							const label = data.datasets[tooltipItem.datasetIndex].label || '';
+							return `${label}: ${Vue.filter('bytes')(tooltipItem.yLabel, 1)}`;
+						}
+					}
+				}
+			}];
+		},
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .gkgckalzgidaygcxnugepioremxvxvpt
 	padding 32px
@@ -576,12 +675,12 @@ export default Vue.extend({
 
 			*
 				&:not(.active)
-					color $theme-color
+					color var(--primary)
 					cursor pointer
 
 	> div
 		> *
 			display block
-			height 320px
+			height 350px
 
 </style>
diff --git a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue
index b894f0e109..806f7f5c3f 100644
--- a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue
+++ b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy">
+<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom">
 	<span slot="header">
 		<span v-html="title" :class="$style.title"></span>
 		<span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}%i18n:@choose-file%)</span>
@@ -59,7 +59,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .title
 	> [data-fa]
@@ -74,7 +74,7 @@ export default Vue.extend({
 
 .footer
 	height 72px
-	background lighten($theme-color, 95%)
+	background var(--primaryLighten95)
 
 .upload
 	display inline-block
@@ -87,7 +87,7 @@ export default Vue.extend({
 	width 40px
 	height 40px
 	font-size 1em
-	color rgba($theme-color, 0.5)
+	color var(--primaryAlpha05)
 	background transparent
 	outline none
 	border solid 1px transparent
@@ -95,13 +95,13 @@ export default Vue.extend({
 
 	&:hover
 		background transparent
-		border-color rgba($theme-color, 0.3)
+		border-color var(--primaryAlpha03)
 
 	&:active
-		color rgba($theme-color, 0.6)
+		color var(--primaryAlpha06)
 		background transparent
-		border-color rgba($theme-color, 0.5)
-		box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset
+		border-color var(--primaryAlpha05)
+		//box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset
 
 	&:focus
 		&:after
@@ -112,7 +112,7 @@ export default Vue.extend({
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 .ok
@@ -138,7 +138,7 @@ export default Vue.extend({
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 	&:disabled
@@ -147,20 +147,20 @@ export default Vue.extend({
 
 .ok
 	right 16px
-	color $theme-color-foreground
-	background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-	border solid 1px lighten($theme-color, 15%)
+	color var(--primaryForeground)
+	background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%)
+	border solid 1px var(--primaryLighten15)
 
 	&:not(:disabled)
 		font-weight bold
 
 	&:hover:not(:disabled)
-		background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-		border-color $theme-color
+		background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%)
+		border-color var(--primary)
 
 	&:active:not(:disabled)
-		background $theme-color
-		border-color $theme-color
+		background var(--primary)
+		border-color var(--primary)
 
 .cancel
 	right 148px
diff --git a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue
index 0c4643fdcb..b970218e58 100644
--- a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue
+++ b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy">
+<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom">
 	<span slot="header">
 		<span v-html="title" :class="$style.title"></span>
 	</span>
@@ -37,7 +37,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .title
 	> [data-fa]
@@ -48,7 +48,7 @@ export default Vue.extend({
 
 .footer
 	height 72px
-	background lighten($theme-color, 95%)
+	background var(--primaryLighten95)
 
 .ok
 .cancel
@@ -73,7 +73,7 @@ export default Vue.extend({
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 	&:disabled
@@ -82,20 +82,20 @@ export default Vue.extend({
 
 .ok
 	right 16px
-	color $theme-color-foreground
-	background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-	border solid 1px lighten($theme-color, 15%)
+	color var(--primaryForeground)
+	background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%)
+	border solid 1px var(--primaryLighten15)
 
 	&:not(:disabled)
 		font-weight bold
 
 	&:hover:not(:disabled)
-		background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-		border-color $theme-color
+		background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%)
+		border-color var(--primary)
 
 	&:active:not(:disabled)
-		background $theme-color
-		border-color $theme-color
+		background var(--primary)
+		border-color var(--primary)
 
 .cancel
 	right 148px
diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue
index e7deec675e..9e4541a752 100644
--- a/src/client/app/desktop/views/components/context-menu.menu.vue
+++ b/src/client/app/desktop/views/components/context-menu.menu.vue
@@ -31,9 +31,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.menu
 	$width = 240px
 	$item-height = 38px
 	$padding = 10px
@@ -48,7 +46,7 @@ root(isDark)
 		&.divider
 			margin-top $padding
 			padding-top $padding
-			border-top solid 1px isDark ? #1c2023 : #eee
+			border-top solid 1px var(--faceDivider)
 
 		&.nest
 			> p
@@ -69,7 +67,7 @@ root(isDark)
 
 			&:active
 				> p, a
-					background $theme-color
+					background var(--primary)
 
 		> p, a
 			display block
@@ -77,7 +75,7 @@ root(isDark)
 			margin 0
 			padding 0 32px 0 38px
 			line-height $item-height
-			color isDark ? #c8cece : #868C8C
+			color var(--text)
 			text-decoration none
 			cursor pointer
 
@@ -90,14 +88,14 @@ root(isDark)
 		&:hover
 			> p, a
 				text-decoration none
-				background $theme-color
-				color $theme-color-foreground
+				background var(--primary)
+				color var(--primaryForeground)
 
 		&:active
 			> p, a
 				text-decoration none
-				background darken($theme-color, 10%)
-				color $theme-color-foreground
+				background var(--primaryDarken10)
+				color var(--primaryForeground)
 
 	li > ul
 		visibility hidden
@@ -106,17 +104,11 @@ root(isDark)
 		left $width
 		margin-top -($padding)
 		width $width
-		background isDark ? #282c37 :#fff
+		background var(--popupBg)
 		border-radius 0 4px 4px 4px
 		box-shadow 2px 2px 8px rgba(#000, 0.2)
 		transition visibility 0s linear 0.2s
 
-.menu[data-darkmode]
-	root(true)
-
-.menu:not([data-darkmode])
-	root(false)
-
 </style>
 
 <style lang="stylus" module>
diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue
index afb6838eb6..b0a34866cd 100644
--- a/src/client/app/desktop/views/components/context-menu.vue
+++ b/src/client/app/desktop/views/components/context-menu.vue
@@ -64,14 +64,14 @@ export default Vue.extend({
 			});
 
 			this.$emit('closed');
-			this.$destroy();
+			this.destroyDom();
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.context-menu
 	$width = 240px
 	$item-height = 38px
 	$padding = 10px
@@ -82,15 +82,9 @@ root(isDark)
 	z-index 4096
 	width $width
 	font-size 0.8em
-	background isDark ? #282c37 : #fff
+	background var(--popupBg)
 	border-radius 0 4px 4px 4px
 	box-shadow 2px 2px 8px rgba(#000, 0.2)
 	opacity 0
 
-.context-menu[data-darkmode]
-	root(true)
-
-.context-menu:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/crop-window.vue b/src/client/app/desktop/views/components/crop-window.vue
index 4fa258549f..629c3b013a 100644
--- a/src/client/app/desktop/views/components/crop-window.vue
+++ b/src/client/app/desktop/views/components/crop-window.vue
@@ -61,7 +61,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .header
 	> [data-fa]
@@ -73,7 +73,7 @@ export default Vue.extend({
 
 .actions
 	height 72px
-	background lighten($theme-color, 95%)
+	background var(--primaryLighten95)
 
 .ok
 .cancel
@@ -98,7 +98,7 @@ export default Vue.extend({
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 	&:disabled
@@ -111,20 +111,20 @@ export default Vue.extend({
 
 .ok
 	right 16px
-	color $theme-color-foreground
-	background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-	border solid 1px lighten($theme-color, 15%)
+	color var(--primaryForeground)
+	background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%)
+	border solid 1px var(--primaryLighten15)
 
 	&:not(:disabled)
 		font-weight bold
 
 	&:hover:not(:disabled)
-		background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-		border-color $theme-color
+		background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%)
+		border-color var(--primary)
 
 	&:active:not(:disabled)
-		background $theme-color
-		border-color $theme-color
+		background var(--primary)
+		border-color var(--primary)
 
 .cancel
 .skip
@@ -155,11 +155,11 @@ export default Vue.extend({
 }
 
 .cropper-view-box {
-	outline-color: $theme-color;
+	outline-color: var(--primary);
 }
 
 .cropper-line, .cropper-point {
-	background-color: $theme-color;
+	background-color: var(--primary);
 }
 
 .cropper-bg {
diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue
index aff21c1754..baa6f911fe 100644
--- a/src/client/app/desktop/views/components/dialog.vue
+++ b/src/client/app/desktop/views/components/dialog.vue
@@ -78,7 +78,7 @@ export default Vue.extend({
 				scale: 0.8,
 				duration: 300,
 				easing: [ 0.5, -0.5, 1, 0.5 ],
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		},
 		onBgClick() {
@@ -91,7 +91,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-dialog
 	> .bg
@@ -144,20 +144,20 @@ export default Vue.extend({
 					margin 0 0.375em
 
 				&:hover
-					color $theme-color
+					color var(--primary)
 
 				&:active
-					color darken($theme-color, 10%)
+					color var(--primaryDarken10)
 					transition color 0s ease
 
 </style>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .header
 	margin 1em 0
-	color $theme-color
+	color var(--primary)
 	// color #43A4EC
 	font-weight bold
 
diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue
index 1f45b64324..191579538d 100644
--- a/src/client/app/desktop/views/components/drive-window.vue
+++ b/src/client/app/desktop/views/components/drive-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout">
+<mk-window ref="window" @closed="destroyDom" width="800px" height="500px" :popout-url="popout">
 	<template slot="header">
 		<p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:@used%</p>
 		<span :class="$style.title">%fa:cloud%%i18n:@drive%</span>
diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue
index 3ac8923c51..d7e24cfe71 100644
--- a/src/client/app/desktop/views/components/drive.file.vue
+++ b/src/client/app/desktop/views/components/drive.file.vue
@@ -200,9 +200,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.gvfdktuvdgwhmztnuekzkswkjygptfcv
 	padding 8px 0 0 0
 	height 180px
 	border-radius 4px
@@ -237,13 +235,13 @@ root(isDark)
 					background #ce2212
 
 	&[data-is-selected]
-		background $theme-color
+		background var(--primary)
 
 		&:hover
-			background lighten($theme-color, 10%)
+			background var(--primaryLighten10)
 
 		&:active
-			background darken($theme-color, 10%)
+			background var(--primaryDarken10)
 
 		> .label
 			&:before
@@ -251,7 +249,7 @@ root(isDark)
 				display none
 
 		> .name
-			color $theme-color-foreground
+			color var(--primaryForeground)
 
 	&[data-is-contextmenu-showing]
 		&:after
@@ -262,7 +260,7 @@ root(isDark)
 			right -4px
 			bottom -4px
 			left -4px
-			border 2px dashed rgba($theme-color, 0.3)
+			border 2px dashed var(--primaryAlpha03)
 			border-radius 4px
 
 	> .label
@@ -337,16 +335,10 @@ root(isDark)
 		font-size 0.8em
 		text-align center
 		word-break break-all
-		color isDark ? #fff : #444
+		color var(--text)
 		overflow hidden
 
 		> .ext
 			opacity 0.5
 
-.gvfdktuvdgwhmztnuekzkswkjygptfcv[data-darkmode]
-	root(true)
-
-.gvfdktuvdgwhmztnuekzkswkjygptfcv:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue
index 83880fef5c..cfc2b64ff4 100644
--- a/src/client/app/desktop/views/components/drive.folder.vue
+++ b/src/client/app/desktop/views/components/drive.folder.vue
@@ -163,7 +163,7 @@ export default Vue.extend({
 							});
 							break;
 						default:
-							alert('%i18n:@unhandled-error% ' + err);
+							alert(`%i18n:@unhandled-error% ${err}`);
 					}
 				});
 			}
@@ -214,12 +214,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.ynntpczxvnusfwdyxsfuhvcmuypqopdd
 	padding 8px
 	height 64px
-	background isDark ? rgba($theme-color, 0.2) : lighten($theme-color, 95%)
+	background var(--desktopDriveFolderBg)
 	border-radius 4px
 
 	&, *
@@ -229,10 +227,10 @@ root(isDark)
 		pointer-events none
 
 	&:hover
-		background isDark ? rgba(lighten($theme-color, 10%), 0.2) : lighten($theme-color, 90%)
+		background var(--desktopDriveFolderHoverBg)
 
 	&:active
-		background isDark ? rgba(darken($theme-color, 10%), 0.2) : lighten($theme-color, 85%)
+		background var(--desktopDriveFolderActiveBg)
 
 	&[data-is-contextmenu-showing]
 	&[data-draghover]
@@ -244,26 +242,20 @@ root(isDark)
 			right -4px
 			bottom -4px
 			left -4px
-			border 2px dashed rgba($theme-color, 0.3)
+			border 2px dashed var(--primaryAlpha03)
 			border-radius 4px
 
 	&[data-draghover]
-		background isDark ? rgba(darken($theme-color, 10%), 0.2) : lighten($theme-color, 90%)
+		background var(--desktopDriveFolderActiveBg)
 
 	> .name
 		margin 0
 		font-size 0.9em
-		color isDark ? #fff : darken($theme-color, 30%)
+		color var(--desktopDriveFolderFg)
 
 		> [data-fa]
 			margin-right 4px
 			margin-left 2px
 			text-align left
 
-.ynntpczxvnusfwdyxsfuhvcmuypqopdd[data-darkmode]
-	root(true)
-
-.ynntpczxvnusfwdyxsfuhvcmuypqopdd:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue
index d919e4a5ea..1376a04d99 100644
--- a/src/client/app/desktop/views/components/drive.vue
+++ b/src/client/app/desktop/views/components/drive.vue
@@ -98,8 +98,7 @@ export default Vue.extend({
 			hierarchyFolders: [],
 			selectedFiles: [],
 			uploadings: [],
-			connection: null,
-			connectionId: null,
+			connection: null
 
 			/**
 			 * ドロップされようとしているか
@@ -116,8 +115,7 @@ export default Vue.extend({
 		};
 	},
 	mounted() {
-		this.connection = (this as any).os.streams.driveStream.getConnection();
-		this.connectionId = (this as any).os.streams.driveStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('drive');
 
 		this.connection.on('file_created', this.onStreamDriveFileCreated);
 		this.connection.on('file_updated', this.onStreamDriveFileUpdated);
@@ -132,12 +130,7 @@ export default Vue.extend({
 		}
 	},
 	beforeDestroy() {
-		this.connection.off('file_created', this.onStreamDriveFileCreated);
-		this.connection.off('file_updated', this.onStreamDriveFileUpdated);
-		this.connection.off('file_deleted', this.onStreamDriveFileDeleted);
-		this.connection.off('folder_created', this.onStreamDriveFolderCreated);
-		this.connection.off('folder_updated', this.onStreamDriveFolderUpdated);
-		(this as any).os.streams.driveStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		onContextmenu(e) {
@@ -323,7 +316,7 @@ export default Vue.extend({
 							});
 							break;
 						default:
-							alert('%i18n:@unhandled-error% ' + err);
+							alert(`%i18n:@unhandled-error% ${err}`);
 					}
 				});
 			}
@@ -404,7 +397,7 @@ export default Vue.extend({
 					folder: folder
 				});
 			} else {
-				window.open(url + '/i/drive/folder/' + folder.id,
+				window.open(`${url}/i/drive/folder/${folder.id}`,
 					'drive_window',
 					'height=500, width=800');
 			}
@@ -585,18 +578,15 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-
+.mk-drive
 	> nav
 		display block
 		z-index 2
 		width 100%
 		overflow auto
 		font-size 0.9em
-		color isDark ? #d2d9dc : #555
-		background isDark ? #282c37 : #fff
+		color var(--text)
+		background var(--face)
 		box-shadow 0 1px 0 rgba(#000, 0.05)
 
 		&, *
@@ -674,7 +664,7 @@ root(isDark)
 		padding 8px
 		height calc(100% - 38px)
 		overflow auto
-		background isDark ? #191b22 : #fff
+		background var(--desktopDriveBg)
 
 		&, *
 			user-select none
@@ -697,8 +687,8 @@ root(isDark)
 			z-index 128
 			top 0
 			left 0
-			border solid 1px $theme-color
-			background rgba($theme-color, 0.5)
+			border solid 1px var(--primary)
+			background var(--primaryAlpha05)
 			pointer-events none
 
 		> .contents
@@ -769,7 +759,7 @@ root(isDark)
 		top 38px
 		width 100%
 		height calc(100% - 38px)
-		border dashed 2px rgba($theme-color, 0.5)
+		border dashed 2px var(--primaryAlpha05)
 		pointer-events none
 
 	> .mk-uploader
@@ -780,10 +770,4 @@ root(isDark)
 	> input
 		display none
 
-.mk-drive[data-darkmode]
-	root(true)
-
-.mk-drive:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue
index 62742a8f39..4d3d61dfe0 100644
--- a/src/client/app/desktop/views/components/follow-button.vue
+++ b/src/client/app/desktop/views/components/follow-button.vue
@@ -5,7 +5,8 @@
 	:disabled="wait"
 >
 	<template v-if="!wait">
-		<template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half%<template v-if="size == 'big'"> %i18n:@request-pending%</template></template>
+		<template v-if="u.hasPendingFollowRequestFromYou && u.isLocked">%fa:hourglass-half%<template v-if="size == 'big'"> %i18n:@request-pending%</template></template>
+		<template v-else-if="u.hasPendingFollowRequestFromYou && !u.isLocked">%fa:hourglass-start%<template v-if="size == 'big'"> %i18n:@follow-processing%</template></template>
 		<template v-else-if="u.isFollowing">%fa:minus%<template v-if="size == 'big'"> %i18n:@following%</template></template>
 		<template v-else-if="!u.isFollowing && u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow-request%</template></template>
 		<template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow%</template></template>
@@ -33,35 +34,32 @@ export default Vue.extend({
 		return {
 			u: this.user,
 			wait: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
-
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 		this.connection.on('follow', this.onFollow);
 		this.connection.on('unfollow', this.onUnfollow);
 	},
 
 	beforeDestroy() {
-		this.connection.off('follow', this.onFollow);
-		this.connection.off('unfollow', this.onUnfollow);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 
 	methods: {
 		onFollow(user) {
 			if (user.id == this.u.id) {
-				this.user.isFollowing = user.isFollowing;
+				this.u.isFollowing = user.isFollowing;
+				this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
 			}
 		},
 
 		onUnfollow(user) {
 			if (user.id == this.u.id) {
-				this.user.isFollowing = user.isFollowing;
+				this.u.isFollowing = user.isFollowing;
+				this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
 			}
 		},
 
@@ -74,7 +72,7 @@ export default Vue.extend({
 						userId: this.u.id
 					});
 				} else {
-					if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) {
+					if (this.u.hasPendingFollowRequestFromYou) {
 						this.u = await (this as any).api('following/requests/cancel', {
 							userId: this.u.id
 						});
@@ -99,9 +97,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-follow-button
 	display block
 	cursor pointer
 	padding 0
@@ -124,37 +120,34 @@ root(isDark)
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 	&:not(.active)
-		color isDark ? #fff : #888
-		background isDark ? linear-gradient(to bottom, #313543 0%, #282c37 100%) : linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
-		border solid 1px isDark ? #1c2023 : #e2e2e2
+		color var(--primary)
+		border solid 1px var(--primary)
 
 		&:hover
-			background isDark ? linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%) : linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
-			border-color isDark ? #151a1d : #dcdcdc
+			background var(--primaryAlpha03)
 
 		&:active
-			background isDark ? #22262f : #ececec
-			border-color isDark ? #151a1d : #dcdcdc
+			background var(--primaryAlpha05)
 
 	&.active
-		color $theme-color-foreground
-		background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-		border solid 1px lighten($theme-color, 15%)
+		color var(--primaryForeground)
+		background var(--primary)
+		border solid 1px var(--primary)
 
 		&:not(:disabled)
 			font-weight bold
 
 		&:hover:not(:disabled)
-			background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-			border-color $theme-color
+			background var(--primaryLighten5)
+			border-color var(--primaryLighten5)
 
 		&:active:not(:disabled)
-			background $theme-color
-			border-color $theme-color
+			background var(--primaryDarken5)
+			border-color var(--primaryDarken5)
 
 	&.wait
 		cursor wait !important
@@ -165,10 +158,4 @@ root(isDark)
 		height 38px
 		line-height 38px
 
-.mk-follow-button[data-darkmode]
-	root(true)
-
-.mk-follow-button:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/followers-window.vue b/src/client/app/desktop/views/components/followers-window.vue
index fdab7bc1ce..d5214adb2f 100644
--- a/src/client/app/desktop/views/components/followers-window.vue
+++ b/src/client/app/desktop/views/components/followers-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window width="400px" height="550px" @closed="$destroy">
+<mk-window width="400px" height="550px" @closed="destroyDom">
 	<span slot="header" :class="$style.header">
 		<img :src="user.avatarUrl" alt=""/>{{ '%i18n:@followers%'.replace('{}', name) }}
 	</span>
diff --git a/src/client/app/desktop/views/components/following-window.vue b/src/client/app/desktop/views/components/following-window.vue
index 7cca833a82..aa9f2bde7b 100644
--- a/src/client/app/desktop/views/components/following-window.vue
+++ b/src/client/app/desktop/views/components/following-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window width="400px" height="550px" @closed="$destroy">
+<mk-window width="400px" height="550px" @closed="destroyDom">
 	<span slot="header" :class="$style.header">
 		<img :src="user.avatarUrl" alt=""/>{{ '%i18n:@following%'.replace('{}', name) }}
 	</span>
diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue
index 7dfd9e4359..4e8a212b00 100644
--- a/src/client/app/desktop/views/components/friends-maker.vue
+++ b/src/client/app/desktop/views/components/friends-maker.vue
@@ -14,7 +14,7 @@
 	<p class="empty" v-if="!fetching && users.length == 0">%i18n:@empty%</p>
 	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@fetching%<mk-ellipsis/></p>
 	<a class="refresh" @click="refresh">%i18n:@refresh%</a>
-	<button class="close" @click="$destroy()" title="%i18n:@close%">%fa:times%</button>
+	<button class="close" @click="destroyDom()" title="%i18n:@close%">%fa:times%</button>
 </div>
 </template>
 
diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue
index 7c6cb9cd40..594eae58f8 100644
--- a/src/client/app/desktop/views/components/game-window.vue
+++ b/src/client/app/desktop/views/components/game-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy">
+<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom">
 	<span slot="header" :class="$style.header">%fa:gamepad%%i18n:@game%</span>
 	<mk-reversi :class="$style.content" @gamed="g => game = g"/>
 </mk-window>
diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue
index d45cc82e13..9008e26263 100644
--- a/src/client/app/desktop/views/components/home.vue
+++ b/src/client/app/desktop/views/components/home.vue
@@ -141,7 +141,6 @@ export default Vue.extend({
 	data() {
 		return {
 			connection: null,
-			connectionId: null,
 			widgetAdderSelected: null,
 			trash: []
 		};
@@ -176,12 +175,11 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 	},
 
 	beforeDestroy() {
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 
 	methods: {
@@ -237,15 +235,17 @@ export default Vue.extend({
 
 		warp(date) {
 			(this.$refs.tl as any).warp(date);
+		},
+
+		focus() {
+			(this.$refs.tl as any).focus();
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-home
 	display block
 
 	&[data-customize]
@@ -275,8 +275,8 @@ root(isDark)
 		left 0
 		width 100%
 		height 48px
-		color isDark ? #fff : #000
-		background isDark ? #313543 : #f7f7f7
+		color var(--text)
+		background var(--desktopHeaderBg)
 		box-shadow 0 1px 1px rgba(#000, 0.075)
 
 		> a
@@ -288,15 +288,15 @@ root(isDark)
 			padding 0 16px
 			line-height 48px
 			text-decoration none
-			color $theme-color-foreground
-			background $theme-color
+			color var(--primaryForeground)
+			background var(--primary)
 			transition background 0.1s ease
 
 			&:hover
-				background lighten($theme-color, 10%)
+				background var(--primaryLighten10)
 
 			&:active
-				background darken($theme-color, 10%)
+				background var(--primaryDarken10)
 				transition background 0s ease
 
 			> [data-fa]
@@ -316,7 +316,7 @@ root(isDark)
 						line-height 48px
 
 				&.trash
-					border-left solid 1px isDark ? #1c2023 : #ddd
+					border-left solid 1px var(--faceDivider)
 
 					> div
 						width 100%
@@ -336,7 +336,7 @@ root(isDark)
 		display flex
 		justify-content center
 		margin 0 auto
-		max-width 1220px
+		max-width 1240px
 
 		> *
 			.customize-container
@@ -351,13 +351,13 @@ root(isDark)
 
 		> .main
 			padding 16px
-			width calc(100% - 275px * 2)
+			width calc(100% - 280px * 2)
 			order 2
 
 			> .form
 				margin-bottom 16px
-				border solid 1px rgba(#000, 0.075)
-				border-radius 4px
+				box-shadow var(--shadow)
+				border-radius var(--round)
 
 			@media (max-width 700px)
 				padding 0
@@ -367,7 +367,7 @@ root(isDark)
 					border-radius 0
 
 		> *:not(.main)
-			width 275px
+			width 280px
 			padding 16px 0 16px 0
 
 			> *:not(:last-child)
@@ -391,10 +391,4 @@ root(isDark)
 				max-width 700px
 				margin 0 auto
 
-.mk-home[data-darkmode]
-	root(true)
-
-.mk-home:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/input-dialog.vue b/src/client/app/desktop/views/components/input-dialog.vue
index e2cf4e48fd..976e897fe8 100644
--- a/src/client/app/desktop/views/components/input-dialog.vue
+++ b/src/client/app/desktop/views/components/input-dialog.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy">
+<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="destroyDom">
 	<span slot="header" :class="$style.header">
 		%fa:i-cursor%{{ title }}
 	</span>
@@ -76,7 +76,7 @@ export default Vue.extend({
 
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .header
 	> [data-fa]
@@ -96,25 +96,25 @@ export default Vue.extend({
 		color #333
 		background #fff
 		outline none
-		border solid 1px rgba($theme-color, 0.1)
+		border solid 1px var(--primaryAlpha01)
 		border-radius 4px
 		transition border-color .3s ease
 
 		&:hover
-			border-color rgba($theme-color, 0.2)
+			border-color var(--primaryAlpha02)
 			transition border-color .1s ease
 
 		&:focus
-			color $theme-color
-			border-color rgba($theme-color, 0.5)
+			color var(--primary)
+			border-color var(--primaryAlpha05)
 			transition border-color 0s ease
 
 		&::-webkit-input-placeholder
-			color rgba($theme-color, 0.3)
+			color var(--primaryAlpha03)
 
 .actions
 	height 72px
-	background lighten($theme-color, 95%)
+	background var(--primaryLighten95)
 
 .ok
 .cancel
@@ -139,7 +139,7 @@ export default Vue.extend({
 			right -5px
 			bottom -5px
 			left -5px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 8px
 
 	&:disabled
@@ -148,20 +148,20 @@ export default Vue.extend({
 
 .ok
 	right 16px
-	color $theme-color-foreground
-	background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-	border solid 1px lighten($theme-color, 15%)
+	color var(--primaryForeground)
+	background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%)
+	border solid 1px var(--primaryLighten15)
 
 	&:not(:disabled)
 		font-weight bold
 
 	&:hover:not(:disabled)
-		background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-		border-color $theme-color
+		background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%)
+		border-color var(--primary)
 
 	&:active:not(:disabled)
-		background $theme-color
-		border-color $theme-color
+		background var(--primary)
+		border-color var(--primary)
 
 .cancel
 	right 148px
diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue
index 026522d907..89a340d3ae 100644
--- a/src/client/app/desktop/views/components/media-image-dialog.vue
+++ b/src/client/app/desktop/views/components/media-image-dialog.vue
@@ -26,7 +26,7 @@ export default Vue.extend({
 				opacity: 0,
 				duration: 100,
 				easing: 'linear',
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		}
 	}
diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue
index 8b68f260fa..f9ab188ca5 100644
--- a/src/client/app/desktop/views/components/media-image.vue
+++ b/src/client/app/desktop/views/components/media-image.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide" @click="hide = false">
+<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
 	<div>
 		<b>%fa:exclamation-triangle% %i18n:@sensitive%</b>
 		<span>%i18n:@click-to-show%</span>
@@ -27,12 +27,13 @@ export default Vue.extend({
 		},
 		raw: {
 			default: false
-		},
-		hide: {
-			type: Boolean,
-			default: true
 		}
 	},
+	data() {
+		return {
+			hide: true
+		};
+	},
 	computed: {
 		style(): any {
 			return {
@@ -48,7 +49,7 @@ export default Vue.extend({
 			const mouseY = e.clientY - rect.top;
 			const xp = mouseX / this.$el.offsetWidth * 100;
 			const yp = mouseY / this.$el.offsetHeight * 100;
-			this.$el.style.backgroundPosition = xp + '% ' + yp + '%';
+			this.$el.style.backgroundPosition = `${xp}% ${yp}%`;
 			this.$el.style.backgroundImage = `url("${this.image.url}")`;
 		},
 
@@ -89,7 +90,7 @@ export default Vue.extend({
 		text-align center
 		font-size 12px
 
-		> b
+		> *
 			display block
 
 </style>
diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue
index 959cefa42c..03c93c8939 100644
--- a/src/client/app/desktop/views/components/media-video-dialog.vue
+++ b/src/client/app/desktop/views/components/media-video-dialog.vue
@@ -28,7 +28,7 @@ export default Vue.extend({
 				opacity: 0,
 				duration: 100,
 				easing: 'linear',
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		}
 	}
diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue
index 6c60f2da96..7859a59254 100644
--- a/src/client/app/desktop/views/components/media-video.vue
+++ b/src/client/app/desktop/views/components/media-video.vue
@@ -6,19 +6,12 @@
 	</div>
 </div>
 <div class="vwxdhznewyashiknzolsoihtlpicqepe" v-else>
-	<video class="video"
-		:src="video.url"
-		:title="video.name"
-		controls
-		@dblclick.prevent="onClick"
-		ref="video"
-		v-if="inlinePlayable" />
 	<a class="thumbnail"
 		:href="video.url"
 		:style="imageStyle"
 		@click.prevent="onClick"
 		:title="video.name"
-		v-else>
+	>
 		%fa:R play-circle%
 	</a>
 </div>
@@ -36,16 +29,17 @@ export default Vue.extend({
 		},
 		inlinePlayable: {
 			default: false
-		},
-		hide: {
-			type: Boolean,
-			default: true
 		}
 	},
+	data() {
+		return {
+			hide: true
+		};
+	},
 	computed: {
 		imageStyle(): any {
 			return {
-				'background-image': `url(${this.video.url})`
+				'background-image': null // TODO `url(${this.video.thumbnailUrl})`
 			};
 		}
 	},
@@ -79,7 +73,6 @@ export default Vue.extend({
 		justify-content center
 		align-items center
 		font-size 3.5em
-
 		cursor zoom-in
 		overflow hidden
 		background-position center
@@ -101,5 +94,4 @@ export default Vue.extend({
 
 		> b
 			display block
-
 </style>
diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue
index 41b421b0e7..3706377607 100644
--- a/src/client/app/desktop/views/components/messaging-room-window.vue
+++ b/src/client/app/desktop/views/components/messaging-room-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy">
+<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom">
 	<span slot="header" :class="$style.header">%fa:comments%%i18n:@title% {{ user | userName }}</span>
 	<mk-messaging-room :user="user" :class="$style.content"/>
 </mk-window>
diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue
index 9580c5061d..a8f0fc68b9 100644
--- a/src/client/app/desktop/views/components/messaging-window.vue
+++ b/src/client/app/desktop/views/components/messaging-window.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" width="500px" height="560px" @closed="$destroy">
+<mk-window ref="window" width="500px" height="560px" @closed="destroyDom">
 	<span slot="header" :class="$style.header">%fa:comments%%i18n:@title%</span>
 	<mk-messaging :class="$style.content" @navigate="navigate"/>
 </mk-window>
diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue
index 1ba4a9a447..b119f23d7a 100644
--- a/src/client/app/desktop/views/components/note-detail.vue
+++ b/src/client/app/desktop/views/components/note-detail.vue
@@ -37,20 +37,26 @@
 			</router-link>
 		</header>
 		<div class="body">
-			<div class="text">
-				<span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span>
-				<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
-				<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
-			</div>
-			<div class="media" v-if="p.media.length > 0">
-				<mk-media-list :media-list="p.media" :raw="true"/>
-			</div>
-			<mk-poll v-if="p.poll" :note="p"/>
-			<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
-			<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
-			<div class="map" v-if="p.geo" ref="map"></div>
-			<div class="renote" v-if="p.renote">
-				<mk-note-preview :note="p.renote"/>
+			<p v-if="p.cw != null" class="cw">
+				<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="p.cw == null || showContent">
+				<div class="text">
+					<span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span>
+					<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
+					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
+				</div>
+				<div class="files" v-if="p.files.length > 0">
+					<mk-media-list :media-list="p.files" :raw="true"/>
+				</div>
+				<mk-poll v-if="p.poll" :note="p"/>
+				<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
+				<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+				<div class="map" v-if="p.geo" ref="map"></div>
+				<div class="renote" v-if="p.renote">
+					<mk-note-preview :note="p.renote"/>
+				</div>
 			</div>
 		</div>
 		<footer>
@@ -86,12 +92,16 @@ import MkRenoteFormWindow from './renote-form-window.vue';
 import MkNoteMenu from '../../../common/views/components/note-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './notes.note.sub.vue';
+import { sum } from '../../../../../prelude/array';
+import noteSubscriber from '../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
 	components: {
 		XSub
 	},
 
+	mixins: [noteSubscriber('note')],
+
 	props: {
 		note: {
 			type: Object,
@@ -104,6 +114,7 @@ export default Vue.extend({
 
 	data() {
 		return {
+			showContent: false,
 			conversation: [],
 			conversationFetching: false,
 			replies: []
@@ -114,22 +125,24 @@ export default Vue.extend({
 		isRenote(): boolean {
 			return (this.note.renote &&
 				this.note.text == null &&
-				this.note.mediaIds.length == 0 &&
+				this.note.fileIds.length == 0 &&
 				this.note.poll == null);
 		},
+
 		p(): any {
 			return this.isRenote ? this.note.renote : this.note;
 		},
+
 		reactionsCount(): number {
 			return this.p.reactionCounts
-				? Object.keys(this.p.reactionCounts)
-					.map(key => this.p.reactionCounts[key])
-					.reduce((a, b) => a + b)
+				? sum(Object.values(this.p.reactionCounts))
 				: 0;
 		},
+
 		title(): string {
 			return new Date(this.p.createdAt).toLocaleString();
 		},
+
 		urls(): string[] {
 			if (this.p.text) {
 				const ast = parse(this.p.text);
@@ -184,22 +197,26 @@ export default Vue.extend({
 				this.conversation = conversation.reverse();
 			});
 		},
+
 		reply() {
 			(this as any).os.new(MkPostFormWindow, {
 				reply: this.p
 			});
 		},
+
 		renote() {
 			(this as any).os.new(MkRenoteFormWindow, {
 				note: this.p
 			});
 		},
+
 		react() {
 			(this as any).os.new(MkReactionPicker, {
 				source: this.$refs.reactButton,
 				note: this.p
 			});
 		},
+
 		menu() {
 			(this as any).os.new(MkNoteMenu, {
 				source: this.$refs.menuButton,
@@ -211,14 +228,12 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-note-detail
 	overflow hidden
 	text-align left
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.1)
-	border-radius 8px
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 
 	> .read-more
 		display block
@@ -229,28 +244,28 @@ root(isDark)
 		text-align center
 		color #999
 		cursor pointer
-		background isDark ? #21242d : #fafafa
+		background var(--subNoteBg)
 		outline none
 		border none
-		border-bottom solid 1px isDark ? #1c2023 : #eef0f2
-		border-radius 6px 6px 0 0
+		border-bottom solid 1px var(--faceDivider)
+		border-radius var(--round) var(--round) 0 0
 
 		&:hover
-			background isDark ? #2e3440 : #f6f6f6
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
 		&:active
-			background isDark ? #21242b : #f0f0f0
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 		&:disabled
-			color isDark ? #21242b : #ccc
+			cursor wait
 
 	> .conversation
 		> *
-			border-bottom 1px solid isDark ? #1c2023 : #eef0f2
+			border-bottom 1px solid var(--faceDivider)
 
 	> .renote
-		color #9dbb00
-		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+		color var(--renoteText)
+		background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
 
 		> p
 			margin 0
@@ -273,7 +288,7 @@ root(isDark)
 			padding-top 8px
 
 	> .reply-to
-		border-bottom 1px solid isDark ? #1c2023 : #eef0f2
+		border-bottom 1px solid var(--faceDivider)
 
 	> article
 		padding 28px 32px 18px 32px
@@ -285,7 +300,7 @@ root(isDark)
 
 		&:hover
 			> footer > button
-				color isDark ? #707b97 : #888
+				color var(--noteActionsHighlighted)
 
 		> .avatar
 			width 60px
@@ -302,7 +317,7 @@ root(isDark)
 				display inline-block
 				margin 0
 				line-height 24px
-				color isDark ? #fff : #627079
+				color var(--noteHeaderName)
 				font-size 18px
 				font-weight 700
 				text-align left
@@ -315,49 +330,61 @@ root(isDark)
 				display block
 				text-align left
 				margin 0
-				color isDark ? #606984 : #ccc
+				color var(--noteHeaderAcct)
 
 			> .time
 				position absolute
 				top 0
 				right 32px
 				font-size 1em
-				color isDark ? #606984 : #c0c0c0
+				color var(--noteHeaderInfo)
 
 		> .body
 			padding 8px 0
 
-			> .text
+			> .cw
 				cursor default
 				display block
 				margin 0
 				padding 0
 				overflow-wrap break-word
-				font-size 1.5em
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
-			> .renote
-				margin 8px 0
+				> .text
+					margin-right 8px
 
-				> .mk-note-preview
-					padding 16px
-					border dashed 1px #c0dac6
-					border-radius 8px
+			> .content
+				> .text
+					cursor default
+					display block
+					margin 0
+					padding 0
+					overflow-wrap break-word
+					font-size 1.5em
+					color var(--noteText)
 
-			> .location
-				margin 4px 0
-				font-size 12px
-				color #ccc
+				> .renote
+					margin 8px 0
 
-			> .map
-				width 100%
-				height 300px
+					> *
+						padding 16px
+						border dashed 1px var(--quoteBorder)
+						border-radius 8px
 
-				&:empty
-					display none
+				> .location
+					margin 4px 0
+					font-size 12px
+					color #ccc
 
-			> .mk-url-preview
-				margin-top 8px
+				> .map
+					width 100%
+					height 300px
+
+					&:empty
+						display none
+
+				> .mk-url-preview
+					margin-top 8px
 
 		> footer
 			font-size 1.2em
@@ -368,20 +395,20 @@ root(isDark)
 				background transparent
 				border none
 				font-size 1em
-				color isDark ? #606984 : #ccc
+				color var(--noteActions)
 				cursor pointer
 
 				&:hover
-					color isDark ? #a1a8bf : #444
+					color var(--noteActionsHover)
 
 				&.replyButton:hover
-					color #0af
+					color var(--noteActionsReplyHover)
 
 				&.renoteButton:hover
-					color #8d0
+					color var(--noteActionsRenoteHover)
 
 				&.reactionButton:hover
-					color #fa0
+					color var(--noteActionsReactionHover)
 
 				> .count
 					display inline
@@ -389,16 +416,10 @@ root(isDark)
 					color #999
 
 				&.reacted, &.reacted:hover
-					color #fa0
+					color var(--noteActionsReactionHover)
 
 	> .replies
 		> *
-			border-top 1px solid isDark ? #1c2023 : #eef0f2
-
-.mk-note-detail[data-darkmode]
-	root(true)
-
-.mk-note-detail:not([data-darkmode])
-	root(false)
+			border-top 1px solid var(--faceDivider)
 
 </style>
diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue
index c723db98c0..4c1c7e7b2d 100644
--- a/src/client/app/desktop/views/components/note-preview.vue
+++ b/src/client/app/desktop/views/components/note-preview.vue
@@ -1,10 +1,16 @@
 <template>
-<div class="mk-note-preview" :title="title">
+<div class="qiziqtywpuaucsgarwajitwaakggnisj" :title="title">
 	<mk-avatar class="avatar" :user="note.user" v-if="!mini"/>
 	<div class="main">
 		<mk-note-header class="header" :note="note" :mini="true"/>
 		<div class="body">
-			<mk-sub-note-content class="text" :note="note"/>
+			<p v-if="note.cw != null" class="cw">
+				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="note.cw == null || showContent">
+				<mk-sub-note-content class="text" :note="note"/>
+			</div>
 		</div>
 	</div>
 </div>
@@ -25,6 +31,13 @@ export default Vue.extend({
 			default: false
 		}
 	},
+
+	data() {
+		return {
+			showContent: false
+		};
+	},
+
 	computed: {
 		title(): string {
 			return new Date(this.note.createdAt).toLocaleString();
@@ -34,7 +47,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.qiziqtywpuaucsgarwajitwaakggnisj
 	display flex
 	font-size 0.9em
 
@@ -52,16 +65,22 @@ root(isDark)
 
 		> .body
 
-			> .text
+			> .cw
 				cursor default
+				display block
 				margin 0
 				padding 0
-				color isDark ? #959ba7 : #717171
+				overflow-wrap break-word
+				color var(--noteText)
 
-.mk-note-preview[data-darkmode]
-	root(true)
+				> .text
+					margin-right 8px
 
-.mk-note-preview:not([data-darkmode])
-	root(false)
+			> .content
+				> .text
+					cursor default
+					margin 0
+					padding 0
+					color var(--subNoteText)
 
 </style>
diff --git a/src/client/app/desktop/views/components/notes.note.sub.vue b/src/client/app/desktop/views/components/notes.note.sub.vue
index fc851e83e9..ee52670f8f 100644
--- a/src/client/app/desktop/views/components/notes.note.sub.vue
+++ b/src/client/app/desktop/views/components/notes.note.sub.vue
@@ -1,10 +1,16 @@
 <template>
-<div class="sub" :title="title">
+<div class="tkfdzaxtkdeianobciwadajxzbddorql" :title="title">
 	<mk-avatar class="avatar" :user="note.user"/>
 	<div class="main">
 		<mk-note-header class="header" :note="note"/>
 		<div class="body">
-			<mk-sub-note-content class="text" :note="note"/>
+			<p v-if="note.cw != null" class="cw">
+				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="note.cw == null || showContent">
+				<mk-sub-note-content class="text" :note="note"/>
+			</div>
 		</div>
 	</div>
 </div>
@@ -14,7 +20,19 @@
 import Vue from 'vue';
 
 export default Vue.extend({
-	props: ['note'],
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			showContent: false
+		};
+	},
+
 	computed: {
 		title(): string {
 			return new Date(this.note.createdAt).toLocaleString();
@@ -24,12 +42,12 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.tkfdzaxtkdeianobciwadajxzbddorql
 	display flex
 	margin 0
 	padding 16px 32px
 	font-size 0.9em
-	background isDark ? #21242d : #fcfcfc
+	background var(--subNoteBg)
 
 	> .avatar
 		flex-shrink 0
@@ -48,20 +66,26 @@ root(isDark)
 
 		> .body
 
-			> .text
+			> .cw
 				cursor default
+				display block
 				margin 0
 				padding 0
-				color isDark ? #959ba7 : #717171
+				overflow-wrap break-word
+				color var(--noteText)
 
-				pre
-					max-height 120px
-					font-size 80%
+				> .text
+					margin-right 8px
 
-.sub[data-darkmode]
-	root(true)
+			> .content
+				> .text
+					cursor default
+					margin 0
+					padding 0
+					color var(--subNoteText)
 
-.sub:not([data-darkmode])
-	root(false)
+					pre
+						max-height 120px
+						font-size 80%
 
 </style>
diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue
index 7592ae3905..2db1479823 100644
--- a/src/client/app/desktop/views/components/notes.note.vue
+++ b/src/client/app/desktop/views/components/notes.note.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="note" tabindex="-1" :title="title" @keydown="onKeydown">
+<div class="note" tabindex="-1" v-hotkey="keymap" :title="title">
 	<div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
 		<x-sub :note="p.reply"/>
 	</div>
@@ -18,7 +18,7 @@
 			<div class="body">
 				<p v-if="p.cw != null" class="cw">
 					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
-					<span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@hide%' : '%i18n:@see-more%' }}</span>
+					<mk-cw-button v-model="showContent"/>
 				</p>
 				<div class="content" v-show="p.cw == null || showContent">
 					<div class="text">
@@ -28,32 +28,30 @@
 						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
 						<a class="rp" v-if="p.renote">RP:</a>
 					</div>
-					<div class="media" v-if="p.media.length > 0">
-						<mk-media-list :media-list="p.media"/>
+					<div class="files" v-if="p.files.length > 0">
+						<mk-media-list :media-list="p.files"/>
 					</div>
 					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
 					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
 					<div class="map" v-if="p.geo" ref="map"></div>
-					<div class="renote" v-if="p.renote">
-						<mk-note-preview :note="p.renote"/>
-					</div>
+					<div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 				</div>
 			</div>
-			<footer>
+			<footer v-if="p.deletedAt == null">
 				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
-				<button class="replyButton" @click="reply" title="%i18n:@reply%">
+				<button class="replyButton" @click="reply()" title="%i18n:@reply%">
 					<template v-if="p.reply">%fa:reply-all%</template>
 					<template v-else>%fa:reply%</template>
 					<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
 				</button>
-				<button class="renoteButton" @click="renote" title="%i18n:@renote%">
+				<button class="renoteButton" @click="renote()" title="%i18n:@renote%">
 					%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
 				</button>
-				<button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%">
+				<button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%">
 					%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
 				</button>
-				<button @click="menu" ref="menuButton">
+				<button @click="menu()" ref="menuButton">
 					%fa:ellipsis-h%
 				</button>
 				<!-- <button title="%i18n:@detail">
@@ -78,6 +76,8 @@ import MkRenoteFormWindow from './renote-form-window.vue';
 import MkNoteMenu from '../../../common/views/components/note-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './notes.note.sub.vue';
+import { sum } from '../../../../../prelude/array';
+import noteSubscriber from '../../../common/scripts/note-subscriber';
 
 function focus(el, fn) {
 	const target = fn(el);
@@ -95,22 +95,51 @@ export default Vue.extend({
 		XSub
 	},
 
-	props: ['note'],
+	mixins: [noteSubscriber('note')],
+
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
 
 	data() {
 		return {
 			showContent: false,
-			isDetailOpened: false,
-			connection: null,
-			connectionId: null
+			isDetailOpened: false
 		};
 	},
 
 	computed: {
+		keymap(): any {
+			return {
+				'r|left': () => this.reply(true),
+				'e|a|plus': () => this.react(true),
+				'q|right': () => this.renote(true),
+				'ctrl+q|ctrl+right': this.renoteDirectly,
+				'up|k|shift+tab': this.focusBefore,
+				'down|j|tab': this.focusAfter,
+				'esc': this.blur,
+				'm|o': () => this.menu(true),
+				's': this.toggleShowContent,
+				'1': () => this.reactDirectly('like'),
+				'2': () => this.reactDirectly('love'),
+				'3': () => this.reactDirectly('laugh'),
+				'4': () => this.reactDirectly('hmm'),
+				'5': () => this.reactDirectly('surprise'),
+				'6': () => this.reactDirectly('congrats'),
+				'7': () => this.reactDirectly('angry'),
+				'8': () => this.reactDirectly('confused'),
+				'9': () => this.reactDirectly('rip'),
+				'0': () => this.reactDirectly('pudding'),
+			};
+		},
+
 		isRenote(): boolean {
 			return (this.note.renote &&
 				this.note.text == null &&
-				this.note.mediaIds.length == 0 &&
+				this.note.fileIds.length == 0 &&
 				this.note.poll == null);
 		},
 
@@ -120,9 +149,7 @@ export default Vue.extend({
 
 		reactionsCount(): number {
 			return this.p.reactionCounts
-				? Object.keys(this.p.reactionCounts)
-					.map(key => this.p.reactionCounts[key])
-					.reduce((a, b) => a + b)
+				? sum(Object.values(this.p.reactionCounts))
 				: 0;
 		},
 
@@ -142,156 +169,81 @@ export default Vue.extend({
 		}
 	},
 
-	created() {
-		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
-		}
-	},
-
-	mounted() {
-		this.capture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.on('_connected_', this.onStreamConnected);
-		}
-
-		// Draw map
-		if (this.p.geo) {
-			const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true;
-			if (shouldShowMap) {
-				(this as any).os.getGoogleMaps().then(maps => {
-					const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
-					const map = new maps.Map(this.$refs.map, {
-						center: uluru,
-						zoom: 15
-					});
-					new maps.Marker({
-						position: uluru,
-						map: map
-					});
-				});
-			}
-		}
-	},
-
-	beforeDestroy() {
-		this.decapture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.off('_connected_', this.onStreamConnected);
-			(this as any).os.stream.dispose(this.connectionId);
-		}
-	},
-
 	methods: {
-		capture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'capture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		decapture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'decapture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		onStreamConnected() {
-			this.capture();
-		},
-
-		onStreamNoteUpdated(data) {
-			const note = data.note;
-			if (note.id == this.note.id) {
-				this.$emit('update:note', note);
-			} else if (note.id == this.note.renoteId) {
-				this.note.renote = note;
-			}
-		},
-
-		reply() {
+		reply(viaKeyboard = false) {
 			(this as any).os.new(MkPostFormWindow, {
-				reply: this.p
-			});
+				reply: this.p,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
 		},
 
-		renote() {
+		renote(viaKeyboard = false) {
 			(this as any).os.new(MkRenoteFormWindow, {
-				note: this.p
+				note: this.p,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
+		},
+
+		renoteDirectly() {
+			(this as any).api('notes/create', {
+				renoteId: this.p.id
 			});
 		},
 
-		react() {
+		react(viaKeyboard = false) {
+			this.blur();
 			(this as any).os.new(MkReactionPicker, {
 				source: this.$refs.reactButton,
-				note: this.p
+				note: this.p,
+				showFocus: viaKeyboard,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
+		},
+
+		reactDirectly(reaction) {
+			(this as any).api('notes/reactions/create', {
+				noteId: this.p.id,
+				reaction: reaction
 			});
 		},
 
-		menu() {
+		menu(viaKeyboard = false) {
 			(this as any).os.new(MkNoteMenu, {
 				source: this.$refs.menuButton,
-				note: this.p
-			});
+				note: this.p,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
 		},
 
-		onKeydown(e) {
-			let shouldBeCancel = true;
+		toggleShowContent() {
+			this.showContent = !this.showContent;
+		},
 
-			switch (true) {
-				case e.which == 38: // [↑]
-				case e.which == 74: // [j]
-				case e.which == 9 && e.shiftKey: // [Shift] + [Tab]
-					focus(this.$el, e => e.previousElementSibling);
-					break;
+		focus() {
+			this.$el.focus();
+		},
 
-				case e.which == 40: // [↓]
-				case e.which == 75: // [k]
-				case e.which == 9: // [Tab]
-					focus(this.$el, e => e.nextElementSibling);
-					break;
+		blur() {
+			this.$el.blur();
+		},
 
-				case e.which == 81: // [q]
-				case e.which == 69: // [e]
-					this.renote();
-					break;
+		focusBefore() {
+			focus(this.$el, e => e.previousElementSibling);
+		},
 
-				case e.which == 70: // [f]
-				case e.which == 76: // [l]
-					//this.like();
-					break;
-
-				case e.which == 82: // [r]
-					this.reply();
-					break;
-
-				default:
-					shouldBeCancel = false;
-			}
-
-			if (shouldBeCancel) e.preventDefault();
+		focusAfter() {
+			focus(this.$el, e => e.nextElementSibling);
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.note
 	margin 0
 	padding 0
-	background isDark ? #282C37 : #fff
-	border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+	background var(--face)
+	border-bottom solid 1px var(--faceDivider)
 
 	&[data-round]
 		&:first-child
@@ -316,7 +268,7 @@ root(isDark)
 			right 2px
 			bottom 2px
 			left 2px
-			border 2px solid rgba($theme-color, 0.3)
+			border 2px solid var(--primaryAlpha03)
 			border-radius 4px
 
 	> .renote
@@ -325,8 +277,8 @@ root(isDark)
 		padding 16px 32px 8px 32px
 		line-height 28px
 		white-space pre
-		color #9dbb00
-		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+		color var(--renoteText)
+		background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
 
 		.avatar
 			display inline-block
@@ -366,7 +318,7 @@ root(isDark)
 
 		&:hover
 			> .main > footer > button
-				color isDark ? #707b97 : #888
+				color var(--noteActionsHighlighted)
 
 		> .avatar
 			flex-shrink 0
@@ -394,24 +346,11 @@ root(isDark)
 					margin 0
 					padding 0
 					overflow-wrap break-word
-					color isDark ? #fff : #717171
+					color var(--noteText)
 
 					> .text
 						margin-right 8px
 
-					> .toggle
-						display inline-block
-						padding 4px 8px
-						font-size 0.7em
-						color isDark ? #393f4f : #fff
-						background isDark ? #687390 : #b1b9c1
-						border-radius 2px
-						cursor pointer
-						user-select none
-
-						&:hover
-							background isDark ? #707b97 : #bbc4ce
-
 				> .content
 
 					> .text
@@ -420,7 +359,7 @@ root(isDark)
 						margin 0
 						padding 0
 						overflow-wrap break-word
-						color isDark ? #fff : #717171
+						color var(--noteText)
 
 						>>> .title
 							display block
@@ -428,7 +367,7 @@ root(isDark)
 							padding 4px
 							font-size 90%
 							text-align center
-							background isDark ? #2f3944 : #eef1f3
+							background var(--mfmTitleBg)
 							border-radius 4px
 
 						>>> .code
@@ -437,17 +376,17 @@ root(isDark)
 						>>> .quote
 							margin 8px
 							padding 6px 12px
-							color isDark ? #6f808e : #aaa
-							border-left solid 3px isDark ? #637182 : #eee
+							color var(--mfmQuote)
+							border-left solid 3px var(--mfmQuoteLine)
 
 						> .reply
 							margin-right 8px
-							color isDark ? #99abbf : #717171
+							color var(--text)
 
 						> .rp
 							margin-left 4px
 							font-style oblique
-							color #a0bf46
+							color var(--renoteText)
 
 					> .location
 						margin 4px 0
@@ -470,9 +409,9 @@ root(isDark)
 					> .renote
 						margin 8px 0
 
-						> .mk-note-preview
+						> *
 							padding 16px
-							border dashed 1px isDark ? #4e945e : #c0dac6
+							border dashed 1px var(--quoteBorder)
 							border-radius 8px
 
 			> footer
@@ -481,22 +420,22 @@ root(isDark)
 					padding 0 8px
 					line-height 32px
 					font-size 1em
-					color isDark ? #606984 : #ddd
+					color var(--noteActions)
 					background transparent
 					border none
 					cursor pointer
 
 					&:hover
-						color isDark ? #a1a8bf : #444
+						color var(--noteActionsHover)
 
 					&.replyButton:hover
-						color #0af
+						color var(--noteActionsReplyHover)
 
 					&.renoteButton:hover
-						color #8d0
+						color var(--noteActionsRenoteHover)
 
 					&.reactionButton:hover
-						color #fa0
+						color var(--noteActionsReactionHover)
 
 					> .count
 						display inline
@@ -504,18 +443,12 @@ root(isDark)
 						color #999
 
 					&.reacted, &.reacted:hover
-						color #fa0
+						color var(--noteActionsReactionHover)
 
 	> .detail
 		padding-top 4px
 		background rgba(#000, 0.0125)
 
-.note[data-darkmode]
-	root(true)
-
-.note:not([data-darkmode])
-	root(false)
-
 </style>
 
 <style lang="stylus" module>
@@ -538,7 +471,7 @@ root(isDark)
 		padding 0 4px
 		margin-left 4px
 		font-size 80%
-		color $theme-color-foreground
-		background $theme-color
+		color var(--primaryForeground)
+		background var(--primary)
 		border-radius 4px
 </style>
diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index a1c1207a7b..84b13ed84e 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -10,17 +10,15 @@
 	</div>
 
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
-	<!--<transition-group name="mk-notes" class="transition">-->
-	<div class="notes">
+	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes">
 		<template v-for="(note, i) in _notes">
-			<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
+			<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" ref="note"/>
 			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
 				<span>%fa:angle-up%{{ note._datetext }}</span>
 				<span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
 			</p>
 		</template>
-	</div>
-	<!--</transition-group>-->
+	</component>
 
 	<footer v-if="more">
 		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
@@ -91,7 +89,7 @@ export default Vue.extend({
 		},
 
 		focus() {
-			(this.$el as any).children[0].focus();
+			(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
 		},
 
 		onNoteUpdated(i, note) {
@@ -122,7 +120,7 @@ export default Vue.extend({
 		prepend(note, silent = false) {
 			//#region 弾く
 			const isMyNote = note.userId == this.$store.state.i.id;
-			const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+			const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
 
 			if (this.$store.state.settings.showMyRenotes === false) {
 				if (isMyNote && isPureRenote) {
@@ -218,9 +216,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-notes
 	.transition
 		.mk-notes-enter
 		.mk-notes-leave-to
@@ -237,9 +233,9 @@ root(isDark)
 			line-height 32px
 			font-size 14px
 			text-align center
-			color isDark ? #666b79 : #aaa
-			background isDark ? #242731 : #fdfdfd
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			color var(--dateDividerFg)
+			background var(--dateDividerBg)
+			border-bottom solid 1px var(--faceDivider)
 
 			span
 				margin 0 16px
@@ -252,7 +248,7 @@ root(isDark)
 		position sticky
 		z-index 100
 		height 3px
-		background $theme-color
+		background var(--primary)
 
 	> footer
 		> button
@@ -262,21 +258,15 @@ root(isDark)
 			width 100%
 			text-align center
 			color #ccc
-			background isDark ? #282C37 : #fff
-			border-top solid 1px isDark ? #1c2023 : #eaeaea
+			background var(--face)
+			border-top solid 1px var(--faceDivider)
 			border-bottom-left-radius 6px
 			border-bottom-right-radius 6px
 
 			&:hover
-				background isDark ? #2e3440 : #f5f5f5
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
 			&:active
-				background isDark ? #21242b : #eee
-
-.mk-notes[data-darkmode]
-	root(true)
-
-.mk-notes:not([data-darkmode])
-	root(false)
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 </style>
diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue
index bfe71903e4..95b8e1355a 100644
--- a/src/client/app/desktop/views/components/notifications.vue
+++ b/src/client/app/desktop/views/components/notifications.vue
@@ -2,8 +2,7 @@
 <div class="mk-notifications">
 	<div class="notifications" v-if="notifications.length != 0">
 		<!-- トランジションを有効にするとなぜかメモリリークする -->
-		<!-- <transition-group name="mk-notifications" class="transition"> -->
-		<div>
+		<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition" tag="div">
 			<template v-for="(notification, i) in _notifications">
 				<div class="notification" :class="notification.type" :key="notification.id">
 					<mk-time :time="notification.createdAt"/>
@@ -97,8 +96,7 @@
 					<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
 				</p>
 			</template>
-		</div>
-		<!-- </transition-group> -->
+		</component>
 	</div>
 	<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
 		<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }}
@@ -120,10 +118,10 @@ export default Vue.extend({
 			notifications: [],
 			moreNotifications: false,
 			connection: null,
-			connectionId: null,
 			getNoteSummary
 		};
 	},
+
 	computed: {
 		_notifications(): any[] {
 			return (this.notifications as any).map(notification => {
@@ -135,9 +133,9 @@ export default Vue.extend({
 			});
 		}
 	},
+
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
 		this.connection.on('notification', this.onNotification);
 
@@ -155,10 +153,11 @@ export default Vue.extend({
 			this.fetching = false;
 		});
 	},
+
 	beforeDestroy() {
-		this.connection.off('notification', this.onNotification);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
+
 	methods: {
 		fetchMoreNotifications() {
 			this.fetchingMoreNotifications = true;
@@ -179,10 +178,11 @@ export default Vue.extend({
 				this.fetchingMoreNotifications = false;
 			});
 		},
+
 		onNotification(notification) {
 			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
 			this.connection.send({
-				type: 'read_notification',
+				type: 'readNotification',
 				id: notification.id
 			});
 
@@ -193,7 +193,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-notifications
 	.transition
 		.mk-notifications-enter
 		.mk-notifications-leave-to
@@ -210,7 +210,7 @@ root(isDark)
 				padding 16px
 				overflow-wrap break-word
 				font-size 13px
-				border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
+				border-bottom solid 1px var(--faceDivider)
 
 				&:last-child
 					border-bottom none
@@ -221,7 +221,7 @@ root(isDark)
 					top 16px
 					right 12px
 					vertical-align top
-					color isDark ? #606984 : rgba(#000, 0.6)
+					color var(--noteHeaderInfo)
 					font-size small
 
 				&:after
@@ -251,10 +251,10 @@ root(isDark)
 							margin-right 4px
 
 				.note-preview
-					color isDark ? #c2cad4 : rgba(#000, 0.7)
+					color var(--noteText)
 
 				.note-ref
-					color isDark ? #c2cad4 : rgba(#000, 0.7)
+					color var(--noteText)
 
 					[data-fa]
 						font-size 1em
@@ -285,9 +285,9 @@ root(isDark)
 				line-height 32px
 				text-align center
 				font-size 0.8em
-				color isDark ? #666b79 : #aaa
-				background isDark ? #242731 : #fdfdfd
-				border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
+				color var(--dateDividerFg)
+				background var(--dateDividerBg)
+				border-bottom solid 1px var(--faceDivider)
 
 				span
 					margin 0 16px
@@ -329,10 +329,4 @@ root(isDark)
 		> [data-fa]
 			margin-right 4px
 
-.mk-notifications[data-darkmode]
-	root(true)
-
-.mk-notifications:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue
index 51a416e281..a5d191f2f3 100644
--- a/src/client/app/desktop/views/components/post-form-window.vue
+++ b/src/client/app/desktop/views/components/post-form-window.vue
@@ -1,10 +1,10 @@
 <template>
-<mk-window class="mk-post-form-window" ref="window" is-modal @closed="$destroy">
+<mk-window class="mk-post-form-window" ref="window" is-modal @closed="onWindowClosed" :animation="animation">
 	<span slot="header" class="mk-post-form-window--header">
 		<span class="icon" v-if="geo">%fa:map-marker-alt%</span>
 		<span v-if="!reply">%i18n:@note%</span>
 		<span v-if="reply">%i18n:@reply%</span>
-		<span class="count" v-if="media.length != 0">{{ '%i18n:@attaches%'.replace('{}', media.length) }}</span>
+		<span class="count" v-if="files.length != 0">{{ '%i18n:@attaches%'.replace('{}', files.length) }}</span>
 		<span class="count" v-if="uploadings.length != 0">{{ '%i18n:@uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span>
 	</span>
 
@@ -14,7 +14,7 @@
 			:reply="reply"
 			@posted="onPosted"
 			@change-uploadings="onChangeUploadings"
-			@change-attached-media="onChangeMedia"
+			@change-attached-files="onChangeFiles"
 			@geo-attached="onGeoAttached"
 			@geo-dettached="onGeoDettached"/>
 	</div>
@@ -25,25 +25,39 @@
 import Vue from 'vue';
 
 export default Vue.extend({
-	props: ['reply'],
+	props: {
+		reply: {
+			type: Object,
+			required: false
+		},
+
+		animation: {
+			type: Boolean,
+			required: false,
+			default: true
+		}
+	},
+
 	data() {
 		return {
 			uploadings: [],
-			media: [],
+			files: [],
 			geo: null
 		};
 	},
+
 	mounted() {
 		this.$nextTick(() => {
 			(this.$refs.form as any).focus();
 		});
 	},
+
 	methods: {
 		onChangeUploadings(files) {
 			this.uploadings = files;
 		},
-		onChangeMedia(media) {
-			this.media = media;
+		onChangeFiles(files) {
+			this.files = files;
 		},
 		onGeoAttached(geo) {
 			this.geo = geo;
@@ -53,13 +67,17 @@ export default Vue.extend({
 		},
 		onPosted() {
 			(this.$refs.window as any).close();
+		},
+		onWindowClosed() {
+			this.$emit('closed');
+			this.destroyDom();
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-post-form-window
 	.mk-post-form-window--header
 		.icon
 			margin-right 8px
@@ -76,15 +94,6 @@ root(isDark)
 
 	.mk-post-form-window--body
 		.notePreview
-			if isDark
-				margin 16px 22px 0 22px
-			else
 				margin 16px 22px
 
-.mk-post-form-window[data-darkmode]
-	root(true)
-
-.mk-post-form-window:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue
index bacaea65ee..e25cc33579 100644
--- a/src/client/app/desktop/views/components/post-form.vue
+++ b/src/client/app/desktop/views/components/post-form.vue
@@ -20,7 +20,7 @@
 			@keydown="onKeydown" @paste="onPaste" :placeholder="placeholder"
 			v-autocomplete="'text'"
 		></textarea>
-		<div class="medias" :class="{ with: poll }" v-show="files.length != 0">
+		<div class="files" :class="{ with: poll }" v-show="files.length != 0">
 			<x-draggable :list="files" :options="{ animation: 150 }">
 				<div v-for="file in files" :key="file.id">
 					<div class="img" :style="{ backgroundImage: `url(${file.thumbnailUrl})` }" :title="file.name"></div>
@@ -35,7 +35,7 @@
 	<button class="upload" title="%i18n:@attach-media-from-local%" @click="chooseFile">%fa:upload%</button>
 	<button class="drive" title="%i18n:@attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button>
 	<button class="kao" title="%i18n:@insert-a-kao%" @click="kao">%fa:R smile%</button>
-	<button class="poll" title="%i18n:@create-poll%" @click="poll = true">%fa:chart-pie%</button>
+	<button class="poll" title="%i18n:@create-poll%" @click="poll = !poll">%fa:chart-pie%</button>
 	<button class="poll" title="%i18n:@hide-contents%" @click="useCw = !useCw">%fa:eye-slash%</button>
 	<button class="geo" title="%i18n:@attach-location-information%" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
 	<button class="visibility" title="%i18n:@visibility%" @click="setVisibility" ref="visibilityButton">
@@ -45,11 +45,11 @@
 		<span v-if="visibility === 'specified'">%fa:envelope%</span>
 		<span v-if="visibility === 'private'">%fa:lock%</span>
 	</button>
-	<p class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</p>
+	<p class="text-count" :class="{ over: this.trimmedLength(text) > 1000 }">{{ 1000 - this.trimmedLength(text) }}</p>
 	<button :class="{ posting }" class="submit" :disabled="!canPost" @click="post">
 		{{ posting ? '%i18n:@posting%' : submitText }}<mk-ellipsis v-if="posting"/>
 	</button>
-	<input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/>
+	<input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/>
 	<div class="dropzone" v-if="draghover"></div>
 </div>
 </template>
@@ -62,6 +62,9 @@ import getFace from '../../../common/scripts/get-face';
 import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue';
 import parse from '../../../../../mfm/parse';
 import { host } from '../../../config';
+import { erase, unique } from '../../../../../prelude/array';
+import { length } from 'stringz';
+import parseAcct from '../../../../../misc/acct/parse';
 
 export default Vue.extend({
 	components: {
@@ -99,7 +102,7 @@ export default Vue.extend({
 			useCw: false,
 			cw: null,
 			geo: null,
-			visibility: this.$store.state.device.visibility || 'public',
+			visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility,
 			visibleUsers: [],
 			autocomplete: null,
 			draghover: false,
@@ -110,9 +113,9 @@ export default Vue.extend({
 	computed: {
 		draftId(): string {
 			return this.renote
-				? 'renote:' + this.renote.id
+				? `renote:${this.renote.id}`
 				: this.reply
-					? 'reply:' + this.reply.id
+					? `reply:${this.reply.id}`
 					: 'note';
 		},
 
@@ -145,7 +148,7 @@ export default Vue.extend({
 		canPost(): boolean {
 			return !this.posting &&
 				(1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) &&
-				(this.text.trim().length <= 1000);
+				(length(this.text.trim()) <= 1000);
 		}
 	},
 
@@ -175,6 +178,18 @@ export default Vue.extend({
 			});
 		}
 
+		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+		if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) {
+			this.visibility = this.reply.visibility;
+		}
+
+		// ダイレクトへのリプライはリプライ先ユーザーを初期設定
+		if (this.reply && this.reply.visibility === 'specified') {
+			(this as any).api('users/show', {	userId: this.reply.userId }).then(user => {
+				this.visibleUsers.push(user);
+			});
+		}
+
 		this.$nextTick(() => {
 			// 書きかけの投稿を復元
 			if (!this.instant) {
@@ -188,7 +203,7 @@ export default Vue.extend({
 							(this.$refs.poll as any).set(draft.data.poll);
 						});
 					}
-					this.$emit('change-attached-media', this.files);
+					this.$emit('change-attached-files', this.files);
 				}
 			}
 
@@ -197,6 +212,10 @@ export default Vue.extend({
 	},
 
 	methods: {
+	  trimmedLength(text: string) {
+			return length(text.trim());
+		},
+
 		addTag(tag: string) {
 			insertTextAtCursor(this.$refs.text, ` #${tag} `);
 		},
@@ -225,12 +244,12 @@ export default Vue.extend({
 
 		attachMedia(driveFile) {
 			this.files.push(driveFile);
-			this.$emit('change-attached-media', this.files);
+			this.$emit('change-attached-files', this.files);
 		},
 
 		detachMedia(id) {
 			this.files = this.files.filter(x => x.id != id);
-			this.$emit('change-attached-media', this.files);
+			this.$emit('change-attached-files', this.files);
 		},
 
 		onChangeFile() {
@@ -249,7 +268,7 @@ export default Vue.extend({
 			this.text = '';
 			this.files = [];
 			this.poll = false;
-			this.$emit('change-attached-media', this.files);
+			this.$emit('change-attached-files', this.files);
 		},
 
 		onKeydown(e) {
@@ -297,7 +316,7 @@ export default Vue.extend({
 			if (driveFile != null && driveFile != '') {
 				const file = JSON.parse(driveFile);
 				this.files.push(file);
-				this.$emit('change-attached-media', this.files);
+				this.$emit('change-attached-files', this.files);
 				e.preventDefault();
 			}
 			//#endregion
@@ -313,7 +332,7 @@ export default Vue.extend({
 				this.geo = pos.coords;
 				this.$emit('geo-attached', this.geo);
 			}, err => {
-				alert('%i18n:@error%: ' + err.message);
+				alert(`%i18n:@error%: ${err.message}`);
 			}, {
 					enableHighAccuracy: true
 				});
@@ -336,17 +355,16 @@ export default Vue.extend({
 		addVisibleUser() {
 			(this as any).apis.input({
 				title: '%i18n:@enter-username%'
-			}).then(username => {
-				(this as any).api('users/show', {
-					username
-				}).then(user => {
+			}).then(acct => {
+				if (acct.startsWith('@')) acct = acct.substr(1);
+				(this as any).api('users/show', parseAcct(acct)).then(user => {
 					this.visibleUsers.push(user);
 				});
 			});
 		},
 
 		removeVisibleUser(user) {
-			this.visibleUsers = this.visibleUsers.filter(u => u != user);
+			this.visibleUsers = erase(user, this.visibleUsers);
 		},
 
 		post() {
@@ -354,7 +372,7 @@ export default Vue.extend({
 
 			(this as any).api('notes/create', {
 				text: this.text == '' ? undefined : this.text,
-				mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+				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 : undefined,
 				poll: this.poll ? (this.$refs.poll as any).get() : undefined,
@@ -391,7 +409,7 @@ export default Vue.extend({
 			if (this.text && this.text != '') {
 				const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag);
 				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
-				localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], [])));
+				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
 			}
 		},
 
@@ -428,12 +446,11 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-post-form
 	display block
 	padding 16px
-	background isDark ? #282C37 : lighten($theme-color, 95%)
+	background var(--desktopPostFormBg)
+	overflow hidden
 
 	&:after
 		content ""
@@ -447,26 +464,26 @@ root(isDark)
 			width 100%
 			padding 12px
 			font-size 16px
-			color isDark ? #fff : #333
-			background isDark ? #191d23 : #fff
+			color var(--desktopPostFormTextareaFg)
+			background var(--desktopPostFormTextareaBg)
 			outline none
-			border solid 1px rgba($theme-color, 0.1)
+			border solid 1px var(--primaryAlpha01)
 			border-radius 4px
 			transition border-color .2s ease
 
 			&:hover
-				border-color rgba($theme-color, 0.2)
+				border-color var(--primaryAlpha02)
 				transition border-color .1s ease
 
 			&:focus
-				border-color rgba($theme-color, 0.5)
+				border-color var(--primaryAlpha05)
 				transition border-color 0s ease
 
 			&:disabled
 				opacity 0.5
 
 			&::-webkit-input-placeholder
-				color rgba($theme-color, 0.3)
+				color var(--primaryAlpha03)
 
 		> input
 			margin-bottom 8px
@@ -480,17 +497,17 @@ root(isDark)
 			&:hover
 				& + *
 				& + * + *
-					border-color rgba($theme-color, 0.2)
+					border-color var(--primaryAlpha02)
 					transition border-color .1s ease
 
 			&:focus
 				& + *
 				& + * + *
-					border-color rgba($theme-color, 0.5)
+					border-color var(--primaryAlpha05)
 					transition border-color 0s ease
 
 			&.with
-				border-bottom solid 1px rgba($theme-color, 0.1) !important
+				border-bottom solid 1px var(--primaryAlpha01) !important
 				border-radius 4px 4px 0 0
 
 		> .visibleUsers
@@ -499,7 +516,7 @@ root(isDark)
 
 			> span
 				margin-right 16px
-				color isDark ? #fff : #666
+				color var(--primary)
 
 		> .hashtags
 			margin 0 0 8px 0
@@ -508,23 +525,23 @@ root(isDark)
 			font-size 14px
 
 			> b
-				color isDark ? #9baec8 : darken($theme-color, 20%)
+				color var(--primary)
 
 			> *
 				margin-right 8px
 				white-space nowrap
 
-		> .medias
+		> .files
 			margin 0
 			padding 0
-			background isDark ? #181b23 : lighten($theme-color, 98%)
-			border solid 1px rgba($theme-color, 0.1)
+			background var(--desktopPostFormTextareaBg)
+			border solid 1px var(--primaryAlpha01)
 			border-top none
 			border-radius 0 0 4px 4px
 			transition border-color .3s ease
 
 			&.with
-				border-bottom solid 1px rgba($theme-color, 0.1) !important
+				border-bottom solid 1px var(--primaryAlpha01) !important
 				border-radius 0
 
 			> .remain
@@ -534,7 +551,7 @@ root(isDark)
 				right 8px
 				margin 0
 				padding 0
-				color rgba($theme-color, 0.4)
+				color var(--primaryAlpha04)
 
 			> div
 				padding 4px
@@ -568,8 +585,8 @@ root(isDark)
 						cursor pointer
 
 		> .mk-poll-editor
-			background isDark ? #181b23 : lighten($theme-color, 98%)
-			border solid 1px rgba($theme-color, 0.1)
+			background var(--desktopPostFormTextareaBg)
+			border solid 1px var(--primaryAlpha01)
 			border-top none
 			border-radius 0 0 4px 4px
 			transition border-color .3s ease
@@ -577,7 +594,7 @@ root(isDark)
 	> .mk-uploader
 		margin 8px 0 0 0
 		padding 8px
-		border solid 1px rgba($theme-color, 0.2)
+		border solid 1px var(--primaryAlpha02)
 		border-radius 4px
 
 	input[type='file']
@@ -594,22 +611,20 @@ root(isDark)
 		width 110px
 		height 40px
 		font-size 1em
-		color $theme-color-foreground
-		background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
+		color var(--primaryForeground)
+		background var(--primary)
 		outline none
-		border solid 1px lighten($theme-color, 15%)
+		border none
 		border-radius 4px
 
 		&:not(:disabled)
 			font-weight bold
 
 		&:hover:not(:disabled)
-			background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-			border-color $theme-color
+			background var(--primaryLighten5)
 
 		&:active:not(:disabled)
-			background $theme-color
-			border-color $theme-color
+			background var(--primaryDarken5)
 
 		&:focus
 			&:after
@@ -620,7 +635,7 @@ root(isDark)
 				right -5px
 				bottom -5px
 				left -5px
-				border 2px solid rgba($theme-color, 0.3)
+				border 2px solid var(--primaryAlpha03)
 				border-radius 8px
 
 		&:disabled
@@ -630,13 +645,13 @@ root(isDark)
 		&.wait
 			background linear-gradient(
 				45deg,
-				darken($theme-color, 10%) 25%,
-				$theme-color              25%,
-				$theme-color              50%,
-				darken($theme-color, 10%) 50%,
-				darken($theme-color, 10%) 75%,
-				$theme-color              75%,
-				$theme-color
+				var(--primaryDarken10) 25%,
+				var(--primary)              25%,
+				var(--primary)              50%,
+				var(--primaryDarken10) 50%,
+				var(--primaryDarken10) 75%,
+				var(--primary)              75%,
+				var(--primary)
 			)
 			background-size 32px 32px
 			animation stripe-bg 1.5s linear infinite
@@ -655,7 +670,7 @@ root(isDark)
 		right 138px
 		margin 0
 		line-height 40px
-		color rgba($theme-color, 0.5)
+		color var(--primaryAlpha05)
 
 		&.over
 			color #ec3828
@@ -673,7 +688,7 @@ root(isDark)
 		width 40px
 		height 40px
 		font-size 1em
-		color isDark ? $theme-color : rgba($theme-color, 0.5)
+		color var(--desktopPostFormTransparentButtonFg)
 		background transparent
 		outline none
 		border solid 1px transparent
@@ -681,12 +696,12 @@ root(isDark)
 
 		&:hover
 			background transparent
-			border-color isDark ? rgba($theme-color, 0.5) : rgba($theme-color, 0.3)
+			border-color var(--primaryAlpha03)
 
 		&:active
-			color rgba($theme-color, 0.6)
-			background isDark ? transparent : linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%)
-			border-color rgba($theme-color, 0.5)
+			color var(--primaryAlpha06)
+			background linear-gradient(to bottom, var(--desktopPostFormTransparentButtonActiveGradientStart) 0%, var(--desktopPostFormTransparentButtonActiveGradientEnd) 100%)
+			border-color var(--primaryAlpha05)
 			box-shadow 0 2px 4px rgba(#000, 0.15) inset
 
 		&:focus
@@ -698,7 +713,7 @@ root(isDark)
 				right -5px
 				bottom -5px
 				left -5px
-				border 2px solid rgba($theme-color, 0.3)
+				border 2px solid var(--primaryAlpha03)
 				border-radius 8px
 
 	> .dropzone
@@ -707,13 +722,7 @@ root(isDark)
 		top 0
 		width 100%
 		height 100%
-		border dashed 2px rgba($theme-color, 0.5)
+		border dashed 2px var(--primaryAlpha05)
 		pointer-events none
 
-.mk-post-form[data-darkmode]
-	root(true)
-
-.mk-post-form:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/progress-dialog.vue b/src/client/app/desktop/views/components/progress-dialog.vue
index 2f59733d99..feda6050bc 100644
--- a/src/client/app/desktop/views/components/progress-dialog.vue
+++ b/src/client/app/desktop/views/components/progress-dialog.vue
@@ -1,5 +1,5 @@
 <template>
-<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy">
+<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="destroyDom">
 	<span slot="header">{{ title }}<mk-ellipsis/></span>
 	<div :class="$style.body">
 		<p :class="$style.init" v-if="isNaN(value)">%i18n:@waiting%<mk-ellipsis/></p>
@@ -37,7 +37,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .body
 	padding 18px 24px 24px 24px
@@ -53,7 +53,7 @@ export default Vue.extend({
 	margin 0 0 4px 0
 	text-align center
 	line-height 16px
-	color rgba($theme-color, 0.7)
+	color var(--primaryAlpha07)
 
 	&:after
 		content '%'
@@ -69,21 +69,21 @@ export default Vue.extend({
 	overflow hidden
 
 	&::-webkit-progress-value
-		background $theme-color
+		background var(--primary)
 
 	&::-webkit-progress-bar
-		background rgba($theme-color, 0.1)
+		background var(--primaryAlpha01)
 
 .waiting
 	background linear-gradient(
 		45deg,
-		lighten($theme-color, 30%) 25%,
-		$theme-color               25%,
-		$theme-color               50%,
-		lighten($theme-color, 30%) 50%,
-		lighten($theme-color, 30%) 75%,
-		$theme-color               75%,
-		$theme-color
+		var(--primaryLighten30) 25%,
+		var(--primary)               25%,
+		var(--primary)               50%,
+		var(--primaryLighten30) 50%,
+		var(--primaryLighten30) 75%,
+		var(--primary)               75%,
+		var(--primary)
 	)
 	background-size 32px 32px
 	animation progress-dialog-tag-progress-waiting 1.5s linear infinite
diff --git a/src/client/app/desktop/views/components/received-follow-requests-window.vue b/src/client/app/desktop/views/components/received-follow-requests-window.vue
index 26b7ec2590..3df1329c48 100644
--- a/src/client/app/desktop/views/components/received-follow-requests-window.vue
+++ b/src/client/app/desktop/views/components/received-follow-requests-window.vue
@@ -1,8 +1,8 @@
 <template>
-<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy">
+<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom">
 	<span slot="header">%fa:envelope R% %i18n:@title%</span>
 
-	<div class="slpqaxdoxhvglersgjukmvizkqbmbokc" :data-darkmode="$store.state.device.darkmode">
+	<div class="slpqaxdoxhvglersgjukmvizkqbmbokc">
 		<div v-for="req in requests">
 			<router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link>
 			<span>
@@ -47,8 +47,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-
-root(isDark)
+.slpqaxdoxhvglersgjukmvizkqbmbokc
 	padding 16px
 
 	> button
@@ -57,16 +56,10 @@ root(isDark)
 	> div
 		display flex
 		padding 16px
-		border solid 1px isDark ? #1c2023 : #eee
+		border solid 1px var(--faceDivider)
 		border-radius 4px
 
 		> span
 			margin 0 0 0 auto
 
-.slpqaxdoxhvglersgjukmvizkqbmbokc[data-darkmode]
-	root(true)
-
-.slpqaxdoxhvglersgjukmvizkqbmbokc:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/renote-form-window.vue b/src/client/app/desktop/views/components/renote-form-window.vue
index df9d2f7fc7..b9760fcbe9 100644
--- a/src/client/app/desktop/views/components/renote-form-window.vue
+++ b/src/client/app/desktop/views/components/renote-form-window.vue
@@ -1,7 +1,7 @@
 <template>
-<mk-window ref="window" is-modal @closed="$destroy">
+<mk-window ref="window" is-modal @closed="onWindowClosed" :animation="animation">
 	<span slot="header" :class="$style.header">%fa:retweet%%i18n:@title%</span>
-	<mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled"/>
+	<mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled" v-hotkey.global="keymap"/>
 </mk-window>
 </template>
 
@@ -9,26 +9,48 @@
 import Vue from 'vue';
 
 export default Vue.extend({
-	props: ['note'],
-	mounted() {
-		document.addEventListener('keydown', this.onDocumentKeydown);
+	props: {
+		note: {
+			type: Object,
+			required: true
+		},
+
+		animation: {
+			type: Boolean,
+			required: false,
+			default: true
+		}
 	},
-	beforeDestroy() {
-		document.removeEventListener('keydown', this.onDocumentKeydown);
+
+	computed: {
+		keymap(): any {
+			return {
+				'esc': this.close,
+				'enter': this.post,
+				'q': this.quote,
+			};
+		}
 	},
+
 	methods: {
-		onDocumentKeydown(e) {
-			if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
-				if (e.which == 27) { // Esc
-					(this.$refs.window as any).close();
-				}
-			}
+		post() {
+			(this.$refs.form as any).ok();
+		},
+		quote() {
+			(this.$refs.form as any).onQuote();
+		},
+		close() {
+			(this.$refs.window as any).close();
 		},
 		onPosted() {
 			(this.$refs.window as any).close();
 		},
 		onCanceled() {
 			(this.$refs.window as any).close();
+		},
+		onWindowClosed() {
+			this.$emit('closed');
+			this.destroyDom();
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue
index 38eab3362f..68d485bada 100644
--- a/src/client/app/desktop/views/components/renote-form.vue
+++ b/src/client/app/desktop/views/components/renote-form.vue
@@ -1,11 +1,11 @@
 <template>
 <div class="mk-renote-form">
-	<mk-note-preview :note="note"/>
+	<mk-note-preview class="preview" :note="note"/>
 	<template v-if="!quote">
 		<footer>
 			<a class="quote" v-if="!quote" @click="onQuote">%i18n:@quote%</a>
-			<button class="ui cancel" @click="cancel">%i18n:@cancel%</button>
-			<button class="ui primary ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:@reposting%' : '%i18n:@renote%' }}</button>
+			<ui-button class="button cancel" inline @click="cancel">%i18n:@cancel%</ui-button>
+			<ui-button class="button ok" inline primary @click="ok" :disabled="wait">{{ wait ? '%i18n:@reposting%' : '%i18n:@renote%' }}</ui-button>
 		</footer>
 	</template>
 	<template v-if="quote">
@@ -57,16 +57,13 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-
-	> .mk-note-preview
+.mk-renote-form
+	> .preview
 		margin 16px 22px
 
 	> footer
 		height 72px
-		background isDark ? #313543 : lighten($theme-color, 95%)
+		background var(--desktopRenoteFormFooter)
 
 		> .quote
 			position absolute
@@ -74,7 +71,7 @@ root(isDark)
 			left 28px
 			line-height 40px
 
-		button
+		> .button
 			display block
 			position absolute
 			bottom 16px
@@ -87,10 +84,4 @@ root(isDark)
 			&.ok
 				right 16px
 
-.mk-renote-form[data-darkmode]
-	root(true)
-
-.mk-renote-form:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue
index deb865b102..4247717748 100644
--- a/src/client/app/desktop/views/components/settings-window.vue
+++ b/src/client/app/desktop/views/components/settings-window.vue
@@ -1,13 +1,19 @@
 <template>
-<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy">
+<mk-window ref="window" is-modal width="700px" height="550px" @closed="destroyDom">
 	<span slot="header" :class="$style.header">%fa:cog%%i18n:@settings%</span>
-	<mk-settings @done="close"/>
+	<mk-settings :initial-page="initialPage" @done="close"/>
 </mk-window>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 export default Vue.extend({
+	props: {
+		initialPage: {
+			type: String,
+			required: false
+		}
+	},
 	methods: {
 		close() {
 			(this as any).$refs.window.close();
diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue
index e8a3cc9685..d254b27110 100644
--- a/src/client/app/desktop/views/components/settings.drive.vue
+++ b/src/client/app/desktop/views/components/settings.drive.vue
@@ -1,7 +1,6 @@
 <template>
 <div class="root">
 	<template v-if="!fetching">
-		<el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/>
 		<p><b>{{ capacity | bytes }}</b>%i18n:max%<b>{{ usage | bytes }}</b>%i18n:in-use%</p>
 	</template>
 </div>
diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue
index 262583b640..5f465a52bb 100644
--- a/src/client/app/desktop/views/components/settings.profile.vue
+++ b/src/client/app/desktop/views/components/settings.profile.vue
@@ -6,30 +6,28 @@
 		<button class="ui" @click="updateAvatar">%i18n:@choice-avatar%</button>
 	</label>
 	<label class="ui from group">
-		<p>%i18n:@name%</p>
-		<input v-model="name" type="text" class="ui"/>
+		<ui-input v-model="name" type="text">%i18n:@name%</ui-input>
 	</label>
 	<label class="ui from group">
-		<p>%i18n:@location%</p>
-		<input v-model="location" type="text" class="ui"/>
+		<ui-input v-model="location" type="text">%i18n:@location%</ui-input>
 	</label>
 	<label class="ui from group">
-		<p>%i18n:@description%</p>
-		<textarea v-model="description" class="ui"></textarea>
+		<ui-textarea v-model="description">%i18n:@description%</ui-textarea>
 	</label>
 	<label class="ui from group">
 		<p>%i18n:@birthday%</p>
-		<el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/>
+		<input type="date" v-model="birthday"/>
 	</label>
-	<button class="ui primary" @click="save">%i18n:@save%</button>
+	<ui-button primary @click="save">%i18n:@save%</ui-button>
 	<section>
 		<h2>%i18n:@locked-account%</h2>
-		<mk-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked" text="%i18n:@is-locked%"/>
+		<ui-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked">%i18n:@is-locked%</ui-switch>
 	</section>
 	<section>
 		<h2>%i18n:@other%</h2>
-		<mk-switch v-model="$store.state.i.isBot" @change="onChangeIsBot" text="%i18n:@is-bot%"/>
-		<mk-switch v-model="$store.state.i.isCat" @change="onChangeIsCat" text="%i18n:@is-cat%"/>
+		<ui-switch v-model="$store.state.i.isBot" @change="onChangeIsBot">%i18n:@is-bot%</ui-switch>
+		<ui-switch v-model="$store.state.i.isCat" @change="onChangeIsCat">%i18n:@is-cat%</ui-switch>
+		<ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch>
 	</section>
 </div>
 </template>
@@ -46,6 +44,12 @@ export default Vue.extend({
 			birthday: null,
 		};
 	},
+	computed: {
+		alwaysMarkNsfw: {
+			get() { return this.$store.state.i.settings.alwaysMarkNsfw; },
+			set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); }
+		},
+	},
 	created() {
 		this.name = this.$store.state.i.name || '';
 		this.location = this.$store.state.i.profile.location;
diff --git a/src/client/app/desktop/views/components/settings.signins.vue b/src/client/app/desktop/views/components/settings.signins.vue
index a414c95c27..7d1bb4f4e7 100644
--- a/src/client/app/desktop/views/components/settings.signins.vue
+++ b/src/client/app/desktop/views/components/settings.signins.vue
@@ -23,25 +23,25 @@ export default Vue.extend({
 		return {
 			fetching: true,
 			signins: [],
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
+
 	mounted() {
 		(this as any).api('i/signin_history').then(signins => {
 			this.signins = signins;
 			this.fetching = false;
 		});
 
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
 		this.connection.on('signin', this.onSignin);
 	},
+
 	beforeDestroy() {
-		this.connection.off('signin', this.onSignin);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
+
 	methods: {
 		onSignin(signin) {
 			this.signins.unshift(signin);
diff --git a/src/client/app/desktop/views/components/settings.tags.vue b/src/client/app/desktop/views/components/settings.tags.vue
new file mode 100644
index 0000000000..dfc69a387e
--- /dev/null
+++ b/src/client/app/desktop/views/components/settings.tags.vue
@@ -0,0 +1,58 @@
+<template>
+<div class="vfcitkilproprqtbnpoertpsziierwzi">
+	<div v-for="timeline in timelines" class="timeline">
+		<ui-input v-model="timeline.title" @change="save">
+			<span>%i18n:@title%</span>
+		</ui-input>
+		<ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" @input="onQueryChange(timeline, $event)">
+			<span>%i18n:@query%</span>
+		</ui-textarea>
+		<ui-button class="save" @click="save">%i18n:@save%</ui-button>
+	</div>
+	<ui-button class="add" @click="add">%i18n:@add%</ui-button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as uuid from 'uuid';
+
+export default Vue.extend({
+	data() {
+		return {
+			timelines: this.$store.state.settings.tagTimelines
+		};
+	},
+
+	methods: {
+		add() {
+			this.timelines.push({
+				id: uuid(),
+				title: '',
+				query: ''
+			});
+
+			this.save();
+		},
+
+		save() {
+			this.$store.dispatch('settings/set', { key: 'tagTimelines', value: this.timelines });
+		},
+
+		onQueryChange(timeline, value) {
+			timeline.query = value.split('\n').map(tags => tags.split(' '));
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.vfcitkilproprqtbnpoertpsziierwzi
+	> .timeline
+		padding-bottom 16px
+		border-bottom solid 1px rgba(#000, 0.1)
+
+	> .add
+		margin-top 16px
+
+</style>
diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue
index 7d6f1d55fb..1cb8d4d4c8 100644
--- a/src/client/app/desktop/views/components/settings.vue
+++ b/src/client/app/desktop/views/components/settings.vue
@@ -5,6 +5,7 @@
 		<p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p>
 		<p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%%i18n:@notification%</p>
 		<p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:@drive%</p>
+		<p :class="{ active: page == 'hashtags' }" @mousedown="page = 'hashtags'">%fa:hashtag .fw%%i18n:@tags%</p>
 		<p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:@mute%</p>
 		<p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%%i18n:@apps%</p>
 		<p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p>
@@ -18,19 +19,43 @@
 			<x-profile/>
 		</section>
 
+		<section class="web" v-show="page == 'web'">
+			<h1>%i18n:@theme%</h1>
+			<mk-theme/>
+		</section>
+
 		<section class="web" v-show="page == 'web'">
 			<h1>%i18n:@behaviour%</h1>
-			<mk-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll" text="%i18n:@fetch-on-scroll%">
-				<span>%i18n:@fetch-on-scroll-desc%</span>
-			</mk-switch>
-			<mk-switch v-model="autoPopout" text="%i18n:@auto-popout%">
-				<span>%i18n:@auto-popout-desc%</span>
-			</mk-switch>
+			<ui-switch v-model="fetchOnScroll">
+				%i18n:@fetch-on-scroll%
+				<span slot="desc">%i18n:@fetch-on-scroll-desc%</span>
+			</ui-switch>
+			<ui-switch v-model="autoPopout">
+				%i18n:@auto-popout%
+				<span slot="desc">%i18n:@auto-popout-desc%</span>
+			</ui-switch>
+
+			<section>
+				<header>%i18n:@note-visibility%</header>
+				<ui-switch v-model="rememberNoteVisibility">%i18n:@remember-note-visibility%</ui-switch>
+				<section>
+					<header>%i18n:@default-note-visibility%</header>
+					<ui-select v-model="defaultNoteVisibility">
+						<option value="public">%i18n:common.note-visibility.public%</option>
+						<option value="home">%i18n:common.note-visibility.home%</option>
+						<option value="followers">%i18n:common.note-visibility.followers%</option>
+						<option value="specified">%i18n:common.note-visibility.specified%</option>
+						<option value="private">%i18n:common.note-visibility.private%</option>
+					</ui-select>
+				</section>
+			</section>
+
 			<details>
 				<summary>%i18n:@advanced%</summary>
-				<mk-switch v-model="apiViaStream" text="%i18n:@api-via-stream%">
-					<span>%i18n:@api-via-stream-desc%</span>
-				</mk-switch>
+				<ui-switch v-model="apiViaStream">
+					%i18n:@api-via-stream%
+					<span slot="desc">%i18n:@api-via-stream-desc%</span>
+				</ui-switch>
 			</details>
 		</section>
 
@@ -42,58 +67,61 @@
 			<div class="div">
 				<button class="ui" @click="updateWallpaper">%i18n:@choose-wallpaper%</button>
 				<button class="ui" @click="deleteWallpaper">%i18n:@delete-wallpaper%</button>
-				<mk-switch v-model="darkmode" text="%i18n:@dark-mode%"/>
-				<mk-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons" text="%i18n:@circle-icons%"/>
-				<mk-switch v-model="$store.state.settings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="%i18n:@gradient-window-header%"/>
-				<mk-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi" text="%i18n:common.i-like-sushi%"/>
+				<ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch>
+				<ui-switch v-model="useShadow">%i18n:@use-shadow%</ui-switch>
+				<ui-switch v-model="roundedCorners">%i18n:@rounded-corners%</ui-switch>
+				<ui-switch v-model="circleIcons">%i18n:@circle-icons%</ui-switch>
+				<ui-switch v-model="reduceMotion">%i18n:common.reduce-motion%</ui-switch>
+				<ui-switch v-model="contrastedAcct">%i18n:@contrasted-acct%</ui-switch>
+				<ui-switch v-model="showFullAcct">%i18n:common.show-full-acct%</ui-switch>
+				<ui-switch v-model="iLikeSushi">%i18n:common.i-like-sushi%</ui-switch>
 			</div>
-			<mk-switch v-model="$store.state.settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/>
-			<mk-switch v-model="$store.state.settings.suggestRecentHashtags" @change="onChangeSuggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/>
-			<mk-switch v-model="$store.state.settings.showClockOnHeader" @change="onChangeShowClockOnHeader" text="%i18n:@show-clock-on-header%"/>
-			<mk-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget" text="%i18n:@show-reply-target%"/>
-			<mk-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes" text="%i18n:@show-my-renotes%"/>
-			<mk-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/>
-			<mk-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes" text="%i18n:@show-local-renotes%"/>
-			<mk-switch v-model="$store.state.settings.showMaps" @change="onChangeShowMaps" text="%i18n:@show-maps%">
-				<span>%i18n:@show-maps-desc%</span>
-			</mk-switch>
-			<mk-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm" text="%i18n:common.disable-animated-mfm%"/>
-			<mk-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels" text="%i18n:common.show-reversi-board-labels%"/>
-			<mk-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones" text="%i18n:common.use-contrast-reversi-stones%"/>
+			<ui-switch v-model="showPostFormOnTopOfTl">%i18n:@post-form-on-timeline%</ui-switch>
+			<ui-switch v-model="suggestRecentHashtags">%i18n:@suggest-recent-hashtags%</ui-switch>
+			<ui-switch v-model="showClockOnHeader">%i18n:@show-clock-on-header%</ui-switch>
+			<ui-switch v-model="alwaysShowNsfw">%i18n:common.always-show-nsfw%</ui-switch>
+			<ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch>
+			<ui-switch v-model="showMyRenotes">%i18n:@show-my-renotes%</ui-switch>
+			<ui-switch v-model="showRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch>
+			<ui-switch v-model="showLocalRenotes">%i18n:@show-local-renotes%</ui-switch>
+			<ui-switch v-model="showMaps">%i18n:@show-maps%</ui-switch>
+			<ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch>
+			<ui-switch v-model="games_reversi_showBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch>
+			<ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch>
 		</section>
 
 		<section class="web" v-show="page == 'web'">
 			<h1>%i18n:@sound%</h1>
-			<mk-switch v-model="enableSounds" text="%i18n:@enable-sounds%">
-				<span>%i18n:@enable-sounds-desc%</span>
-			</mk-switch>
+			<ui-switch v-model="enableSounds">
+				%i18n:@enable-sounds%
+				<span slot="desc">%i18n:@enable-sounds-desc%</span>
+			</ui-switch>
 			<label>%i18n:@volume%</label>
-			<el-slider
+			<input type="range"
 				v-model="soundVolume"
-				:show-input="true"
-				:format-tooltip="v => `${v * 100}%`"
 				:disabled="!enableSounds"
-				:max="1"
-				:step="0.1"
+				max="1"
+				step="0.1"
 			/>
 			<button class="ui button" @click="soundTest">%fa:volume-up% %i18n:@test%</button>
 		</section>
 
 		<section class="web" v-show="page == 'web'">
 			<h1>%i18n:@mobile%</h1>
-			<mk-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile" text="%i18n:@disable-via-mobile%"/>
+			<ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch>
 		</section>
 
 		<section class="web" v-show="page == 'web'">
 			<h1>%i18n:@language%</h1>
-			<el-select v-model="lang" placeholder="%i18n:@pick-language%">
-				<el-option-group label="%i18n:@recommended%">
-					<el-option label="%i18n:@auto%" :value="null"/>
-				</el-option-group>
-				<el-option-group label="%i18n:@specify-language%">
-					<el-option v-for="x in langs" :label="x[1]" :value="x[0]" :key="x[0]"/>
-				</el-option-group>
-			</el-select>
+			<select v-model="lang" placeholder="%i18n:@pick-language%">
+				<optgroup label="%i18n:@recommended%">
+					<option value="">%i18n:@auto%</option>
+				</optgroup>
+
+				<optgroup label="%i18n:@specify-language%">
+					<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+				</optgroup>
+			</select>
 			<div class="none ui info">
 				<p>%fa:info-circle%%i18n:@language-desc%</p>
 			</div>
@@ -109,9 +137,10 @@
 
 		<section class="notification" v-show="page == 'notification'">
 			<h1>%i18n:@notification%</h1>
-			<mk-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch" text="%i18n:@auto-watch%">
-				<span>%i18n:@auto-watch-desc%</span>
-			</mk-switch>
+			<ui-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch">
+				%i18n:@auto-watch%
+				<span slot="desc">%i18n:@auto-watch-desc%</span>
+			</ui-switch>
 		</section>
 
 		<section class="drive" v-show="page == 'drive'">
@@ -119,6 +148,11 @@
 			<x-drive/>
 		</section>
 
+		<section class="hashtags" v-show="page == 'hashtags'">
+			<h1>%i18n:@tags%</h1>
+			<x-tags/>
+		</section>
+
 		<section class="mute" v-show="page == 'mute'">
 			<h1>%i18n:@mute%</h1>
 			<x-mute/>
@@ -174,24 +208,23 @@
 			</button>
 			<details>
 				<summary>%i18n:@update-settings%</summary>
-				<mk-switch v-model="preventUpdate" text="%i18n:@prevent-update%">
-					<span>%i18n:@prevent-update-desc%</span>
-				</mk-switch>
+				<ui-switch v-model="preventUpdate">
+					%i18n:@prevent-update%
+					<span slot="desc">%i18n:@prevent-update-desc%</span>
+				</ui-switch>
 			</details>
 		</section>
 
 		<section class="other" v-show="page == 'other'">
 			<h1>%i18n:@advanced-settings%</h1>
-			<mk-switch v-model="debug" text="%i18n:@debug-mode%">
-				<span>%i18n:@debug-mode-desc%</span>
-			</mk-switch>
-			<mk-switch v-model="enableExperimentalFeatures" text="%i18n:@experimental%">
-				<span>%i18n:@experimental-desc%</span>
-			</mk-switch>
-			<details v-if="debug">
-				<summary>%i18n:@tools%</summary>
-				<button class="ui button block" @click="taskmngr">%i18n:@task-manager%</button>
-			</details>
+			<ui-switch v-model="debug">
+				%i18n:@debug-mode%
+				<span slot="desc">%i18n:@debug-mode-desc%</span>
+			</ui-switch>
+			<ui-switch v-model="enableExperimentalFeatures">
+				%i18n:@experimental%
+				<span slot="desc">%i18n:@experimental-desc%</span>
+			</ui-switch>
 		</section>
 	</div>
 </div>
@@ -207,9 +240,9 @@ import XApi from './settings.api.vue';
 import XApps from './settings.apps.vue';
 import XSignins from './settings.signins.vue';
 import XDrive from './settings.drive.vue';
+import XTags from './settings.tags.vue';
 import { url, langs, version } from '../../../config';
 import checkForUpdate from '../../../common/scripts/check-for-update';
-import MkTaskManager from './taskmanager.vue';
 
 export default Vue.extend({
 	components: {
@@ -220,11 +253,18 @@ export default Vue.extend({
 		XApi,
 		XApps,
 		XSignins,
-		XDrive
+		XDrive,
+		XTags
+	},
+	props: {
+		initialPage: {
+			type: String,
+			required: false
+		}
 	},
 	data() {
 		return {
-			page: 'profile',
+			page: this.initialPage || 'profile',
 			meta: null,
 			version,
 			langs,
@@ -233,6 +273,11 @@ export default Vue.extend({
 		};
 	},
 	computed: {
+		reduceMotion: {
+			get() { return this.$store.state.device.reduceMotion; },
+			set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); }
+		},
+
 		apiViaStream: {
 			get() { return this.$store.state.device.apiViaStream; },
 			set(value) { this.$store.commit('device/set', { key: 'apiViaStream', value }); }
@@ -276,6 +321,116 @@ export default Vue.extend({
 		enableExperimentalFeatures: {
 			get() { return this.$store.state.device.enableExperimentalFeatures; },
 			set(value) { this.$store.commit('device/set', { key: 'enableExperimentalFeatures', value }); }
+		},
+
+		alwaysShowNsfw: {
+			get() { return this.$store.state.device.alwaysShowNsfw; },
+			set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); }
+		},
+
+		useShadow: {
+			get() { return this.$store.state.settings.useShadow; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'useShadow', value }); }
+		},
+
+		roundedCorners: {
+			get() { return this.$store.state.settings.roundedCorners; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'roundedCorners', value }); }
+		},
+
+		fetchOnScroll: {
+			get() { return this.$store.state.settings.fetchOnScroll; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); }
+		},
+
+		rememberNoteVisibility: {
+			get() { return this.$store.state.settings.rememberNoteVisibility; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
+		},
+
+		defaultNoteVisibility: {
+			get() { return this.$store.state.settings.defaultNoteVisibility; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
+		},
+
+		showReplyTarget: {
+			get() { return this.$store.state.settings.showReplyTarget; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); }
+		},
+
+		showMyRenotes: {
+			get() { return this.$store.state.settings.showMyRenotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); }
+		},
+
+		showRenotedMyNotes: {
+			get() { return this.$store.state.settings.showRenotedMyNotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); }
+		},
+
+		showLocalRenotes: {
+			get() { return this.$store.state.settings.showLocalRenotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); }
+		},
+
+		showPostFormOnTopOfTl: {
+			get() { return this.$store.state.settings.showPostFormOnTopOfTl; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showPostFormOnTopOfTl', value }); }
+		},
+
+		suggestRecentHashtags: {
+			get() { return this.$store.state.settings.suggestRecentHashtags; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'suggestRecentHashtags', value }); }
+		},
+
+		showClockOnHeader: {
+			get() { return this.$store.state.settings.showClockOnHeader; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showClockOnHeader', value }); }
+		},
+
+		showMaps: {
+			get() { return this.$store.state.settings.showMaps; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showMaps', value }); }
+		},
+
+		circleIcons: {
+			get() { return this.$store.state.settings.circleIcons; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); }
+		},
+
+		contrastedAcct: {
+			get() { return this.$store.state.settings.contrastedAcct; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); }
+		},
+
+		showFullAcct: {
+			get() { return this.$store.state.settings.showFullAcct; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); }
+		},
+
+		iLikeSushi: {
+			get() { return this.$store.state.settings.iLikeSushi; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); }
+		},
+
+		games_reversi_showBoardLabels: {
+			get() { return this.$store.state.settings.games.reversi.showBoardLabels; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); }
+		},
+
+		games_reversi_useContrastStones: {
+			get() { return this.$store.state.settings.games.reversi.useContrastStones; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); }
+		},
+
+		disableAnimatedMfm: {
+			get() { return this.$store.state.settings.disableAnimatedMfm; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); }
+		},
+
+		disableViaMobile: {
+			get() { return this.$store.state.settings.disableViaMobile; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); }
 		}
 	},
 	created() {
@@ -284,9 +439,6 @@ export default Vue.extend({
 		});
 	},
 	methods: {
-		taskmngr() {
-			(this as any).os.new(MkTaskManager);
-		},
 		customizeHome() {
 			this.$router.push('/i/customize-home');
 			this.$emit('done');
@@ -305,113 +457,11 @@ export default Vue.extend({
 				wallpaperId: null
 			});
 		},
-		onChangeFetchOnScroll(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'fetchOnScroll',
-				value: v
-			});
-		},
 		onChangeAutoWatch(v) {
 			(this as any).api('i/update', {
 				autoWatch: v
 			});
 		},
-		onChangeDark(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'dark',
-				value: v
-			});
-		},
-		onChangeShowPostFormOnTopOfTl(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showPostFormOnTopOfTl',
-				value: v
-			});
-		},
-		onChangeSuggestRecentHashtags(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'suggestRecentHashtags',
-				value: v
-			});
-		},
-		onChangeShowClockOnHeader(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showClockOnHeader',
-				value: v
-			});
-		},
-		onChangeShowReplyTarget(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showReplyTarget',
-				value: v
-			});
-		},
-		onChangeShowMyRenotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showMyRenotes',
-				value: v
-			});
-		},
-		onChangeShowRenotedMyNotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showRenotedMyNotes',
-				value: v
-			});
-		},
-		onChangeShowLocalRenotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showLocalRenotes',
-				value: v
-			});
-		},
-		onChangeShowMaps(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showMaps',
-				value: v
-			});
-		},
-		onChangeCircleIcons(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'circleIcons',
-				value: v
-			});
-		},
-		onChangeILikeSushi(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'iLikeSushi',
-				value: v
-			});
-		},
-		onChangeReversiBoardLabels(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'games.reversi.showBoardLabels',
-				value: v
-			});
-		},
-		onChangeUseContrastReversiStones(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'games.reversi.useContrastStones',
-				value: v
-			});
-		},
-		onChangeDisableAnimatedMfm(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'disableAnimatedMfm',
-				value: v
-			});
-		},
-		onChangeGradientWindowHeader(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'gradientWindowHeader',
-				value: v
-			});
-		},
-		onChangeDisableViaMobile(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'disableViaMobile',
-				value: v
-			});
-		},
 		checkForUpdate() {
 			this.checkingForUpdate = true;
 			checkForUpdate((this as any).os, true, true).then(newer => {
@@ -447,9 +497,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-settings
 	display flex
 	width 100%
 	height 100%
@@ -460,13 +508,13 @@ root(isDark)
 		height 100%
 		padding 16px 0 0 0
 		overflow auto
-		border-right solid 1px isDark ? #1c2023 : #ddd
+		border-right solid 1px var(--faceDivider)
 
 		> p
 			display block
 			padding 10px 16px
 			margin 0
-			color isDark ? #9aa2a7 : #666
+			color var(--desktopSettingsNavItem)
 			cursor pointer
 			user-select none
 			transition margin-left 0.2s ease
@@ -475,11 +523,11 @@ root(isDark)
 				margin-right 4px
 
 			&:hover
-				color isDark ? #fff : #555
+				color var(--desktopSettingsNavItemHover)
 
 			&.active
 				margin-left 8px
-				color $theme-color !important
+				color var(--primary) !important
 
 	> .pages
 		width 100%
@@ -489,14 +537,13 @@ root(isDark)
 
 		> section
 			margin 32px
-			color isDark ? #c4ccd2 : #4a535a
+			color var(--text)
 
 			> h1
 				margin 0 0 1em 0
 				padding 0 0 8px 0
 				font-size 1em
-				color isDark ? #e3e7ea : #555
-				border-bottom solid 1px isDark ? #1c2023 : #eee
+				border-bottom solid 1px var(--faceDivider)
 
 			&, >>> *
 				.ui.button.block
@@ -509,18 +556,12 @@ root(isDark)
 						margin 0 0 1em 0
 						padding 0 0 8px 0
 						font-size 1em
-						color isDark ? #e3e7ea : #555
-						border-bottom solid 1px isDark ? #1c2023 : #eee
+						color var(--text)
+						border-bottom solid 1px var(--faceDivider)
 
 		> .web
 			> .div
-				border-bottom solid 1px isDark ? #1c2023 : #eee
+				border-bottom solid 1px var(--faceDivider)
 				margin 16px 0
 
-.mk-settings[data-darkmode]
-	root(true)
-
-.mk-settings:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue
index cb0374b910..fd8e658056 100644
--- a/src/client/app/desktop/views/components/sub-note-content.vue
+++ b/src/client/app/desktop/views/components/sub-note-content.vue
@@ -7,9 +7,9 @@
 		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
 		<a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RP: ...</a>
 	</div>
-	<details v-if="note.media.length > 0">
-		<summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary>
-		<mk-media-list :media-list="note.media"/>
+	<details v-if="note.files.length > 0">
+		<summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary>
+		<mk-media-list :media-list="note.files"/>
 	</details>
 	<details v-if="note.poll">
 		<summary>%i18n:@poll%</summary>
@@ -38,7 +38,7 @@ export default Vue.extend({
 		> .rp
 			margin-left 4px
 			font-style oblique
-			color #a0bf46
+			color var(--renoteText)
 
 	mk-poll
 		font-size 80%
diff --git a/src/client/app/desktop/views/components/taskmanager.vue b/src/client/app/desktop/views/components/taskmanager.vue
deleted file mode 100644
index 1f1385add8..0000000000
--- a/src/client/app/desktop/views/components/taskmanager.vue
+++ /dev/null
@@ -1,219 +0,0 @@
-<template>
-<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager">
-	<span slot="header" :class="$style.header">%fa:stethoscope%%i18n:@title%</span>
-	<el-tabs :class="$style.content">
-		<el-tab-pane label="Requests">
-			<el-table
-				:data="os.requests"
-				style="width: 100%"
-				:default-sort="{prop: 'date', order: 'descending'}"
-			>
-				<el-table-column type="expand">
-					<template slot-scope="props">
-						<pre>{{ props.row.data }}</pre>
-						<pre>{{ props.row.res }}</pre>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="Requested at"
-					prop="date"
-					sortable
-				>
-					<template slot-scope="scope">
-						<b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b>
-						<span>(<mk-time :time="scope.row.date"/>)</span>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="Name"
-				>
-					<template slot-scope="scope">
-						<b>{{ scope.row.name }}</b>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="Status"
-				>
-					<template slot-scope="scope">
-						<span>{{ scope.row.status || '(pending)' }}</span>
-					</template>
-				</el-table-column>
-			</el-table>
-		</el-tab-pane>
-
-		<el-tab-pane label="Streams">
-			<el-table
-				:data="os.connections"
-				style="width: 100%"
-			>
-				<el-table-column
-					label="Uptime"
-				>
-					<template slot-scope="scope">
-						<mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/>
-						<span v-else>-</span>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="Name"
-				>
-					<template slot-scope="scope">
-						<b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="User"
-				>
-					<template slot-scope="scope">
-						<span>{{ scope.row.user || '(anonymous)' }}</span>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					prop="state"
-					label="State"
-				/>
-
-				<el-table-column
-					prop="in"
-					label="In"
-				/>
-
-				<el-table-column
-					prop="out"
-					label="Out"
-				/>
-			</el-table>
-		</el-tab-pane>
-
-		<el-tab-pane label="Streams (Inspect)">
-			<el-tabs type="card" style="height:50%">
-				<el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab">
-					<div style="padding: 12px 0 0 12px">
-					<el-button size="mini" @click="send(c)">Send</el-button>
-					<el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button>
-					<el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button>
-					<el-button size="mini" type="danger" @click="c.close">Disconnect</el-button>
-				</div>
-
-					<el-table
-						:data="c.inout"
-						style="width: 100%"
-						:default-sort="{prop: 'at', order: 'descending'}"
-					>
-						<el-table-column type="expand">
-							<template slot-scope="props">
-								<pre>{{ props.row.data }}</pre>
-							</template>
-						</el-table-column>
-
-						<el-table-column
-							label="Date"
-							prop="at"
-							sortable
-						>
-							<template slot-scope="scope">
-								<b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b>
-								<span>(<mk-time :time="scope.row.at"/>)</span>
-							</template>
-						</el-table-column>
-
-						<el-table-column
-							label="Type"
-						>
-							<template slot-scope="scope">
-								<span>{{ getMessageType(scope.row.data) }}</span>
-							</template>
-						</el-table-column>
-
-						<el-table-column
-							label="Incoming / Outgoing"
-							prop="type"
-						/>
-					</el-table>
-				</el-tab-pane>
-			</el-tabs>
-		</el-tab-pane>
-
-		<el-tab-pane label="Windows">
-			<el-table
-				:data="Array.from(os.windows.windows)"
-				style="width: 100%"
-			>
-				<el-table-column
-					label="Name"
-				>
-					<template slot-scope="scope">
-						<b>{{ scope.row.name || '(unknown)' }}</b>
-					</template>
-				</el-table-column>
-
-				<el-table-column
-					label="Operations"
-				>
-					<template slot-scope="scope">
-						<el-button size="mini" type="danger" @click="scope.row.close">Close</el-button>
-					</template>
-				</el-table-column>
-			</el-table>
-		</el-tab-pane>
-	</el-tabs>
-</mk-window>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-export default Vue.extend({
-	mounted() {
-		(this as any).os.windows.on('added', this.onWindowsChanged);
-		(this as any).os.windows.on('removed', this.onWindowsChanged);
-	},
-	beforeDestroy() {
-		(this as any).os.windows.off('added', this.onWindowsChanged);
-		(this as any).os.windows.off('removed', this.onWindowsChanged);
-	},
-	methods: {
-		getMessageType(data): string {
-			return data.type ? data.type : '-';
-		},
-		onWindowsChanged() {
-			this.$forceUpdate();
-		},
-		send(c) {
-			(this as any).apis.input({
-				title: 'Send a JSON message',
-				allowEmpty: false
-			}).then(json => {
-				c.send(JSON.parse(json));
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" module>
-.header
-	> [data-fa]
-		margin-right 4px
-
-.content
-	height 100%
-	overflow auto
-
-</style>
-
-<style>
-.el-tabs__header {
-	margin-bottom: 0 !important;
-}
-
-.el-tabs__item {
-	padding: 0 20px !important;
-}
-</style>
diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue
index 25fd5d36ac..2c17e936eb 100644
--- a/src/client/app/desktop/views/components/timeline.core.vue
+++ b/src/client/app/desktop/views/components/timeline.core.vue
@@ -23,6 +23,9 @@ export default Vue.extend({
 		src: {
 			type: String,
 			required: true
+		},
+		tagTl: {
+			required: false
 		}
 	},
 
@@ -32,8 +35,14 @@ export default Vue.extend({
 			moreFetching: false,
 			existMore: false,
 			connection: null,
-			connectionId: null,
-			date: null
+			date: null,
+			baseQuery: {
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			},
+			query: {},
+			endpoint: null
 		};
 	},
 
@@ -42,53 +51,67 @@ export default Vue.extend({
 			return this.$store.state.i.followingCount == 0;
 		},
 
-		stream(): any {
-			switch (this.src) {
-				case 'home': return (this as any).os.stream;
-				case 'local': return (this as any).os.streams.localTimelineStream;
-				case 'hybrid': return (this as any).os.streams.hybridTimelineStream;
-				case 'global': return (this as any).os.streams.globalTimelineStream;
-			}
-		},
-
-		endpoint(): string {
-			switch (this.src) {
-				case 'home': return 'notes/timeline';
-				case 'local': return 'notes/local-timeline';
-				case 'hybrid': return 'notes/hybrid-timeline';
-				case 'global': return 'notes/global-timeline';
-			}
-		},
-
 		canFetchMore(): boolean {
 			return !this.moreFetching && !this.fetching && this.existMore;
 		}
 	},
 
 	mounted() {
-		this.connection = this.stream.getConnection();
-		this.connectionId = this.stream.use();
+		const prepend = note => {
+			(this.$refs.timeline as any).prepend(note);
+		};
 
-		this.connection.on('note', this.onNote);
-		if (this.src == 'home') {
-			this.connection.on('follow', this.onChangeFollowing);
-			this.connection.on('unfollow', this.onChangeFollowing);
+		if (this.src == 'tag') {
+			this.endpoint = 'notes/search_by_tag';
+			this.query = {
+				query: this.tagTl.query
+			};
+			this.connection = (this as any).os.stream.connectToChannel('hashtag', { q: this.tagTl.query });
+			this.connection.on('note', prepend);
+		} else if (this.src == 'home') {
+			this.endpoint = 'notes/timeline';
+			const onChangeFollowing = () => {
+				this.fetch();
+			};
+			this.connection = (this as any).os.stream.useSharedConnection('homeTimeline');
+			this.connection.on('note', prepend);
+			this.connection.on('follow', onChangeFollowing);
+			this.connection.on('unfollow', onChangeFollowing);
+		} else if (this.src == 'local') {
+			this.endpoint = 'notes/local-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('localTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'hybrid') {
+			this.endpoint = 'notes/hybrid-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('hybridTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'global') {
+			this.endpoint = 'notes/global-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('globalTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'mentions') {
+			this.endpoint = 'notes/mentions';
+			this.connection = (this as any).os.stream.useSharedConnection('main');
+			this.connection.on('mention', prepend);
+		} else if (this.src == 'messages') {
+			this.endpoint = 'notes/mentions';
+			this.query = {
+				visibility: 'specified'
+			};
+			const onNote = note => {
+				if (note.visibility == 'specified') {
+					prepend(note);
+				}
+			};
+			this.connection = (this as any).os.stream.useSharedConnection('main');
+			this.connection.on('mention', onNote);
 		}
 
-		document.addEventListener('keydown', this.onKeydown);
-
 		this.fetch();
 	},
 
 	beforeDestroy() {
-		this.connection.off('note', this.onNote);
-		if (this.src == 'home') {
-			this.connection.off('follow', this.onChangeFollowing);
-			this.connection.off('unfollow', this.onChangeFollowing);
-		}
-		this.stream.dispose(this.connectionId);
-
-		document.removeEventListener('keydown', this.onKeydown);
+		this.connection.dispose();
 	},
 
 	methods: {
@@ -96,13 +119,10 @@ export default Vue.extend({
 			this.fetching = true;
 
 			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				(this as any).api(this.endpoint, {
+				(this as any).api(this.endpoint, Object.assign({
 					limit: fetchLimit + 1,
-					untilDate: this.date ? this.date.getTime() : undefined,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).then(notes => {
+					untilDate: this.date ? this.date.getTime() : undefined
+				}, this.baseQuery, this.query)).then(notes => {
 					if (notes.length == fetchLimit + 1) {
 						notes.pop();
 						this.existMore = true;
@@ -119,13 +139,10 @@ export default Vue.extend({
 
 			this.moreFetching = true;
 
-			const promise = (this as any).api(this.endpoint, {
+			const promise = (this as any).api(this.endpoint, Object.assign({
 				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
+				untilId: (this.$refs.timeline as any).tail().id
+			}, this.baseQuery, this.query));
 
 			promise.then(notes => {
 				if (notes.length == fetchLimit + 1) {
@@ -140,15 +157,6 @@ export default Vue.extend({
 			return promise;
 		},
 
-		onNote(note) {
-			// Prepend a note
-			(this.$refs.timeline as any).prepend(note);
-		},
-
-		onChangeFollowing() {
-			this.fetch();
-		},
-
 		focus() {
 			(this.$refs.timeline as any).focus();
 		},
@@ -156,21 +164,13 @@ export default Vue.extend({
 		warp(date) {
 			this.date = date;
 			this.fetch();
-		},
-
-		onKeydown(e) {
-			if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
-				if (e.which == 84) { // t
-					this.focus();
-				}
-			}
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-timeline-core
 	> .mk-friends-maker
diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue
index 52a7753438..3e4c45d228 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -2,16 +2,25 @@
 <div class="mk-timeline">
 	<header>
 		<span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span>
-		<span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span>
-		<span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span>
+		<span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span>
+		<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span>
 		<span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span>
+		<span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl">%fa:hashtag% {{ tagTl.title }}</span>
 		<span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
-		<button @click="chooseList" title="%i18n:@list%">%fa:list%</button>
+		<div class="buttons">
+			<button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></button>
+			<button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></button>
+			<button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button>
+			<button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button>
+		</div>
 	</header>
 	<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
 	<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
 	<x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
 	<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
+	<x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
+	<x-core v-if="src == 'messages'" ref="tl" key="messages" src="messages"/>
+	<x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
 	<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
 </div>
 </template>
@@ -19,7 +28,8 @@
 <script lang="ts">
 import Vue from 'vue';
 import XCore from './timeline.core.vue';
-import MkUserListsWindow from './user-lists-window.vue';
+import Menu from '../../../common/views/components/menu.vue';
+import MkSettingsWindow from './settings-window.vue';
 
 export default Vue.extend({
 	components: {
@@ -29,7 +39,9 @@ export default Vue.extend({
 	data() {
 		return {
 			src: 'home',
-			list: null
+			list: null,
+			tagTl: null,
+			enableLocalTimeline: false
 		};
 	},
 
@@ -38,16 +50,28 @@ export default Vue.extend({
 			this.saveSrc();
 		},
 
-		list() {
+		list(x) {
 			this.saveSrc();
+			if (x != null) this.tagTl = null;
+		},
+
+		tagTl(x) {
+			this.saveSrc();
+			if (x != null) this.list = null;
 		}
 	},
 
 	created() {
+		(this as any).os.getMeta().then(meta => {
+			this.enableLocalTimeline = !meta.disableLocalTimeline;
+		});
+
 		if (this.$store.state.device.tl) {
 			this.src = this.$store.state.device.tl.src;
 			if (this.src == 'list') {
 				this.list = this.$store.state.device.tl.arg;
+			} else if (this.src == 'tag') {
+				this.tagTl = this.$store.state.device.tl.arg;
 			}
 		} else if (this.$store.state.i.followingCount == 0) {
 			this.src = 'hybrid';
@@ -64,20 +88,86 @@ export default Vue.extend({
 		saveSrc() {
 			this.$store.commit('device/setTl', {
 				src: this.src,
-				arg: this.list
+				arg: this.src == 'list' ? this.list : this.tagTl
 			});
 		},
 
+		focus() {
+			(this.$refs.tl as any).focus();
+		},
+
 		warp(date) {
 			(this.$refs.tl as any).warp(date);
 		},
 
-		chooseList() {
-			const w = (this as any).os.new(MkUserListsWindow);
-			w.$once('choosen', list => {
-				this.list = list;
-				this.src = 'list';
-				w.close();
+		async chooseList() {
+			const lists = await (this as any).api('users/lists/list');
+
+			let menu = [{
+				icon: '%fa:plus%',
+				text: '%i18n:@add-list%',
+				action: () => {
+					(this as any).apis.input({
+						title: '%i18n:@list-name%',
+					}).then(async title => {
+						const list = await (this as any).api('users/lists/create', {
+							title
+						});
+
+						this.list = list;
+						this.src = 'list';
+					});
+				}
+			}];
+
+			if (lists.length > 0) {
+				menu.push(null);
+			}
+
+			menu = menu.concat(lists.map(list => ({
+				icon: '%fa:list%',
+				text: list.title,
+				action: () => {
+					this.list = list;
+					this.src = 'list';
+				}
+			})));
+
+			this.os.new(Menu, {
+				source: this.$refs.listButton,
+				compact: false,
+				items: menu
+			});
+		},
+
+		chooseTag() {
+			let menu = [{
+				icon: '%fa:plus%',
+				text: '%i18n:@add-tag-timeline%',
+				action: () => {
+					(this as any).os.new(MkSettingsWindow, {
+						initialPage: 'hashtags'
+					});
+				}
+			}];
+
+			if (this.$store.state.settings.tagTimelines.length > 0) {
+				menu.push(null);
+			}
+
+			menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({
+				icon: '%fa:hashtag%',
+				text: t.title,
+				action: () => {
+					this.tagTl = t;
+					this.src = 'tag';
+				}
+			})));
+
+			this.os.new(Menu, {
+				source: this.$refs.tagButton,
+				compact: false,
+				items: menu
 			});
 		}
 	}
@@ -85,36 +175,54 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.mk-timeline
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
+	overflow hidden
 
 	> header
 		padding 0 8px
 		z-index 10
-		background isDark ? #313543 : #fff
-		border-radius 6px 6px 0 0
-		box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08)
+		background var(--faceHeader)
+		box-shadow 0 1px var(--desktopTimelineHeaderShadow)
 
-		> button
+		> .buttons
 			position absolute
 			z-index 2
 			top 0
 			right 0
-			padding 0
-			width 42px
-			font-size 0.9em
-			line-height 42px
-			color isDark ? #9baec8 : #ccc
+			padding-right 8px
 
-			&:hover
-				color isDark ? #b2c1d5 : #aaa
+			> button
+				padding 0 8px
+				font-size 0.9em
+				line-height 42px
+				color var(--faceTextButton)
 
-			&:active
-				color isDark ? #b2c1d5 : #999
+				> .badge
+					position absolute
+					top -4px
+					right 4px
+					font-size 10px
+					color var(--primary)
+
+				&:hover
+					color var(--faceTextButtonHover)
+
+				&[data-active]
+					color var(--primary)
+					cursor default
+
+					&:before
+						content ""
+						display block
+						position absolute
+						bottom 0
+						left 0
+						width 100%
+						height 2px
+						background var(--primary)
 
 		> span
 			display inline-block
@@ -124,7 +232,7 @@ root(isDark)
 			user-select none
 
 			&[data-active]
-				color $theme-color
+				color var(--primary)
 				cursor default
 				font-weight bold
 
@@ -136,19 +244,13 @@ root(isDark)
 					left -8px
 					width calc(100% + 16px)
 					height 2px
-					background $theme-color
+					background var(--primary)
 
 			&:not([data-active])
-				color isDark ? #9aa2a7 : #6f7477
+				color var(--desktopTimelineSrc)
 				cursor pointer
 
 				&:hover
-					color isDark ? #d9dcde : #525a5f
-
-.mk-timeline[data-darkmode]
-	root(true)
-
-.mk-timeline:not([data-darkmode])
-	root(false)
+					color var(--desktopTimelineSrcHover)
 
 </style>
diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue
index 68413914c0..dafede4c36 100644
--- a/src/client/app/desktop/views/components/ui-notification.vue
+++ b/src/client/app/desktop/views/components/ui-notification.vue
@@ -27,7 +27,7 @@ export default Vue.extend({
 					translateY: -64,
 					duration: 500,
 					easing: 'easeInElastic',
-					complete: () => this.$destroy()
+					complete: () => this.destroyDom()
 				});
 			}, 6000);
 		});
@@ -36,7 +36,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-ui-notification
 	display block
 	position fixed
 	z-index 10000
@@ -46,10 +46,10 @@ root(isDark)
 	margin 0 auto
 	padding 128px 0 0 0
 	width 500px
-	color rgba(isDark ? #fff : #000, 0.6)
-	background rgba(isDark ? #282C37 : #fff, 0.9)
+	color var(--desktopNotificationFg)
+	background var(--desktopNotificationBg)
 	border-radius 0 0 8px 8px
-	box-shadow 0 2px 4px rgba(#000, isDark ? 0.4 : 0.2)
+	box-shadow 0 2px 4px var(--desktopNotificationShadow)
 	transform translateY(-64px)
 	opacity 0
 
@@ -58,10 +58,4 @@ root(isDark)
 		line-height 64px
 		text-align center
 
-.mk-ui-notification[data-darkmode]
-	root(true)
-
-.mk-ui-notification:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue
index 5e26389d89..a541dea121 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="account">
+<div class="account" v-hotkey.global="keymap">
 	<button class="header" :data-active="isOpen" @click="toggle">
 		<span class="username">{{ $store.state.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span>
 		<mk-avatar class="avatar" :user="$store.state.i"/>
@@ -63,6 +63,13 @@ export default Vue.extend({
 			isOpen: false
 		};
 	},
+	computed: {
+		keymap(): any {
+			return {
+				'a|m': this.toggle
+			};
+		}
+	},
 	beforeDestroy() {
 		this.close();
 	},
@@ -120,14 +127,12 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.account
 	> .header
 		display block
 		margin 0
 		padding 0
-		color #9eaba8
+		color var(--desktopHeaderFg)
 		border none
 		background transparent
 		cursor pointer
@@ -137,14 +142,11 @@ root(isDark)
 
 		&:hover
 		&[data-active='true']
-			color isDark ? #fff : darken(#9eaba8, 20%)
+			color var(--desktopHeaderHoverFg)
 
 			> .avatar
 				filter saturate(150%)
 
-		&:active
-			color isDark ? #fff : darken(#9eaba8, 30%)
-
 		> .username
 			display block
 			float left
@@ -170,7 +172,7 @@ root(isDark)
 			transition filter 100ms ease
 
 	> .menu
-		$bgcolor = isDark ? #282c37 : #fff
+		$bgcolor = var(--face)
 		display block
 		position absolute
 		top 56px
@@ -213,7 +215,7 @@ root(isDark)
 
 			& + ul
 				padding-top 10px
-				border-top solid 1px isDark ? #1c2023 : #eee
+				border-top solid 1px var(--faceDivider)
 
 			> li
 				display block
@@ -227,7 +229,7 @@ root(isDark)
 					padding 0 28px
 					margin 0
 					line-height 40px
-					color isDark ? #c8cece : #868C8C
+					color var(--text)
 					cursor pointer
 
 					*
@@ -242,8 +244,8 @@ root(isDark)
 							padding 2px 8px
 							font-size 90%
 							font-style normal
-							background $theme-color
-							color $theme-color-foreground
+							background var(--primary)
+							color var(--primaryForeground)
 							border-radius 8px
 
 					> [data-fa]:first-child
@@ -262,11 +264,11 @@ root(isDark)
 
 					&:hover, &:active
 						text-decoration none
-						background $theme-color
-						color $theme-color-foreground
+						background var(--primary)
+						color var(--primaryForeground)
 
 					&:active
-						background darken($theme-color, 10%)
+						background var(--primaryDarken10)
 
 					&.signout
 						$color = #e64137
@@ -283,10 +285,4 @@ root(isDark)
 	transform-origin: center -16px;
 }
 
-.account[data-darkmode]
-	root(true)
-
-.account:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.clock.vue b/src/client/app/desktop/views/components/ui.header.clock.vue
index 1c3f12f2f2..b8b638bc41 100644
--- a/src/client/app/desktop/views/components/ui.header.clock.vue
+++ b/src/client/app/desktop/views/components/ui.header.clock.vue
@@ -89,7 +89,7 @@ export default Vue.extend({
 			display table-cell
 			vertical-align middle
 			height 48px
-			color #9eaba8
+			color var(--desktopHeaderFg)
 
 			> .yyyymmdd
 				opacity 0.7
diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue
index 6292b764c6..122570a696 100644
--- a/src/client/app/desktop/views/components/ui.header.nav.vue
+++ b/src/client/app/desktop/views/components/ui.header.nav.vue
@@ -42,8 +42,7 @@ export default Vue.extend({
 	data() {
 		return {
 			hasGameInvitations: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	computed: {
@@ -53,18 +52,15 @@ export default Vue.extend({
 	},
 	mounted() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
+			this.connection = (this as any).os.stream.useSharedConnection('main');
 
-			this.connection.on('reversi_invited', this.onReversiInvited);
+			this.connection.on('reversiInvited', this.onReversiInvited);
 			this.connection.on('reversi_no_invites', this.onReversiNoInvites);
 		}
 	},
 	beforeDestroy() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection.off('reversi_invited', this.onReversiInvited);
-			this.connection.off('reversi_no_invites', this.onReversiNoInvites);
-			(this as any).os.stream.dispose(this.connectionId);
+			this.connection.dispose();
 		}
 	},
 	methods: {
@@ -95,9 +91,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.nav
 	display inline-block
 	margin 0
 	padding 0
@@ -120,7 +114,7 @@ root(isDark)
 
 			&.active
 				> a
-					border-bottom solid 3px $theme-color
+					border-bottom solid 3px var(--primary)
 
 			> a
 				display inline-block
@@ -129,7 +123,7 @@ root(isDark)
 				padding 0 24px
 				font-size 13px
 				font-variant small-caps
-				color isDark ? #b8c5ca : #9eaba8
+				color var(--desktopHeaderFg)
 				text-decoration none
 				transition none
 				cursor pointer
@@ -138,7 +132,7 @@ root(isDark)
 					pointer-events none
 
 				&:hover
-					color isDark ? #fff : darken(#9eaba8, 20%)
+					color var(--desktopHeaderHoverFg)
 					text-decoration none
 
 				> [data-fa]:first-child
@@ -147,7 +141,7 @@ root(isDark)
 				> [data-fa]:last-child
 					margin-left 5px
 					font-size 10px
-					color $theme-color
+					color var(--primary)
 
 					@media (max-width 1100px)
 						margin-left -5px
@@ -162,10 +156,4 @@ root(isDark)
 				@media (max-width 700px)
 					padding 0 12px
 
-.nav[data-darkmode]
-	root(true)
-
-.nav:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue
index 59a16df9ec..c59a49556d 100644
--- a/src/client/app/desktop/views/components/ui.header.notifications.vue
+++ b/src/client/app/desktop/views/components/ui.header.notifications.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="notifications">
+<div class="notifications" v-hotkey.global="keymap">
 	<button :data-active="isOpen" @click="toggle" title="%i18n:@title%">
 		%fa:R bell%<template v-if="hasUnreadNotification">%fa:circle%</template>
 	</button>
@@ -19,11 +19,19 @@ export default Vue.extend({
 			isOpen: false
 		};
 	},
+
 	computed: {
 		hasUnreadNotification(): boolean {
 			return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification;
+		},
+
+		keymap(): any {
+			return {
+				'shift+n': this.toggle
+			};
 		}
 	},
+
 	methods: {
 		toggle() {
 			this.isOpen ? this.close() : this.open();
@@ -53,16 +61,13 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-
+.notifications
 	> button
 		display block
 		margin 0
 		padding 0
 		width 32px
-		color #9eaba8
+		color var(--desktopHeaderFg)
 		border none
 		background transparent
 		cursor pointer
@@ -72,10 +77,7 @@ root(isDark)
 
 		&:hover
 		&[data-active='true']
-			color isDark ? #fff : darken(#9eaba8, 20%)
-
-		&:active
-			color isDark ? #fff : darken(#9eaba8, 30%)
+			color var(--desktopHeaderHoverFg)
 
 		> [data-fa].bell
 			font-size 1.2em
@@ -85,10 +87,10 @@ root(isDark)
 			margin-left -5px
 			vertical-align super
 			font-size 10px
-			color $theme-color
+			color var(--primary)
 
 	> .pop
-		$bgcolor = isDark ? #282c37 : #fff
+		$bgcolor = var(--face)
 		display block
 		position absolute
 		top 56px
@@ -127,10 +129,4 @@ root(isDark)
 			font-size 1rem
 			overflow auto
 
-.notifications[data-darkmode]
-	root(true)
-
-.notifications:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.post.vue b/src/client/app/desktop/views/components/ui.header.post.vue
index 3665488542..9527792a34 100644
--- a/src/client/app/desktop/views/components/ui.header.post.vue
+++ b/src/client/app/desktop/views/components/ui.header.post.vue
@@ -17,7 +17,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .note
 	display inline-block
@@ -33,8 +33,8 @@ export default Vue.extend({
 		font-size 1.2em
 		font-weight normal
 		text-decoration none
-		color $theme-color-foreground
-		background $theme-color !important
+		color var(--primaryForeground)
+		background var(--primary) !important
 		outline none
 		border none
 		border-radius 4px
@@ -45,10 +45,10 @@ export default Vue.extend({
 			pointer-events none
 
 		&:hover
-			background lighten($theme-color, 10%) !important
+			background var(--primaryLighten10) !important
 
 		&:active
-			background darken($theme-color, 10%) !important
+			background var(--primaryDarken10) !important
 			transition background 0s ease
 
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue
index 9a36e52fcc..d22efbf84f 100644
--- a/src/client/app/desktop/views/components/ui.header.search.vue
+++ b/src/client/app/desktop/views/components/ui.header.search.vue
@@ -28,8 +28,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-root(isDark)
+.search
 	> [data-fa]
 		display block
 		position absolute
@@ -38,7 +37,7 @@ root(isDark)
 		width 48px
 		text-align center
 		line-height 48px
-		color #9eaba8
+		color var(--desktopHeaderFg)
 		pointer-events none
 
 		> *
@@ -52,26 +51,20 @@ root(isDark)
 		width 14em
 		height 32px
 		font-size 1em
-		background rgba(#000, 0.05)
+		background var(--desktopHeaderSearchBg)
 		outline none
-		//border solid 1px #ddd
 		border none
 		border-radius 16px
 		transition color 0.5s ease, border 0.5s ease
-		color isDark ? #fff : #000
+		color var(--desktopHeaderSearchFg)
 
 		&::placeholder
-			color #9eaba8
+			color var(--desktopHeaderFg)
 
 		&:hover
-			background isDark ? rgba(#fff, 0.04) : rgba(#000, 0.08)
+			background var(--desktopHeaderSearchHoverBg)
 
 		&:focus
-			box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important
+			box-shadow 0 0 0 2px var(--primaryAlpha05) !important
 
-.search[data-darkmode]
-	root(true)
-
-.search:not([data-darkmode])
-	root(false)
 </style>
diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue
index 6de4eaf744..4cfcda0f1a 100644
--- a/src/client/app/desktop/views/components/ui.header.vue
+++ b/src/client/app/desktop/views/components/ui.header.vue
@@ -1,16 +1,18 @@
 <template>
-<div class="header">
+<div class="header" :style="style">
+	<p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p>
 	<mk-special-message/>
 	<div class="main" ref="main">
 		<div class="backdrop"></div>
 		<div class="main">
-			<p ref="welcomeback" v-if="$store.getters.isSignedIn">%i18n:@welcome-back%<b>{{ $store.state.i | userName }}</b>%i18n:@adjective%</p>
 			<div class="container" ref="mainContainer">
 				<div class="left">
 					<x-nav/>
 				</div>
 				<div class="center">
-					<div class="icon" @click="goToTop"></div>
+					<div class="icon" @click="goToTop">
+						<img svg-inline src="../../assets/header-icon.svg"/>
+					</div>
 				</div>
 				<div class="right">
 					<x-search/>
@@ -28,6 +30,7 @@
 <script lang="ts">
 import Vue from 'vue';
 import * as anime from 'animejs';
+import { env } from '../../../config';
 
 import XNav from './ui.header.nav.vue';
 import XSearch from './ui.header.search.vue';
@@ -43,60 +46,25 @@ export default Vue.extend({
 		XAccount,
 		XNotifications,
 		XPost,
-		XClock,
+		XClock
+	},
+
+	data() {
+		return {
+			env: env
+		};
+	},
+
+	computed: {
+		style(): any {
+			return {
+				'box-shadow': this.$store.state.settings.useShadow ? '0 0px 8px rgba(0, 0, 0, 0.2)' : 'none'
+			};
+		}
 	},
 
 	mounted() {
-		this.$store.commit('setUiHeaderHeight', 48);
-
-		if (this.$store.getters.isSignedIn) {
-			const ago = (new Date().getTime() - new Date(this.$store.state.i.lastUsedAt).getTime()) / 1000;
-			const isHisasiburi = ago >= 3600;
-			this.$store.state.i.lastUsedAt = new Date();
-
-			if (isHisasiburi) {
-				(this.$refs.welcomeback as any).style.display = 'block';
-				(this.$refs.main as any).style.overflow = 'hidden';
-
-				anime({
-					targets: this.$refs.welcomeback,
-					top: '0',
-					opacity: 1,
-					delay: 1000,
-					duration: 500,
-					easing: 'easeOutQuad'
-				});
-
-				anime({
-					targets: this.$refs.mainContainer,
-					opacity: 0,
-					delay: 1000,
-					duration: 500,
-					easing: 'easeOutQuad'
-				});
-
-				setTimeout(() => {
-					anime({
-						targets: this.$refs.welcomeback,
-						top: '-48px',
-						opacity: 0,
-						duration: 500,
-						complete: () => {
-							(this.$refs.welcomeback as any).style.display = 'none';
-							(this.$refs.main as any).style.overflow = 'initial';
-						},
-						easing: 'easeInQuad'
-					});
-
-					anime({
-						targets: this.$refs.mainContainer,
-						opacity: 1,
-						duration: 500,
-						easing: 'easeInQuad'
-					});
-				}, 2500);
-			}
-		}
+		this.$store.commit('setUiHeaderHeight', this.$el.offsetHeight);
 	},
 
 	methods: {
@@ -111,13 +79,20 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	position -webkit-sticky
-	position sticky
+.header
+	position fixed
 	top 0
 	z-index 1000
 	width 100%
-	box-shadow 0 1px 1px rgba(#000, 0.075)
+
+	> .warn
+		display block
+		margin 0
+		padding 4px
+		text-align center
+		font-size 12px
+		background #f00
+		color #fff
 
 	> .main
 		height 48px
@@ -128,7 +103,7 @@ root(isDark)
 			z-index 1000
 			width 100%
 			height 48px
-			background isDark ? #313543 : #f7f7f7
+			background var(--desktopHeaderBg)
 
 		> .main
 			z-index 1001
@@ -138,17 +113,6 @@ root(isDark)
 			font-size 0.9rem
 			user-select none
 
-			> p
-				display none
-				position absolute
-				top 48px
-				width 100%
-				line-height 48px
-				margin 0
-				text-align center
-				color isDark ? #fff : #888
-				opacity 0
-
 			> .container
 				display flex
 				width 100%
@@ -166,13 +130,15 @@ root(isDark)
 						margin auto
 						display block
 						width 48px
-						height 48px
-						background-image isDark ? url('/assets/desktop/header-icon.dark.svg') : url('/assets/desktop/header-icon.light.svg')
-						background-size 24px
-						background-position center
-						background-repeat no-repeat
-						opacity 0.3
+						text-align center
 						cursor pointer
+						opacity 0.5
+
+						> svg
+							width 24px
+							height 48px
+							vertical-align top
+							fill var(--desktopHeaderFg)
 
 				> .left,
 				> .center
@@ -189,10 +155,4 @@ root(isDark)
 						> .mk-ui-header-search
 							display none
 
-.header[data-darkmode]
-	root(true)
-
-.header:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue
index d410c3d980..2d1e98447b 100644
--- a/src/client/app/desktop/views/components/ui.vue
+++ b/src/client/app/desktop/views/components/ui.vue
@@ -1,6 +1,7 @@
 <template>
-<div class="mk-ui" :style="style">
-	<x-header class="header" v-show="!zenMode"/>
+<div class="mk-ui" v-hotkey.global="keymap">
+	<div class="bg" v-if="$store.getters.isSignedIn && $store.state.i.wallpaperUrl" :style="style"></div>
+	<x-header class="header" v-show="!zenMode" ref="header"/>
 	<div class="content">
 		<slot></slot>
 	</div>
@@ -16,11 +17,13 @@ export default Vue.extend({
 	components: {
 		XHeader
 	},
+
 	data() {
 		return {
 			zenMode: false
 		};
 	},
+
 	computed: {
 		style(): any {
 			if (!this.$store.getters.isSignedIn || this.$store.state.i.wallpaperUrl == null) return {};
@@ -28,27 +31,37 @@ export default Vue.extend({
 				backgroundColor: this.$store.state.i.wallpaperColor && this.$store.state.i.wallpaperColor.length == 3 ? `rgb(${ this.$store.state.i.wallpaperColor.join(',') })` : null,
 				backgroundImage: `url(${ this.$store.state.i.wallpaperUrl })`
 			};
+		},
+
+		keymap(): any {
+			return {
+				'p': this.post,
+				'n': this.post,
+				'z': this.toggleZenMode
+			};
 		}
 	},
+
+	watch: {
+		'$store.state.uiHeaderHeight'() {
+			this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px';
+		}
+	},
+
 	mounted() {
-		document.addEventListener('keydown', this.onKeydown);
-	},
-	beforeDestroy() {
-		document.removeEventListener('keydown', this.onKeydown);
+		this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px';
 	},
+
 	methods: {
-		onKeydown(e) {
-			if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return;
+		post() {
+			(this as any).apis.post();
+		},
 
-			if (e.which == 80 || e.which == 78) { // p or n
-				e.preventDefault();
-				(this as any).apis.post();
-			}
-
-			if (e.which == 90) { // z
-				e.preventDefault();
-				this.zenMode = !this.zenMode;
-			}
+		toggleZenMode() {
+			this.zenMode = !this.zenMode;
+			this.$nextTick(() => {
+				this.$store.commit('setUiHeaderHeight', this.$refs.header.$el.offsetHeight);
+			});
 		}
 	}
 });
@@ -56,20 +69,22 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .mk-ui
-	display flex
-	flex-direction column
-	flex 1
-	background-size cover
-	background-position center
-	background-attachment fixed
+	min-height 100vh
+	padding-top 48px
+
+	> .bg
+		position fixed
+		top 0
+		left 0
+		width 100%
+		height 100vh
+		background-size cover
+		background-position center
+		background-attachment fixed
+		opacity 0.3
 
 	> .header
 		@media (max-width 1000px)
 			display none
 
-	> .content
-		display flex
-		flex-direction column
-		flex 1
-		overflow hidden
 </style>
diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue
index 0a6f758763..3407851fc5 100644
--- a/src/client/app/desktop/views/components/user-list-timeline.vue
+++ b/src/client/app/desktop/views/components/user-list-timeline.vue
@@ -6,7 +6,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { UserListStream } from '../../../common/scripts/streaming/user-list';
 
 const fetchLimit = 10;
 
diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
index 72ae9cf4e4..9c384314cf 100644
--- a/src/client/app/desktop/views/components/user-lists-window.vue
+++ b/src/client/app/desktop/views/components/user-lists-window.vue
@@ -1,8 +1,8 @@
 <template>
-<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy">
+<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom">
 	<span slot="header">%fa:list% %i18n:@title%</span>
 
-	<div class="xkxvokkjlptzyewouewmceqcxhpgzprp" :data-darkmode="$store.state.device.darkmode">
+	<div class="xkxvokkjlptzyewouewmceqcxhpgzprp">
 		<button class="ui" @click="add">%i18n:@create-list%</button>
 		<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a>
 	</div>
@@ -47,8 +47,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-
-root(isDark)
+.xkxvokkjlptzyewouewmceqcxhpgzprp
 	padding 16px
 
 	> button
@@ -57,13 +56,7 @@ root(isDark)
 	> a
 		display block
 		padding 16px
-		border solid 1px isDark ? #1c2023 : #eee
+		border solid 1px var(--faceDivider)
 		border-radius 4px
 
-.xkxvokkjlptzyewouewmceqcxhpgzprp[data-darkmode]
-	root(true)
-
-.xkxvokkjlptzyewouewmceqcxhpgzprp:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue
index 1e1755ec3c..7f5e79eae1 100644
--- a/src/client/app/desktop/views/components/user-preview.vue
+++ b/src/client/app/desktop/views/components/user-preview.vue
@@ -75,7 +75,7 @@ export default Vue.extend({
 				'margin-top': '-8px',
 				duration: 200,
 				easing: 'easeOutQuad',
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		}
 	}
@@ -83,14 +83,12 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-user-preview
 	position absolute
 	z-index 2048
 	margin-top -8px
 	width 250px
-	background isDark ? #282c37 : #fff
+	background var(--face)
 	background-clip content-box
 	border solid 1px rgba(#000, 0.1)
 	border-radius 4px
@@ -99,7 +97,7 @@ root(isDark)
 
 	> .banner
 		height 84px
-		background-color isDark ? #1c1e26 : #f5f5f5
+		background-color rgba(0, 0, 0, 0.1)
 		background-size cover
 		background-position center
 
@@ -111,7 +109,7 @@ root(isDark)
 		z-index 2
 		width 58px
 		height 58px
-		border solid 3px isDark ? #282c37 : #fff
+		border solid 3px var(--face)
 		border-radius 8px
 
 	> .title
@@ -123,19 +121,20 @@ root(isDark)
 			margin 0
 			font-weight bold
 			line-height 16px
-			color isDark ? #fff : #656565
+			color var(--text)
 
 		> .username
 			display block
 			margin 0
 			line-height 16px
 			font-size 0.8em
-			color isDark ? #606984 : #999
+			color var(--text)
+			opacity 0.7
 
 	> .description
 		padding 0 16px
 		font-size 0.7em
-		color isDark ? #9ea4ad : #555
+		color var(--text)
 
 	> .status
 		padding 8px 16px
@@ -147,21 +146,15 @@ root(isDark)
 			> p
 				margin 0
 				font-size 0.7em
-				color #aaa
+				color var(--text)
 
 			> span
 				font-size 1em
-				color $theme-color
+				color var(--primary)
 
 	> .mk-follow-button
 		position absolute
 		top 92px
 		right 8px
 
-.mk-user-preview[data-darkmode]
-	root(true)
-
-.mk-user-preview:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/components/users-list.item.vue b/src/client/app/desktop/views/components/users-list.item.vue
index 262fd38cd1..f42d577fce 100644
--- a/src/client/app/desktop/views/components/users-list.item.vue
+++ b/src/client/app/desktop/views/components/users-list.item.vue
@@ -1,17 +1,16 @@
 <template>
-<div class="root item">
-	<mk-avatar class="avatar" :user="user"/>
-	<div class="main">
-		<header>
-			<router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link>
-			<span class="username">@{{ user | acct }}</span>
-		</header>
-		<div class="body">
-			<p class="followed" v-if="user.isFollowed">%i18n:@followed%</p>
-			<div class="description">{{ user.description }}</div>
+<div class="zvdbznxvfixtmujpsigoccczftvpiwqh">
+	<div class="banner" :style="bannerStyle"></div>
+	<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
+	<div class="body">
+		<router-link :to="user | userPage" class="name">{{ user | userName }}</router-link>
+		<span class="username">@{{ user | acct }}</span>
+		<div class="description">
+			<misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/>
 		</div>
+		<p class="followed" v-if="user.isFollowed">%i18n:@followed%</p>
+		<mk-follow-button :user="user" :size="'big'"/>
 	</div>
-	<mk-follow-button :user="user"/>
 </div>
 </template>
 
@@ -19,76 +18,69 @@
 import Vue from 'vue';
 
 export default Vue.extend({
-	props: ['user']
+	props: ['user'],
+
+	computed: {
+		bannerStyle(): any {
+			if (this.user.bannerUrl == null) return {};
+			return {
+				backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null,
+				backgroundImage: `url(${ this.user.bannerUrl })`
+			};
+		}
+	},
 });
 </script>
 
 <style lang="stylus" scoped>
-.root.item
-	padding 16px
-	font-size 16px
+.zvdbznxvfixtmujpsigoccczftvpiwqh
+	$bg = #fff
 
-	&:after
-		content ""
-		display block
-		clear both
+	margin 16px auto
+	max-width calc(100% - 32px)
+	font-size 16px
+	text-align center
+	background $bg
+	box-shadow 0 2px 4px rgba(0, 0, 0, 0.1)
+
+	> .banner
+		height 100px
+		background-color #f9f4f4
+		background-position center
+		background-size cover
 
 	> .avatar
 		display block
-		float left
-		margin 0 16px 0 0
-		width 58px
-		height 58px
-		border-radius 8px
+		margin -40px auto 0 auto
+		width 80px
+		height 80px
+		border-radius 100%
+		border solid 4px $bg
 
-	> .main
-		float left
-		width calc(100% - 74px)
+	> .body
+		padding 4px 32px 32px 32px
 
-		> header
-			margin-bottom 2px
+		@media (max-width 400px)
+			padding 4px 16px 16px 16px
 
-			> .name
-				display inline
-				margin 0
-				padding 0
-				color #777
-				font-size 1em
-				font-weight 700
-				text-align left
-				text-decoration none
+		> .name
+			font-size 20px
+			font-weight bold
 
-				&:hover
-					text-decoration underline
+		> .username
+			display block
+			opacity 0.7
 
-			> .username
-				text-align left
-				margin 0 0 0 8px
-				color #ccc
+		> .description
+			margin 16px 0
 
-		> .body
-			> .followed
-				display inline-block
-				margin 0 0 4px 0
-				padding 2px 8px
-				vertical-align top
-				font-size 10px
-				color #71afc7
-				background #eefaff
-				border-radius 4px
-
-			> .description
-				cursor default
-				display block
-				margin 0
-				padding 0
-				overflow-wrap break-word
-				font-size 1.1em
-				color #717171
-
-	> .mk-follow-button
-		position absolute
-		top 16px
-		right 16px
+		> .followed
+			margin 0 0 16px 0
+			padding 0
+			line-height 24px
+			font-size 0.8em
+			color #71afc7
+			background #eefaff
+			border-radius 4px
 
 </style>
diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue
index 0423db8ed7..1316f277b7 100644
--- a/src/client/app/desktop/views/components/users-list.vue
+++ b/src/client/app/desktop/views/components/users-list.vue
@@ -33,7 +33,7 @@ export default Vue.extend({
 	props: ['fetch', 'count', 'youKnowCount'],
 	data() {
 		return {
-			limit: 30,
+			limit: 20,
 			mode: 'all',
 			fetching: true,
 			moreFetching: false,
@@ -69,14 +69,18 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-users-list
 	height 100%
-	background #fff
+	overflow auto
+	background #eee
 
 	> nav
-		z-index 1
+		z-index 10
+		position sticky
+		top 0
+		background #fff
 		box-shadow 0 1px 0 rgba(#000, 0.1)
 
 		> div
@@ -100,8 +104,8 @@ export default Vue.extend({
 
 				&[data-active]
 					font-weight bold
-					color $theme-color
-					border-color $theme-color
+					color var(--primary)
+					border-color var(--primary)
 					cursor default
 
 				> span
@@ -114,16 +118,14 @@ export default Vue.extend({
 					background #eee
 					border-radius 20px
 
-	> .users
-		height calc(100% - 54px)
-		overflow auto
+	> button
+		display block
+		width calc(100% - 32px)
+		margin 16px
+		padding 16px
 
-		> *
-			border-bottom solid 1px rgba(#000, 0.05)
-
-			> *
-				max-width 600px
-				margin 0 auto
+		&:hover
+			background rgba(#000, 0.1)
 
 	> .no
 		margin 0
diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue
index 7cfcd68eba..a506357039 100644
--- a/src/client/app/desktop/views/components/widget-container.vue
+++ b/src/client/app/desktop/views/components/widget-container.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mk-widget-container" :class="{ naked }">
-	<header :class="{ withGradient }" v-if="showHeader">
+	<header v-if="showHeader">
 		<div class="title"><slot name="header"></slot></div>
 		<slot name="func"></slot>
 	</header>
@@ -20,32 +20,23 @@ export default Vue.extend({
 			type: Boolean,
 			default: false
 		}
-	},
-	computed: {
-		withGradient(): boolean {
-			return this.$store.getters.isSignedIn
-				? this.$store.state.settings.gradientWindowHeader != null
-					? this.$store.state.settings.gradientWindowHeader
-					: false
-				: false;
-		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, isDark ? 0.2 : 0.075)
-	border-radius 6px
+.mk-widget-container
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 	overflow hidden
 
 	&.naked
 		background transparent !important
-		border none !important
+		box-shadow none !important
 
 	> header
-		background isDark ? #313543 : #fff
+		background var(--faceHeader)
 
 		> .title
 			z-index 1
@@ -54,7 +45,7 @@ root(isDark)
 			line-height 42px
 			font-size 0.9em
 			font-weight bold
-			color isDark ? #e3e5e8 : #888
+			color var(--faceHeaderText)
 			box-shadow 0 1px rgba(#000, 0.07)
 
 			> [data-fa]
@@ -72,23 +63,12 @@ root(isDark)
 			width 42px
 			font-size 0.9em
 			line-height 42px
-			color isDark ? #9baec8 : #ccc
+			color var(--faceTextButton)
 
 			&:hover
-				color isDark ? #b2c1d5 : #aaa
+				color var(--faceTextButtonHover)
 
 			&:active
-				color isDark ? #b2c1d5 : #999
-
-		&.withGradient
-			> .title
-				background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec)
-				box-shadow 0 1px rgba(#000, 0.11)
-
-.mk-widget-container[data-darkmode]
-	root(true)
-
-.mk-widget-container:not([data-darkmode])
-	root(false)
+				color var(--faceTextButtonActive)
 
 </style>
diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue
index ec044ad27e..a1893ffd6b 100644
--- a/src/client/app/desktop/views/components/window.vue
+++ b/src/client/app/desktop/views/components/window.vue
@@ -4,7 +4,6 @@
 	<div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }">
 		<div class="body">
 			<header ref="header"
-				:class="{ withGradient: $store.state.settings.gradientWindowHeader }"
 				@contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown"
 			>
 				<h1><slot name="header"></slot></h1>
@@ -76,6 +75,11 @@ export default Vue.extend({
 		name: {
 			type: String,
 			default: null
+		},
+		animation: {
+			type: Boolean,
+			required: false,
+			default: true
 		}
 	},
 
@@ -106,7 +110,7 @@ export default Vue.extend({
 
 	mounted() {
 		if (this.preventMount) {
-			this.$destroy();
+			this.destroyDom();
 			return;
 		}
 
@@ -142,7 +146,7 @@ export default Vue.extend({
 				anime({
 					targets: bg,
 					opacity: 1,
-					duration: 100,
+					duration: this.animation ? 100 : 0,
 					easing: 'linear'
 				});
 			}
@@ -152,7 +156,7 @@ export default Vue.extend({
 				targets: main,
 				opacity: 1,
 				scale: [1.1, 1],
-				duration: 200,
+				duration: this.animation ? 200 : 0,
 				easing: 'easeOutQuad'
 			});
 
@@ -160,7 +164,7 @@ export default Vue.extend({
 
 			setTimeout(() => {
 				this.$emit('opened');
-			}, 300);
+			}, this.animation ? 300 : 0);
 		},
 
 		close() {
@@ -174,7 +178,7 @@ export default Vue.extend({
 				anime({
 					targets: bg,
 					opacity: 0,
-					duration: 300,
+					duration: this.animation ? 300 : 0,
 					easing: 'linear'
 				});
 			}
@@ -185,14 +189,14 @@ export default Vue.extend({
 				targets: main,
 				opacity: 0,
 				scale: 0.8,
-				duration: 300,
+				duration: this.animation ? 300 : 0,
 				easing: [0.5, -0.5, 1, 0.5]
 			});
 
 			setTimeout(() => {
-				this.$destroy();
 				this.$emit('closed');
-			}, 300);
+				this.destroyDom();
+			}, this.animation ? 300 : 0);
 		},
 
 		popout() {
@@ -458,9 +462,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-window
 	display block
 
 	> .bg
@@ -488,10 +490,7 @@ root(isDark)
 		&:focus
 			&:not([data-is-modal])
 				> .body
-					if isDark
-						box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 12px 0 rgba(#000, 0.5)
-					else
-						box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(#000, 0.2)
+						box-shadow 0 0 0px 1px var(--primaryAlpha05), 0 2px 12px 0 var(--desktopWindowShadow)
 
 		> .handle
 			$size = 8px
@@ -557,13 +556,9 @@ root(isDark)
 		> .body
 			height 100%
 			overflow hidden
-			background isDark ? #282C37 : #fff
+			background var(--face)
 			border-radius 6px
-
-			if isDark
-				box-shadow 0 2px 12px 0 rgba(#000, 0.5)
-			else
-				box-shadow 0 2px 6px 0 rgba(#000, 0.2)
+			box-shadow 0 2px 12px 0 rgba(#000, 0.5)
 
 			> header
 				$header-height = 40px
@@ -573,14 +568,10 @@ root(isDark)
 				overflow hidden
 				white-space nowrap
 				cursor move
-				background isDark ? #313543 : #fff
+				background var(--faceHeader)
 				border-radius 6px 6px 0 0
 				box-shadow 0 1px 0 rgba(#000, 0.1)
 
-				&.withGradient
-					background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec)
-					box-shadow 0 1px 0 rgba(#000, 0.15)
-
 				&, *
 					user-select none
 
@@ -595,7 +586,7 @@ root(isDark)
 					font-size 1em
 					line-height $header-height
 					font-weight normal
-					color isDark ? #e3e5e8 : #666
+					color var(--desktopWindowTitle)
 
 				> div:last-child
 					position absolute
@@ -610,16 +601,16 @@ root(isDark)
 						padding 0
 						cursor pointer
 						font-size 1em
-						color isDark ? #9baec8 : rgba(#000, 0.4)
+						color var(--faceTextButton)
 						border none
 						outline none
 						background transparent
 
 						&:hover
-							color isDark ? #b2c1d5 : rgba(#000, 0.6)
+							color var(--faceTextButtonHover)
 
 						&:active
-							color isDark ? #b2c1d5 : darken(#000, 30%)
+							color var(--faceTextButtonActive)
 
 						> [data-fa]
 							padding 0
@@ -634,10 +625,4 @@ root(isDark)
 		> .main > .body > .content
 			height calc(100% - 40px)
 
-.mk-window[data-darkmode]
-	root(true)
-
-.mk-window:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/admin/admin.announcements.vue b/src/client/app/desktop/views/pages/admin/admin.announcements.vue
new file mode 100644
index 0000000000..5c1ed74b29
--- /dev/null
+++ b/src/client/app/desktop/views/pages/admin/admin.announcements.vue
@@ -0,0 +1,52 @@
+<template>
+<div class="qldxjjsrseehkusjuoooapmsprvfrxyl mk-admin-card">
+	<header>%i18n:@announcements%</header>
+	<textarea v-model="broadcasts" placeholder='[ { "title": "Title1", "text": "Text1" }, { "title": "Title2", "text": "Text2" } ]'></textarea>
+	<button class="ui" @click="save">%i18n:@save%</button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from "vue";
+
+export default Vue.extend({
+	data() {
+		return {
+			broadcasts: '',
+		};
+	},
+	created() {
+		(this as any).os.getMeta().then(meta => {
+			this.broadcasts = JSON.stringify(meta.broadcasts, null, '  ');
+		});
+	},
+	methods: {
+		save() {
+			let json;
+
+			try {
+				json = JSON.parse(this.broadcasts);
+			} catch (e) {
+				(this as any).os.apis.dialog({ text: `Failed: ${e}` });
+				return;
+			}
+
+			(this as any).api('admin/update-meta', {
+				broadcasts: json
+			}).then(() => {
+				(this as any).os.apis.dialog({ text: `Saved` });
+			}.catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed ${e}` });
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.qldxjjsrseehkusjuoooapmsprvfrxyl
+	textarea
+		width 100%
+		min-height 300px
+
+</style>
diff --git a/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue b/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue
index d14ce12553..63b24cea47 100644
--- a/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue
@@ -111,7 +111,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.zyknedwtlthezamcjlolyusmipqmjgxz
 	> svg
 		display block
 		width 50%
@@ -125,7 +125,7 @@ root(isDark)
 
 		> text
 			font-size 10px
-			fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55)
+			fill var(--chartCaption)
 
 			> tspan
 				opacity 0.5
@@ -135,10 +135,4 @@ root(isDark)
 		display block
 		clear both
 
-.zyknedwtlthezamcjlolyusmipqmjgxz[data-darkmode]
-	root(true)
-
-.zyknedwtlthezamcjlolyusmipqmjgxz:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue
index ebb54d782e..c0075220bc 100644
--- a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue
@@ -1,22 +1,42 @@
 <template>
 <div class="obdskegsannmntldydackcpzezagxqfy mk-admin-card">
 	<header>%i18n:@dashboard%</header>
+
 	<div v-if="stats" class="stats">
 		<div><b>%fa:user% {{ stats.originalUsersCount | number }}</b><span>%i18n:@original-users%</span></div>
 		<div><span>%fa:user% {{ stats.usersCount | number }}</span><span>%i18n:@all-users%</span></div>
 		<div><b>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div>
 		<div><span>%fa:pencil-alt% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div>
 	</div>
+
 	<div class="cpu-memory">
 		<x-cpu-memory :connection="connection"/>
 	</div>
-	<div>
-		<label>
-			<input type="checkbox" v-model="disableRegistration" @change="updateMeta">
-			<span>disableRegistration</span>
-		</label>
-		<button class="ui" @click="invite">%i18n:@invite%</button>
-		<p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p>
+
+	<div v-if="this.$store.state.i && this.$store.state.i.isAdmin" class="form">
+		<div>
+			<label>
+				<p>%i18n:@banner-url%</p>
+				<input v-model="bannerUrl">
+			</label>
+			<button class="ui" @click="updateMeta">%i18n:@save%</button>
+		</div>
+
+		<div>
+			<label>
+				<input type="checkbox" v-model="disableRegistration" @change="updateMeta">
+				<span>%i18n:@disableRegistration%</span>
+			</label>
+			<button class="ui" @click="invite">%i18n:@invite%</button>
+			<p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p>
+		</div>
+
+		<div>
+			<label>
+				<input type="checkbox" v-model="disableLocalTimeline" @change="updateMeta">
+				<span>%i18n:@disableLocalTimeline%</span>
+			</label>
+		</div>
 	</div>
 </div>
 </template>
@@ -33,17 +53,19 @@ export default Vue.extend({
 		return {
 			stats: null,
 			disableRegistration: false,
+			disableLocalTimeline: false,
+			bannerUrl: null,
 			inviteCode: null,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	created() {
-		this.connection = (this as any).os.streams.serverStatsStream.getConnection();
-		this.connectionId = (this as any).os.streams.serverStatsStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('serverStats');
 
 		(this as any).os.getMeta().then(meta => {
 			this.disableRegistration = meta.disableRegistration;
+			this.disableLocalTimeline = meta.disableLocalTimeline;
+			this.bannerUrl = meta.bannerUrl;
 		});
 
 		(this as any).api('stats').then(stats => {
@@ -51,17 +73,25 @@ export default Vue.extend({
 		});
 	},
 	beforeDestroy() {
-		(this as any).os.streams.serverStatsStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		invite() {
 			(this as any).api('admin/invite').then(x => {
 				this.inviteCode = x.code;
+			}).catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed ${e}` });
 			});
 		},
 		updateMeta() {
 			(this as any).api('admin/update-meta', {
-				disableRegistration: this.disableRegistration
+				disableRegistration: this.disableRegistration,
+				disableLocalTimeline: this.disableLocalTimeline,
+				bannerUrl: this.bannerUrl
+			}).then(() => {
+				(this as any).os.apis.dialog({ text: `Saved` });
+			}).catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed ${e}` });
 			});
 		}
 	}
@@ -69,7 +99,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .obdskegsannmntldydackcpzezagxqfy
 	> .stats
@@ -86,7 +116,7 @@ export default Vue.extend({
 
 			> *:first-child
 				display block
-				color $theme-color
+				color var(--primary)
 
 			> *:last-child
 				font-size 70%
@@ -97,4 +127,9 @@ export default Vue.extend({
 		border solid 1px #eee
 		border-radius: 8px
 
+	> .form
+		> div
+			padding 16px
+			border-bottom solid 1px #eee
+
 </style>
diff --git a/src/client/app/desktop/views/pages/admin/admin.hashtags.vue b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue
new file mode 100644
index 0000000000..10bab1cbd7
--- /dev/null
+++ b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="jdnqwkzlnxcfftthoybjxrebyolvoucw mk-admin-card">
+	<header>%i18n:@hided-tags%</header>
+	<textarea v-model="hidedTags"></textarea>
+	<button class="ui" @click="save">%i18n:@save%</button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from "vue";
+
+export default Vue.extend({
+	data() {
+		return {
+			hidedTags: '',
+		};
+	},
+	created() {
+		(this as any).os.getMeta().then(meta => {
+			this.hidedTags = meta.hidedTags.join('\n');
+		});
+	},
+	methods: {
+		save() {
+			(this as any).api('admin/update-meta', {
+				hidedTags: this.hidedTags.split('\n')
+			}).then(() => {
+				(this as any).os.apis.dialog({ text: `Saved` });
+			}).catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed ${e}` });
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+
+
+.jdnqwkzlnxcfftthoybjxrebyolvoucw
+	textarea
+		width 100%
+		min-height 300px
+
+</style>
diff --git a/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue
index 8d8e37e181..a8ff937bbe 100644
--- a/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue
@@ -21,25 +21,31 @@ export default Vue.extend({
 		async suspendUser() {
 			this.suspending = true;
 
-			const user = await (this as any).os.api(
-				"users/show",
-				parseAcct(this.username)
-			);
+			const process = async () => {
+				const user = await (this as any).os.api(
+					"users/show",
+					parseAcct(this.username)
+				);
 
-			await (this as any).os.api("admin/suspend-user", {
-				userId: user.id
+				await (this as any).os.api("admin/suspend-user", {
+					userId: user.id
+				});
+
+				(this as any).os.apis.dialog({ text: "%i18n:@suspended%" });
+			};
+
+			await process().catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed: ${e}` });
 			});
 
 			this.suspending = false;
-
-			(this as any).os.apis.dialog({ text: "%i18n:@suspended%" });
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 header
 	margin 10px 0
diff --git a/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue
index ec423969be..146f5a41d4 100644
--- a/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue
@@ -21,25 +21,32 @@ export default Vue.extend({
 		async unsuspendUser() {
 			this.unsuspending = true;
 
-			const user = await (this as any).os.api(
-				"users/show",
-				parseAcct(this.username)
-			);
+			const process = async () => {
+				const user = await (this as any).os.api(
+					"users/show",
+					parseAcct(this.username)
+				);
 
-			await (this as any).os.api("admin/unsuspend-user", {
-				userId: user.id
+				await (this as any).os.api("admin/unsuspend-user", {
+					userId: user.id
+				});
+
+				(this as any).os.apis.dialog({ text: "%i18n:@unsuspended%" });
+			};
+
+			await process().catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed: ${e}` });
 			});
 
 			this.unsuspending = false;
 
-			(this as any).os.apis.dialog({ text: "%i18n:@unsuspended%" });
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 header
 	margin 10px 0
diff --git a/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue b/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue
index e8204e69f4..5e0fdae5c1 100644
--- a/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue
@@ -21,25 +21,31 @@ export default Vue.extend({
 		async unverifyUser() {
 			this.unverifying = true;
 
-			const user = await (this as any).os.api(
-				"users/show",
-				parseAcct(this.username)
-			);
+			const process = async () => {
+				const user = await (this as any).os.api(
+					"users/show",
+					parseAcct(this.username)
+				);
 
-			await (this as any).os.api("admin/unverify-user", {
-				userId: user.id
+				await (this as any).os.api("admin/unverify-user", {
+					userId: user.id
+				});
+
+				(this as any).os.apis.dialog({ text: "%i18n:@unverified%" });
+			};
+
+			await process().catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed: ${e}` });
 			});
 
 			this.unverifying = false;
-
-			(this as any).os.apis.dialog({ text: "%i18n:@unverified%" });
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 header
 	margin 10px 0
diff --git a/src/client/app/desktop/views/pages/admin/admin.verify-user.vue b/src/client/app/desktop/views/pages/admin/admin.verify-user.vue
index 91fb04af80..d237a5f9c1 100644
--- a/src/client/app/desktop/views/pages/admin/admin.verify-user.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.verify-user.vue
@@ -21,25 +21,31 @@ export default Vue.extend({
 		async verifyUser() {
 			this.verifying = true;
 
-			const user = await (this as any).os.api(
-				"users/show",
-				parseAcct(this.username)
-			);
+			const process = async () => {
+				const user = await (this as any).os.api(
+					"users/show",
+					parseAcct(this.username)
+				);
 
-			await (this as any).os.api("admin/verify-user", {
-				userId: user.id
+				await (this as any).os.api("admin/verify-user", {
+					userId: user.id
+				});
+
+				(this as any).os.apis.dialog({ text: "%i18n:@verified%" });
+			};
+
+			await process().catch(e => {
+				(this as any).os.apis.dialog({ text: `Failed: ${e}` });
 			});
 
 			this.verifying = false;
-
-			(this as any).os.apis.dialog({ text: "%i18n:@verified%" });
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 header
 	margin 10px 0
diff --git a/src/client/app/desktop/views/pages/admin/admin.vue b/src/client/app/desktop/views/pages/admin/admin.vue
index 3438462cd6..ad417e5121 100644
--- a/src/client/app/desktop/views/pages/admin/admin.vue
+++ b/src/client/app/desktop/views/pages/admin/admin.vue
@@ -3,7 +3,16 @@
 	<nav>
 		<ul>
 			<li @click="nav('dashboard')" :class="{ active: page == 'dashboard' }">%fa:chalkboard .fw%%i18n:@dashboard%</li>
-			<li @click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li>
+
+			<li v-if="this.$store.state.i && this.$store.state.i.isAdmin"
+				@click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li>
+
+			<li v-if="this.$store.state.i && this.$store.state.i.isAdmin"
+				@click="nav('announcements')" :class="{ active: page == 'announcements' }">%fa:broadcast-tower .fw%%i18n:@announcements%</li>
+
+			<li v-if="this.$store.state.i && this.$store.state.i.isAdmin"
+				@click="nav('hashtags')" :class="{ active: page == 'hashtags' }">%fa:hashtag .fw%%i18n:@hashtags%</li>
+
 			<!-- <li @click="nav('drive')" :class="{ active: page == 'drive' }">%fa:cloud .fw%%i18n:@drive%</li> -->
 			<!-- <li @click="nav('update')" :class="{ active: page == 'update' }">%i18n:@update%</li> -->
 		</ul>
@@ -13,6 +22,12 @@
 			<x-dashboard/>
 			<x-charts/>
 		</div>
+		<div v-show="page == 'announcements'">
+			<x-announcements/>
+		</div>
+		<div v-show="page == 'hashtags'">
+			<x-hashtags/>
+		</div>
 		<div v-if="page == 'users'">
 			<x-suspend-user/>
 			<x-unsuspend-user/>
@@ -28,6 +43,8 @@
 <script lang="ts">
 import Vue from "vue";
 import XDashboard from "./admin.dashboard.vue";
+import XAnnouncements from "./admin.announcements.vue";
+import XHashtags from "./admin.hashtags.vue";
 import XSuspendUser from "./admin.suspend-user.vue";
 import XUnsuspendUser from "./admin.unsuspend-user.vue";
 import XVerifyUser from "./admin.verify-user.vue";
@@ -37,6 +54,8 @@ import XCharts from "../../components/charts.vue";
 export default Vue.extend({
 	components: {
 		XDashboard,
+		XAnnouncements,
+		XHashtags,
 		XSuspendUser,
 		XUnsuspendUser,
 		XVerifyUser,
@@ -57,7 +76,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus">
-@import '~const.styl'
+
 
 .mk-admin
 	display flex
@@ -93,7 +112,7 @@ export default Vue.extend({
 
 				&.active
 					margin-left 8px
-					color $theme-color !important
+					color var(--primary) !important
 
 	> main
 		width 100%
diff --git a/src/client/app/desktop/views/pages/deck/deck.column-core.vue b/src/client/app/desktop/views/pages/deck/deck.column-core.vue
index 7f219c0be1..e1490cb0e4 100644
--- a/src/client/app/desktop/views/pages/deck/deck.column-core.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.column-core.vue
@@ -6,6 +6,9 @@
 <x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked"/>
 <x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked"/>
 <x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked"/>
+<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked"/>
+<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked"/>
+<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked"/>
 </template>
 
 <script lang="ts">
@@ -13,12 +16,16 @@ import Vue from 'vue';
 import XTlColumn from './deck.tl-column.vue';
 import XNotificationsColumn from './deck.notifications-column.vue';
 import XWidgetsColumn from './deck.widgets-column.vue';
+import XMentionsColumn from './deck.mentions-column.vue';
+import XDirectColumn from './deck.direct-column.vue';
 
 export default Vue.extend({
 	components: {
 		XTlColumn,
 		XNotificationsColumn,
-		XWidgetsColumn
+		XWidgetsColumn,
+		XMentionsColumn,
+		XDirectColumn
 	},
 
 	props: {
diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
index d59d430da6..c372ef490e 100644
--- a/src/client/app/desktop/views/pages/deck/deck.column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -3,18 +3,20 @@
 		@dragover.prevent.stop="onDragover"
 		@dragenter.prevent="onDragenter"
 		@dragleave="onDragleave"
-		@drop.prevent.stop="onDrop"
->
+		@drop.prevent.stop="onDrop">
 	<header :class="{ indicate: count > 0 }"
 			draggable="true"
-			@click="toggleActive"
+			@click="goTop"
 			@dragstart="onDragstart"
 			@dragend="onDragend"
-			@contextmenu.prevent.stop="onContextmenu"
-		>
+			@contextmenu.prevent.stop="onContextmenu">
+		<button class="toggleActive" @click="toggleActive" v-if="isStacked">
+			<template v-if="active">%fa:angle-up%</template>
+			<template v-else>%fa:angle-down%</template>
+		</button>
 		<slot name="header"></slot>
 		<span class="count" v-if="count > 0">({{ count }})</span>
-		<button ref="menu" @click.stop="showMenu">%fa:caret-down%</button>
+		<button class="menu" ref="menu" @click.stop="showMenu">%fa:caret-down%</button>
 	</header>
 	<div ref="body" v-show="active">
 		<slot></slot>
@@ -26,6 +28,7 @@
 import Vue from 'vue';
 import Menu from '../../../../common/views/components/menu.vue';
 import contextmenu from '../../../api/contextmenu';
+import { countIf } from '../../../../../../prelude/array';
 
 export default Vue.extend({
 	props: {
@@ -115,7 +118,7 @@ export default Vue.extend({
 		toggleActive() {
 			if (!this.isStacked) return;
 			const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id));
-			if (this.active && vms.filter(vm => vm.$el.classList.contains('active')).length == 1) return;
+			if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return;
 			this.active = !this.active;
 		},
 
@@ -211,6 +214,13 @@ export default Vue.extend({
 			});
 		},
 
+		goTop() {
+			this.$refs.body.scrollTo({
+				top: 0,
+				behavior: 'smooth'
+			});
+		},
+
 		onDragstart(e) {
 			e.dataTransfer.effectAllowed = 'move';
 			e.dataTransfer.setData('mk-deck-column', this.column.id);
@@ -259,24 +269,22 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs
 	$header-height = 42px
 
 	width 330px
 	min-width 330px
 	height 100%
-	background isDark ? #282C37 : #fff
+	background var(--face)
 	border-radius 6px
-	box-shadow 0 2px 16px rgba(#000, 0.1)
+	//box-shadow 0 2px 16px rgba(#000, 0.1)
 	overflow hidden
 
 	&.draghover
-		box-shadow 0 0 0 2px rgba($theme-color, 0.8)
+		box-shadow 0 0 0 2px var(--primaryAlpha08)
 
 	&.dragging
-		box-shadow 0 0 0 2px rgba($theme-color, 0.4)
+		box-shadow 0 0 0 2px var(--primaryAlpha04)
 
 	&.dropready
 		*
@@ -291,23 +299,23 @@ root(isDark)
 		min-width 285px
 
 	&.naked
-		background rgba(#000, isDark ? 0.25 : 0.1)
+		background var(--deckAcrylicColumnBg)
 
 		> header
 			background transparent
 			box-shadow none
 
-			if !isDark
-				> button
-					color #bbb
+			> button
+				color var(--text)
 
 	> header
+		display flex
 		z-index 1
 		line-height $header-height
 		padding 0 16px
 		font-size 14px
-		color isDark ? #e3e5e8 : #888
-		background isDark ? #313543 : #fff
+		color var(--faceHeaderText)
+		background var(--faceHeader)
 		box-shadow 0 1px rgba(#000, 0.15)
 		cursor pointer
 
@@ -318,7 +326,7 @@ root(isDark)
 			pointer-events none
 
 		&.indicate
-			box-shadow 0 3px 0 0 $theme-color
+			box-shadow 0 3px 0 0 var(--primary)
 
 		> span
 			[data-fa]
@@ -328,30 +336,29 @@ root(isDark)
 			margin-left 4px
 			opacity 0.5
 
-		> button
-			position absolute
-			top 0
-			right 0
+		> .toggleActive
+		> .menu
 			width $header-height
 			line-height $header-height
 			font-size 16px
-			color isDark ? #9baec8 : #ccc
+			color var(--faceTextButton)
 
 			&:hover
-				color isDark ? #b2c1d5 : #aaa
+				color var(--faceTextButtonHover)
 
 			&:active
-				color isDark ? #b2c1d5 : #999
+				color var(--faceTextButtonActive)
+
+		> .toggleActive
+			margin-left -16px
+
+		> .menu
+			margin-left auto
+			margin-right -16px
 
 	> div
 		height "calc(100% - %s)" % $header-height
 		overflow auto
 		overflow-x hidden
 
-.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs[data-darkmode]
-	root(true)
-
-.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.direct-column.vue b/src/client/app/desktop/views/pages/deck/deck.direct-column.vue
new file mode 100644
index 0000000000..d5093761f4
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.direct-column.vue
@@ -0,0 +1,38 @@
+<template>
+<x-column :name="name" :column="column" :is-stacked="isStacked">
+	<span slot="header">%fa:envelope R%{{ name }}</span>
+
+	<x-direct/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+import XDirect from './deck.direct.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XDirect
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	computed: {
+		name(): string {
+			if (this.column.name) return this.column.name;
+			return '%i18n:common.deck.direct%';
+		}
+	},
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.direct.vue b/src/client/app/desktop/views/pages/deck/deck.direct.vue
new file mode 100644
index 0000000000..c771e58a6e
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.direct.vue
@@ -0,0 +1,93 @@
+<template>
+	<x-notes ref="timeline" :more="existMore ? more : null"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from './deck.notes.vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	components: {
+		XNotes
+	},
+
+	props: {
+	},
+
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null
+		};
+	},
+
+	mounted() {
+		this.connection = (this as any).os.stream.useSharedConnection('main');
+		this.connection.on('mention', this.onNote);
+
+		this.fetch();
+	},
+
+	beforeDestroy() {
+		this.connection.dispose();
+	},
+
+	methods: {
+		fetch() {
+			this.fetching = true;
+
+			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+				(this as any).api('notes/mentions', {
+					limit: fetchLimit + 1,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+					visibility: 'specified'
+				}).then(notes => {
+					if (notes.length == fetchLimit + 1) {
+						notes.pop();
+						this.existMore = true;
+					}
+					res(notes);
+					this.fetching = false;
+					this.$emit('loaded');
+				}, rej);
+			}));
+		},
+		more() {
+			this.moreFetching = true;
+
+			const promise = (this as any).api('notes/mentions', {
+				limit: fetchLimit + 1,
+				untilId: (this.$refs.timeline as any).tail().id,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+				visibility: 'specified'
+			});
+
+			promise.then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+				} else {
+					this.existMore = false;
+				}
+				notes.forEach(n => (this.$refs.timeline as any).append(n));
+				this.moreFetching = false;
+			});
+
+			return promise;
+		},
+		onNote(note) {
+			// Prepend a note
+			if (note.visibility == 'specified') {
+				(this.$refs.timeline as any).prepend(note);
+			}
+		}
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue
new file mode 100644
index 0000000000..02d99d3883
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue
@@ -0,0 +1,116 @@
+<template>
+	<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from './deck.notes.vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	components: {
+		XNotes
+	},
+
+	props: {
+		tagTl: {
+			type: Object,
+			required: true
+		},
+		mediaOnly: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		mediaView: {
+			type: Boolean,
+			required: false,
+			default: false
+		}
+	},
+
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null
+		};
+	},
+
+	watch: {
+		mediaOnly() {
+			this.fetch();
+		}
+	},
+
+	mounted() {
+		if (this.connection) this.connection.close();
+		this.connection = (this as any).os.stream.connectToChannel('hashtag', this.tagTl.query);
+		this.connection.on('note', this.onNote);
+
+		this.fetch();
+	},
+
+	beforeDestroy() {
+		this.connection.close();
+	},
+
+	methods: {
+		fetch() {
+			this.fetching = true;
+
+			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+				(this as any).api('notes/search_by_tag', {
+					limit: fetchLimit + 1,
+					withFiles: this.mediaOnly,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+					query: this.tagTl.query
+				}).then(notes => {
+					if (notes.length == fetchLimit + 1) {
+						notes.pop();
+						this.existMore = true;
+					}
+					res(notes);
+					this.fetching = false;
+					this.$emit('loaded');
+				}, rej);
+			}));
+		},
+		more() {
+			this.moreFetching = true;
+
+			const promise = (this as any).api('notes/search_by_tag', {
+				limit: fetchLimit + 1,
+				untilId: (this.$refs.timeline as any).tail().id,
+				withFiles: this.mediaOnly,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+				query: this.tagTl.query
+			});
+
+			promise.then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+				} else {
+					this.existMore = false;
+				}
+				notes.forEach(n => (this.$refs.timeline as any).append(n));
+				this.moreFetching = false;
+			});
+
+			return promise;
+		},
+		onNote(note) {
+			if (this.mediaOnly && note.files.length == 0) return;
+
+			// Prepend a note
+			(this.$refs.timeline as any).prepend(note);
+		}
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue
index 70048f99e3..e543130310 100644
--- a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue
@@ -5,7 +5,6 @@
 <script lang="ts">
 import Vue from 'vue';
 import XNotes from './deck.notes.vue';
-import { UserListStream } from '../../../../common/scripts/streaming/user-list';
 
 const fetchLimit = 10;
 
@@ -68,7 +67,7 @@ export default Vue.extend({
 				(this as any).api('notes/user-list-timeline', {
 					listId: this.list.id,
 					limit: fetchLimit + 1,
-					mediaOnly: this.mediaOnly,
+					withFiles: this.mediaOnly,
 					includeMyRenotes: this.$store.state.settings.showMyRenotes,
 					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@@ -90,7 +89,7 @@ export default Vue.extend({
 				listId: this.list.id,
 				limit: fetchLimit + 1,
 				untilId: (this.$refs.timeline as any).tail().id,
-				mediaOnly: this.mediaOnly,
+				withFiles: this.mediaOnly,
 				includeMyRenotes: this.$store.state.settings.showMyRenotes,
 				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@@ -109,7 +108,7 @@ export default Vue.extend({
 			return promise;
 		},
 		onNote(note) {
-			if (this.mediaOnly && note.media.length == 0) return;
+			if (this.mediaOnly && note.files.length == 0) return;
 
 			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue
new file mode 100644
index 0000000000..8ec10164f2
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue
@@ -0,0 +1,38 @@
+<template>
+<x-column :name="name" :column="column" :is-stacked="isStacked">
+	<span slot="header">%fa:at%{{ name }}</span>
+
+	<x-mentions/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+import XMentions from './deck.mentions.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XMentions
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	computed: {
+		name(): string {
+			if (this.column.name) return this.column.name;
+			return '%i18n:common.deck.mentions%';
+		}
+	},
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions.vue b/src/client/app/desktop/views/pages/deck/deck.mentions.vue
new file mode 100644
index 0000000000..17b572f146
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.mentions.vue
@@ -0,0 +1,89 @@
+<template>
+	<x-notes ref="timeline" :more="existMore ? more : null"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from './deck.notes.vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	components: {
+		XNotes
+	},
+
+	props: {
+	},
+
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null
+		};
+	},
+
+	mounted() {
+		this.connection = (this as any).os.stream.useSharedConnection('main');
+		this.connection.on('mention', this.onNote);
+
+		this.fetch();
+	},
+
+	beforeDestroy() {
+		this.connection.dispose();
+	},
+
+	methods: {
+		fetch() {
+			this.fetching = true;
+
+			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+				(this as any).api('notes/mentions', {
+					limit: fetchLimit + 1,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				}).then(notes => {
+					if (notes.length == fetchLimit + 1) {
+						notes.pop();
+						this.existMore = true;
+					}
+					res(notes);
+					this.fetching = false;
+					this.$emit('loaded');
+				}, rej);
+			}));
+		},
+		more() {
+			this.moreFetching = true;
+
+			const promise = (this as any).api('notes/mentions', {
+				limit: fetchLimit + 1,
+				untilId: (this.$refs.timeline as any).tail().id,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			});
+
+			promise.then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+				} else {
+					this.existMore = false;
+				}
+				notes.forEach(n => (this.$refs.timeline as any).append(n));
+				this.moreFetching = false;
+			});
+
+			return promise;
+		},
+		onNote(note) {
+			// Prepend a note
+			(this.$refs.timeline as any).prepend(note);
+		}
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
index 3ba9ae914e..445bf7e365 100644
--- a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
@@ -29,11 +29,11 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.fnlfosztlhtptnongximhlbykxblytcq
 	display flex
 	padding 16px
 	font-size 10px
-	background isDark ? #21242d : #fcfcfc
+	background var(--subNoteBg)
 
 	&.smart
 		> .main
@@ -62,16 +62,10 @@ root(isDark)
 			> .text
 				margin 0
 				padding 0
-				color isDark ? #959ba7 : #717171
+				color var(--subNoteText)
 
 				pre
 					max-height 120px
 					font-size 80%
 
-.fnlfosztlhtptnongximhlbykxblytcq[data-darkmode]
-	root(true)
-
-.fnlfosztlhtptnongximhlbykxblytcq:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue
index e6d062eac9..e843ac54fe 100644
--- a/src/client/app/desktop/views/pages/deck/deck.note.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.note.vue
@@ -18,7 +18,7 @@
 			<div class="body">
 				<p v-if="p.cw != null" class="cw">
 					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
-					<span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span>
+					<mk-cw-button v-model="showContent"/>
 				</p>
 				<div class="content" v-show="p.cw == null || showContent">
 					<div class="text">
@@ -28,14 +28,15 @@
 						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
 						<a class="rp" v-if="p.renote != null">RP:</a>
 					</div>
-					<div class="media" v-if="p.media.length > 0">
-						<mk-media-list :media-list="p.media"/>
+					<div class="files" v-if="p.files.length > 0">
+						<mk-media-list :media-list="p.files"/>
 					</div>
 					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
 					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
 					<div class="renote" v-if="p.renote">
 						<mk-note-preview :note="p.renote" :mini="true"/>
 					</div>
+					<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="false" :mini="true"/>
 				</div>
 				<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
 			</div>
@@ -53,11 +54,11 @@
 	</article>
 </div>
 <div v-else class="srwrkujossgfuhrbnvqkybtzxpblgchi">
-	<div v-if="note.media.length > 0">
-		<mk-media-list :media-list="note.media"/>
+	<div v-if="note.files.length > 0">
+		<mk-media-list :media-list="note.files"/>
 	</div>
-	<div v-if="note.renote && note.renote.media.length > 0">
-		<mk-media-list :media-list="note.renote.media"/>
+	<div v-if="note.renote && note.renote.files.length > 0">
+		<mk-media-list :media-list="note.renote.files"/>
 	</div>
 </div>
 </template>
@@ -69,12 +70,15 @@ import parse from '../../../../../../mfm/parse';
 import MkNoteMenu from '../../../../common/views/components/note-menu.vue';
 import MkReactionPicker from '../../../../common/views/components/reaction-picker.vue';
 import XSub from './deck.note.sub.vue';
+import noteSubscriber from '../../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
 	components: {
 		XSub
 	},
 
+	mixins: [noteSubscriber('note')],
+
 	props: {
 		note: {
 			type: Object,
@@ -89,9 +93,7 @@ export default Vue.extend({
 
 	data() {
 		return {
-			showContent: false,
-			connection: null,
-			connectionId: null
+			showContent: false
 		};
 	},
 
@@ -99,7 +101,7 @@ export default Vue.extend({
 		isRenote(): boolean {
 			return (this.note.renote &&
 				this.note.text == null &&
-				this.note.mediaIds.length == 0 &&
+				this.note.fileIds.length == 0 &&
 				this.note.poll == null);
 		},
 
@@ -119,64 +121,7 @@ export default Vue.extend({
 		}
 	},
 
-	created() {
-		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
-		}
-	},
-
-	mounted() {
-		this.capture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.on('_connected_', this.onStreamConnected);
-		}
-	},
-
-	beforeDestroy() {
-		this.decapture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.off('_connected_', this.onStreamConnected);
-			(this as any).os.stream.dispose(this.connectionId);
-		}
-	},
-
 	methods: {
-		capture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'capture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		decapture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'decapture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		onStreamConnected() {
-			this.capture();
-		},
-
-		onStreamNoteUpdated(data) {
-			const note = data.note;
-			if (note.id == this.note.id) {
-				this.$emit('update:note', note);
-			} else if (note.id == this.note.renoteId) {
-				this.note.renote = note;
-			}
-		},
-
 		reply() {
 			(this as any).apis.post({
 				reply: this.p
@@ -209,9 +154,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-mediaRoot(isDark)
+.srwrkujossgfuhrbnvqkybtzxpblgchi
 	font-size 13px
 	margin 4px 12px
 
@@ -221,9 +164,9 @@ mediaRoot(isDark)
 	&:last-child
 		margin-bottom 12px
 
-root(isDark)
+.zyjjkidcqjnlegkqebitfviomuqmseqk
 	font-size 13px
-	border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+	border-bottom solid 1px var(--faceDivider)
 
 	&:last-of-type
 		border-bottom none
@@ -241,8 +184,8 @@ root(isDark)
 		padding 8px 16px 0 16px
 		line-height 28px
 		white-space pre
-		color #9dbb00
-		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+		color var(--renoteText)
+		background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
 
 		.avatar
 			flex-shrink 0
@@ -304,24 +247,11 @@ root(isDark)
 					margin 0
 					padding 0
 					overflow-wrap break-word
-					color isDark ? #fff : #717171
+					color var(--noteText)
 
 					> .text
 						margin-right 8px
 
-					> .toggle
-						display inline-block
-						padding 4px 8px
-						font-size 0.7em
-						color isDark ? #393f4f : #fff
-						background isDark ? #687390 : #b1b9c1
-						border-radius 2px
-						cursor pointer
-						user-select none
-
-						&:hover
-							background isDark ? #707b97 : #bbc4ce
-
 				> .content
 
 					> .text
@@ -329,7 +259,7 @@ root(isDark)
 						margin 0
 						padding 0
 						overflow-wrap break-word
-						color isDark ? #fff : #717171
+						color var(--noteText)
 
 						>>> .title
 							display block
@@ -337,7 +267,7 @@ root(isDark)
 							padding 4px
 							font-size 90%
 							text-align center
-							background isDark ? #2f3944 : #eef1f3
+							background var(--mfmTitleBg)
 							border-radius 4px
 
 						>>> .code
@@ -346,31 +276,31 @@ root(isDark)
 						>>> .quote
 							margin 8px
 							padding 6px 12px
-							color isDark ? #6f808e : #aaa
-							border-left solid 3px isDark ? #637182 : #eee
+							color var(--mfmQuote)
+							border-left solid 3px var(--mfmQuoteLine)
 
 						> .reply
 							margin-right 8px
-							color isDark ? #99abbf : #717171
+							color var(--noteText)
 
 						> .rp
 							margin-left 4px
 							font-style oblique
-							color #a0bf46
+							color var(--renoteText)
 
 						[data-is-me]:after
 							content "you"
 							padding 0 4px
 							margin-left 4px
 							font-size 80%
-							color $theme-color-foreground
-							background $theme-color
+							color var(--primaryForeground)
+							background var(--primary)
 							border-radius 4px
 
 					.mk-url-preview
 						margin-top 8px
 
-					> .media
+					> .files
 						> img
 							display block
 							max-width 100%
@@ -393,9 +323,9 @@ root(isDark)
 					> .renote
 						margin 8px 0
 
-						> .mk-note-preview
+						> *
 							padding 16px
-							border dashed 1px isDark ? #4e945e : #c0dac6
+							border dashed 1px var(--quoteBorder)
 							border-radius 8px
 
 				> .app
@@ -410,14 +340,14 @@ root(isDark)
 					border none
 					box-shadow none
 					font-size 1em
-					color isDark ? #606984 : #ddd
+					color var(--noteActions)
 					cursor pointer
 
 					&:not(:last-child)
 						margin-right 28px
 
 					&:hover
-						color isDark ? #9198af : #666
+						color var(--noteActionsHover)
 
 					> .count
 						display inline
@@ -425,18 +355,6 @@ root(isDark)
 						color #999
 
 					&.reacted
-						color $theme-color
-
-.zyjjkidcqjnlegkqebitfviomuqmseqk[data-darkmode]
-	root(true)
-
-.zyjjkidcqjnlegkqebitfviomuqmseqk:not([data-darkmode])
-	root(false)
-
-.srwrkujossgfuhrbnvqkybtzxpblgchi[data-darkmode]
-	mediaRoot(true)
-
-.srwrkujossgfuhrbnvqkybtzxpblgchi:not([data-darkmode])
-	mediaRoot(false)
+						color var(--primary)
 
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue
index f7fca5de92..884be3a841 100644
--- a/src/client/app/desktop/views/pages/deck/deck.notes.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue
@@ -127,7 +127,7 @@ export default Vue.extend({
 		prepend(note, silent = false) {
 			//#region 弾く
 			const isMyNote = note.userId == this.$store.state.i.id;
-			const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+			const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
 
 			if (this.$store.state.settings.showMyRenotes === false) {
 				if (isMyNote && isPureRenote) {
@@ -195,9 +195,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.eamppglmnmimdhrlzhplwpvyeaqmmhxu
 	.transition
 		.mk-notes-enter
 		.mk-notes-leave-to
@@ -214,9 +212,9 @@ root(isDark)
 			line-height 32px
 			font-size 14px
 			text-align center
-			color isDark ? #666b79 : #aaa
-			background isDark ? #242731 : #fdfdfd
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			color var(--dateDividerFg)
+			background var(--dateDividerBg)
+			border-bottom solid 1px var(--faceDivider)
 
 			span
 				margin 0 16px
@@ -232,21 +230,15 @@ root(isDark)
 			width 100%
 			text-align center
 			color #ccc
-			background isDark ? #282C37 : #fff
-			border-top solid 1px isDark ? #1c2023 : #eaeaea
+			background var(--face)
+			border-top solid 1px var(--faceDivider)
 			border-bottom-left-radius 6px
 			border-bottom-right-radius 6px
 
 			&:hover
-				background isDark ? #2e3440 : #f5f5f5
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
 			&:active
-				background isDark ? #21242b : #eee
-
-.eamppglmnmimdhrlzhplwpvyeaqmmhxu[data-darkmode]
-	root(true)
-
-.eamppglmnmimdhrlzhplwpvyeaqmmhxu:not([data-darkmode])
-	root(false)
+				box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notification.vue b/src/client/app/desktop/views/pages/deck/deck.notification.vue
index d0093ff282..149bd10293 100644
--- a/src/client/app/desktop/views/pages/deck/deck.notification.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.notification.vue
@@ -109,7 +109,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.dsfykdcjpuwfvpefwufddclpjhzktmpw
 	> .notification
 		padding 16px
 		font-size 13px
@@ -142,14 +142,14 @@ root(isDark)
 
 				> .mk-time
 					margin-left auto
-					color isDark ? #606984 : #c0c0c0
+					color var(--noteHeaderInfo)
 					font-size 0.9em
 
 			> .note-preview
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
 			> .note-ref
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
 				[data-fa]
 					font-size 1em
@@ -170,10 +170,4 @@ root(isDark)
 			> div > header i
 				color #888
 
-.dsfykdcjpuwfvpefwufddclpjhzktmpw[data-darkmode]
-	root(true)
-
-.dsfykdcjpuwfvpefwufddclpjhzktmpw:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications.vue b/src/client/app/desktop/views/pages/deck/deck.notifications.vue
index fcb74b9140..29de691fe2 100644
--- a/src/client/app/desktop/views/pages/deck/deck.notifications.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue
@@ -1,8 +1,7 @@
 <template>
 <div class="oxynyeqmfvracxnglgulyqfgqxnxmehl">
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
-	<!--<transition-group name="mk-notifications" class="transition notifications">-->
-	<div class="notifications">
+	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications">
 		<template v-for="(notification, i) in _notifications">
 			<x-notification class="notification" :notification="notification" :key="notification.id"/>
 			<p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
@@ -10,8 +9,7 @@
 				<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
 			</p>
 		</template>
-	</div>
-	<!--</transition-group>-->
+	</component>
 	<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
 		<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }}
 	</button>
@@ -40,8 +38,7 @@ export default Vue.extend({
 			notifications: [],
 			queue: [],
 			moreNotifications: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 
@@ -64,8 +61,7 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
 		this.connection.on('notification', this.onNotification);
 
@@ -88,8 +84,7 @@ export default Vue.extend({
 	},
 
 	beforeDestroy() {
-		this.connection.off('notification', this.onNotification);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 
 		this.column.$off('top', this.onTop);
 		this.column.$off('bottom', this.onBottom);
@@ -119,7 +114,7 @@ export default Vue.extend({
 		onNotification(notification) {
 			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
 			this.connection.send({
-				type: 'read_notification',
+				type: 'readNotification',
 				id: notification.id
 			});
 
@@ -157,8 +152,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-
+.oxynyeqmfvracxnglgulyqfgqxnxmehl
 	.transition
 		.mk-notifications-enter
 		.mk-notifications-leave-to
@@ -171,7 +165,7 @@ root(isDark)
 	> .notifications
 
 		> .notification:not(:last-child)
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			border-bottom solid 1px var(--faceDivider)
 
 		> .date
 			display block
@@ -179,9 +173,9 @@ root(isDark)
 			line-height 32px
 			text-align center
 			font-size 0.8em
-			color isDark ? #666b79 : #aaa
-			background isDark ? #242731 : #fdfdfd
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			color var(--dateDividerFg)
+			background var(--dateDividerBg)
+			border-bottom solid 1px var(--faceDivider)
 
 			span
 				margin 0 16px
@@ -223,10 +217,4 @@ root(isDark)
 		> [data-fa]
 			margin-right 4px
 
-.oxynyeqmfvracxnglgulyqfgqxnxmehl[data-darkmode]
-	root(true)
-
-.oxynyeqmfvracxnglgulyqfgqxnxmehl:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
index 231b505f5d..d245e3ecf5 100644
--- a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
@@ -6,14 +6,16 @@
 		<template v-if="column.type == 'hybrid'">%fa:share-alt%</template>
 		<template v-if="column.type == 'global'">%fa:globe%</template>
 		<template v-if="column.type == 'list'">%fa:list%</template>
+		<template v-if="column.type == 'hashtag'">%fa:hashtag%</template>
 		<span>{{ name }}</span>
 	</span>
 
 	<div class="editor" style="padding:0 12px" v-if="edit">
-		<mk-switch v-model="column.isMediaOnly" @change="onChangeSettings" text="%i18n:@is-media-only%"/>
-		<mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/>
+		<ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">%i18n:@is-media-only%</ui-switch>
+		<ui-switch v-model="column.isMediaView" @change="onChangeSettings">%i18n:@is-media-view%</ui-switch>
 	</div>
 	<x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
+	<x-hashtag-tl v-if="column.type == 'hashtag'" :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
 	<x-tl v-else :src="column.type" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
 </x-column>
 </template>
@@ -23,12 +25,14 @@ import Vue from 'vue';
 import XColumn from './deck.column.vue';
 import XTl from './deck.tl.vue';
 import XListTl from './deck.list-tl.vue';
+import XHashtagTl from './deck.hashtag-tl.vue';
 
 export default Vue.extend({
 	components: {
 		XColumn,
 		XTl,
-		XListTl
+		XListTl,
+		XHashtagTl
 	},
 
 	props: {
@@ -65,6 +69,7 @@ export default Vue.extend({
 				case 'hybrid': return '%i18n:common.deck.hybrid%';
 				case 'global': return '%i18n:common.deck.global%';
 				case 'list': return this.column.list.title;
+				case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title;
 			}
 		}
 	},
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue
index a9e4d489c3..8aed80fa1b 100644
--- a/src/client/app/desktop/views/pages/deck/deck.tl.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue
@@ -36,18 +36,17 @@ export default Vue.extend({
 			fetching: true,
 			moreFetching: false,
 			existMore: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 
 	computed: {
 		stream(): any {
 			switch (this.src) {
-				case 'home': return (this as any).os.stream;
-				case 'local': return (this as any).os.streams.localTimelineStream;
-				case 'hybrid': return (this as any).os.streams.hybridTimelineStream;
-				case 'global': return (this as any).os.streams.globalTimelineStream;
+				case 'home': return (this as any).os.stream.useSharedConnection('homeTimeline');
+				case 'local': return (this as any).os.stream.useSharedConnection('localTimeline');
+				case 'hybrid': return (this as any).os.stream.useSharedConnection('hybridTimeline');
+				case 'global': return (this as any).os.stream.useSharedConnection('globalTimeline');
 			}
 		},
 
@@ -68,8 +67,7 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = this.stream.getConnection();
-		this.connectionId = this.stream.use();
+		this.connection = this.stream;
 
 		this.connection.on('note', this.onNote);
 		if (this.src == 'home') {
@@ -81,12 +79,7 @@ export default Vue.extend({
 	},
 
 	beforeDestroy() {
-		this.connection.off('note', this.onNote);
-		if (this.src == 'home') {
-			this.connection.off('follow', this.onChangeFollowing);
-			this.connection.off('unfollow', this.onChangeFollowing);
-		}
-		this.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 
 	methods: {
@@ -96,7 +89,7 @@ export default Vue.extend({
 			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
 				(this as any).api(this.endpoint, {
 					limit: fetchLimit + 1,
-					mediaOnly: this.mediaOnly,
+					withFiles: this.mediaOnly,
 					includeMyRenotes: this.$store.state.settings.showMyRenotes,
 					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@@ -117,7 +110,7 @@ export default Vue.extend({
 
 			const promise = (this as any).api(this.endpoint, {
 				limit: fetchLimit + 1,
-				mediaOnly: this.mediaOnly,
+				withFiles: this.mediaOnly,
 				untilId: (this.$refs.timeline as any).tail().id,
 				includeMyRenotes: this.$store.state.settings.showMyRenotes,
 				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
@@ -138,7 +131,7 @@ export default Vue.extend({
 		},
 
 		onNote(note) {
-			if (this.mediaOnly && note.media.length == 0) return;
+			if (this.mediaOnly && note.files.length == 0) return;
 
 			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
index 26b989656e..22b4c50bb4 100644
--- a/src/client/app/desktop/views/pages/deck/deck.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui :class="$style.root">
-	<div class="qlvquzbjribqcaozciifydkngcwtyzje" :data-darkmode="$store.state.device.darkmode">
+	<div class="qlvquzbjribqcaozciifydkngcwtyzje" :style="style">
 		<template v-for="ids in layout">
 			<div v-if="ids.length > 1" class="folder">
 				<template v-for="id, i in ids">
@@ -35,6 +35,11 @@ export default Vue.extend({
 			if (this.$store.state.settings.deck == null) return [];
 			if (this.$store.state.settings.deck.layout == null) return this.$store.state.settings.deck.columns.map(c => [c.id]);
 			return this.$store.state.settings.deck.layout;
+		},
+		style(): any {
+			return {
+				height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)`
+			};
 		}
 	},
 
@@ -85,6 +90,7 @@ export default Vue.extend({
 	},
 
 	mounted() {
+		document.title = (this as any).os.instanceName;
 		document.documentElement.style.overflow = 'hidden';
 	},
 
@@ -137,6 +143,24 @@ export default Vue.extend({
 							type: 'global'
 						});
 					}
+				}, {
+					icon: '%fa:at%',
+					text: '%i18n:common.deck.mentions%',
+					action: () => {
+						this.$store.dispatch('settings/addDeckColumn', {
+							id: uuid(),
+							type: 'mentions'
+						});
+					}
+				}, {
+					icon: '%fa:envelope R%',
+					text: '%i18n:common.deck.direct%',
+					action: () => {
+						this.$store.dispatch('settings/addDeckColumn', {
+							id: uuid(),
+							type: 'direct'
+						});
+					}
 				}, {
 					icon: '%fa:list%',
 					text: '%i18n:common.deck.list%',
@@ -151,6 +175,20 @@ export default Vue.extend({
 							w.close();
 						});
 					}
+				}, {
+					icon: '%fa:hashtag%',
+					text: '%i18n:common.deck.hashtag%',
+					action: () => {
+						(this as any).apis.input({
+							title: '%i18n:@enter-hashtag-tl-title%'
+						}).then(title => {
+							this.$store.dispatch('settings/addDeckColumn', {
+								id: uuid(),
+								type: 'hashtag',
+								tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id
+							});
+						});
+					}
 				}, {
 					icon: '%fa:bell R%',
 					text: '%i18n:common.deck.notifications%',
@@ -183,9 +221,7 @@ export default Vue.extend({
 </style>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.qlvquzbjribqcaozciifydkngcwtyzje
 	display flex
 	flex 1
 	padding 16px 0 16px 16px
@@ -213,18 +249,12 @@ root(isDark)
 
 	> button
 		padding 0 16px
-		color isDark ? #93a0a5 : #888
+		color var(--faceTextButton)
 
 		&:hover
-			color isDark ? #b8c5ca : #777
+			color var(--faceTextButtonHover)
 
 		&:active
-			color isDark ? #fff : #555
-
-.qlvquzbjribqcaozciifydkngcwtyzje[data-darkmode]
-	root(true)
-
-.qlvquzbjribqcaozciifydkngcwtyzje:not([data-darkmode])
-	root(false)
+			color var(--faceTextButtonActive)
 
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
index 15397232e0..e1fecc98bc 100644
--- a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue
@@ -135,9 +135,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.wtdtxvecapixsepjtcupubtsmometobz
 	.gqpwvtwtprsbmnssnbicggtwqhmylhnq
 		> header
 			padding 16px
@@ -169,14 +167,5 @@ root(isDark)
 				background rgba(#000, 0.7)
 				border-radius 4px
 
-		> header
-			color isDark ? #fff : #000
-
-.wtdtxvecapixsepjtcupubtsmometobz[data-darkmode]
-	root(true)
-
-.wtdtxvecapixsepjtcupubtsmometobz:not([data-darkmode])
-	root(false)
-
 </style>
 
diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue
index 217dcb7751..dec6c4551a 100644
--- a/src/client/app/desktop/views/pages/drive.vue
+++ b/src/client/app/desktop/views/pages/drive.vue
@@ -31,7 +31,7 @@ export default Vue.extend({
 			const title = folder.name + ' | %i18n:@title%';
 
 			// Rewrite URL
-			history.pushState(null, title, '/i/drive/folder/' + folder.id);
+			history.pushState(null, title, `/i/drive/folder/${folder.id}`);
 
 			document.title = title;
 		}
diff --git a/src/client/app/desktop/views/pages/games/reversi.vue b/src/client/app/desktop/views/pages/games/reversi.vue
index ce9b42c65f..1b0e790a22 100644
--- a/src/client/app/desktop/views/pages/games/reversi.vue
+++ b/src/client/app/desktop/views/pages/games/reversi.vue
@@ -16,10 +16,10 @@ export default Vue.extend({
 	methods: {
 		nav(game, actualNav) {
 			if (actualNav) {
-				this.$router.push('/reversi/' + game.id);
+				this.$router.push(`/reversi/${game.id}`);
 			} else {
 				// TODO: https://github.com/vuejs/vue-router/issues/703
-				this.$router.push('/reversi/' + game.id);
+				this.$router.push(`/reversi/${game.id}`);
 			}
 		}
 	}
diff --git a/src/client/app/desktop/views/pages/home.vue b/src/client/app/desktop/views/pages/home.vue
index c7ff0904e0..e595ef4c36 100644
--- a/src/client/app/desktop/views/pages/home.vue
+++ b/src/client/app/desktop/views/pages/home.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<mk-home :mode="mode" @loaded="loaded"/>
+	<mk-home :mode="mode" @loaded="loaded" ref="home" v-hotkey.global="keymap"/>
 </mk-ui>
 </template>
 
@@ -15,6 +15,13 @@ export default Vue.extend({
 			default: 'timeline'
 		}
 	},
+	computed: {
+		keymap(): any {
+			return {
+				't': this.focus
+			};
+		}
+	},
 	mounted() {
 		document.title = (this as any).os.instanceName;
 
@@ -23,6 +30,9 @@ export default Vue.extend({
 	methods: {
 		loaded() {
 			Progress.done();
+		},
+		focus() {
+			this.$refs.home.focus();
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue
index 1ebd53cef4..4be33dda04 100644
--- a/src/client/app/desktop/views/pages/messaging-room.vue
+++ b/src/client/app/desktop/views/pages/messaging-room.vue
@@ -46,7 +46,7 @@ export default Vue.extend({
 				this.user = user;
 				this.fetching = false;
 
-				document.title = 'メッセージ: ' + getUserName(this.user);
+				document.title = `メッセージ: ${getUserName(this.user)}`;
 
 				Progress.done();
 			});
diff --git a/src/client/app/desktop/views/pages/selectdrive.vue b/src/client/app/desktop/views/pages/selectdrive.vue
index c846f2418f..b82ed0a208 100644
--- a/src/client/app/desktop/views/pages/selectdrive.vue
+++ b/src/client/app/desktop/views/pages/selectdrive.vue
@@ -54,7 +54,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mkp-selectdrive
 	display block
@@ -72,7 +72,7 @@ export default Vue.extend({
 		left 0
 		width 100%
 		height 72px
-		background lighten($theme-color, 95%)
+		background var(--primaryLighten95)
 
 		.upload
 			display inline-block
@@ -85,7 +85,7 @@ export default Vue.extend({
 			width 40px
 			height 40px
 			font-size 1em
-			color rgba($theme-color, 0.5)
+			color var(--primaryAlpha05)
 			background transparent
 			outline none
 			border solid 1px transparent
@@ -93,13 +93,13 @@ export default Vue.extend({
 
 			&:hover
 				background transparent
-				border-color rgba($theme-color, 0.3)
+				border-color var(--primaryAlpha03)
 
 			&:active
-				color rgba($theme-color, 0.6)
+				color var(--primaryAlpha06)
 				background transparent
-				border-color rgba($theme-color, 0.5)
-				box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset
+				border-color var(--primaryAlpha05)
+				//box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset
 
 			&:focus
 				&:after
@@ -110,7 +110,7 @@ export default Vue.extend({
 					right -5px
 					bottom -5px
 					left -5px
-					border 2px solid rgba($theme-color, 0.3)
+					border 2px solid var(--primaryAlpha03)
 					border-radius 8px
 
 		.ok
@@ -136,7 +136,7 @@ export default Vue.extend({
 					right -5px
 					bottom -5px
 					left -5px
-					border 2px solid rgba($theme-color, 0.3)
+					border 2px solid var(--primaryAlpha03)
 					border-radius 8px
 
 			&:disabled
@@ -145,20 +145,20 @@ export default Vue.extend({
 
 		.ok
 			right 16px
-			color $theme-color-foreground
-			background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
-			border solid 1px lighten($theme-color, 15%)
+			color var(--primaryForeground)
+			background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%)
+			border solid 1px var(--primaryLighten15)
 
 			&:not(:disabled)
 				font-weight bold
 
 			&:hover:not(:disabled)
-				background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
-				border-color $theme-color
+				background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%)
+				border-color var(--primary)
 
 			&:active:not(:disabled)
-				background $theme-color
-				border-color $theme-color
+				background var(--primary)
+				border-color var(--primary)
 
 		.cancel
 			right 148px
diff --git a/src/client/app/desktop/views/pages/stats/stats.vue b/src/client/app/desktop/views/pages/stats/stats.vue
index 41005b6398..219885fb9e 100644
--- a/src/client/app/desktop/views/pages/stats/stats.vue
+++ b/src/client/app/desktop/views/pages/stats/stats.vue
@@ -34,7 +34,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus">
-@import '~const.styl'
+
 
 .tcrwdhwpuxrwmcttxjcsehgpagpstqey
 	width 100%
@@ -43,7 +43,7 @@ export default Vue.extend({
 	> .stats
 		display flex
 		justify-content center
-		margin-bottom 16px
+		margin 0 auto 16px auto
 		padding 32px
 		background #fff
 		box-shadow 0 2px 8px rgba(#000, 0.1)
@@ -54,11 +54,12 @@ export default Vue.extend({
 
 			> *:first-child
 				display block
-				color $theme-color
+				color var(--primary)
 
 			> *:last-child
 				font-size 70%
 
 	> div
-		max-width 850px
+		max-width 950px
+		margin 0 auto
 </style>
diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue
deleted file mode 100644
index 7d9a4606a1..0000000000
--- a/src/client/app/desktop/views/pages/user-list.users.vue
+++ /dev/null
@@ -1,125 +0,0 @@
-<template>
-<div>
-	<mk-widget-container>
-		<template slot="header">%fa:users% %i18n:@users%</template>
-		<button slot="func" title="%i18n:@add-user%" @click="add">%fa:plus%</button>
-
-		<div data-id="d0b63759-a822-4556-a5ce-373ab966e08a">
-			<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw% %i18n:common.loading%<mk-ellipsis/></p>
-			<template v-else-if="users.length != 0">
-				<div class="user" v-for="_user in users">
-					<mk-avatar class="avatar" :user="_user"/>
-					<div class="body">
-						<router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link>
-						<p class="username">@{{ _user | acct }}</p>
-					</div>
-				</div>
-			</template>
-			<p class="empty" v-else>%i18n:@no-one%</p>
-		</div>
-	</mk-widget-container>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-
-export default Vue.extend({
-	props: {
-		list: {
-			type: Object,
-			required: true
-		}
-	},
-	data() {
-		return {
-			fetching: true,
-			users: []
-		};
-	},
-	mounted() {
-		(this as any).api('users/show', {
-			userIds: this.list.userIds
-		}).then(users => {
-			this.users = users;
-			this.fetching = false;
-		});
-	},
-	methods: {
-		add() {
-			(this as any).apis.input({
-				title: '%i18n:@username%',
-			}).then(async (username: string) => {
-				if (username.startsWith('@')) username = username.slice(1);
-				const user = await (this as any).api('users/show', {
-					username
-				});
-
-				(this as any).api('users/lists/push', {
-					listId: this.list.id,
-					userId: user.id
-				});
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-root(isDark)
-	> .user
-		padding 16px
-		border-bottom solid 1px isDark ? #1c2023 : #eee
-
-		&:last-child
-			border-bottom none
-
-		&:after
-			content ""
-			display block
-			clear both
-
-		> .avatar
-			display block
-			float left
-			margin 0 12px 0 0
-			width 42px
-			height 42px
-			border-radius 8px
-
-		> .body
-			float left
-			width calc(100% - 54px)
-
-			> .name
-				margin 0
-				font-size 16px
-				line-height 24px
-				color isDark ? #fff : #555
-
-			> .username
-				display block
-				margin 0
-				font-size 15px
-				line-height 16px
-				color isDark ? #606984 : #ccc
-
-	> .empty
-		margin 0
-		padding 16px
-		text-align center
-		color #aaa
-
-	> .fetching
-		margin 0
-		padding 16px
-		text-align center
-		color #aaa
-
-[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"][data-darkmode]
-	root(true)
-
-[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"]:not([data-darkmode])
-	root(false)
-
-</style>
diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue
deleted file mode 100644
index 2241b84e5e..0000000000
--- a/src/client/app/desktop/views/pages/user-list.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<template>
-<mk-ui>
-	<div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c">
-		<div>
-			<div><h1>{{ list.title }}</h1></div>
-			<x-users :list="list"/>
-		</div>
-		<main>
-			<mk-user-list-timeline :list="list"/>
-		</main>
-	</div>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import XUsers from './user-list.users.vue';
-
-export default Vue.extend({
-	components: {
-		XUsers
-	},
-	data() {
-		return {
-			fetching: true,
-			list: null
-		};
-	},
-	watch: {
-		$route: 'fetch'
-	},
-	mounted() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this as any).api('users/lists/show', {
-				listId: this.$route.params.list
-			}).then(list => {
-				this.list = list;
-				this.fetching = false;
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-[data-id="02010e15-cc48-4245-8636-16078a9b623c"]
-	display flex
-	justify-content center
-	margin 0 auto
-	max-width 1200px
-
-	> main
-	> div > div
-		> *:not(:last-child)
-			margin-bottom 16px
-
-	> main
-		padding 16px
-		width calc(100% - 275px * 2)
-
-	> div
-		width 275px
-		margin 0
-		padding 16px 0 16px 16px
-
-</style>
diff --git a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
index e4a771910a..cf05006c00 100644
--- a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
+++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="followers-you-know">
+<div class="vahgrswmbzfdlmomxnqftuueyvwaafth">
 	<p class="title">%fa:users%%i18n:@title%</p>
 	<p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p>
 	<div v-if="!fetching && users.length > 0">
@@ -36,10 +36,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.followers-you-know
-	background #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.vahgrswmbzfdlmomxnqftuueyvwaafth
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 
 	> .title
 		z-index 1
@@ -48,7 +48,7 @@ export default Vue.extend({
 		line-height 42px
 		font-size 0.9em
 		font-weight bold
-		color #888
+		color var(--faceHeaderText)
 		box-shadow 0 1px rgba(#000, 0.07)
 
 		> i
diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue
index 516eea0288..36ae360248 100644
--- a/src/client/app/desktop/views/pages/user/user.friends.vue
+++ b/src/client/app/desktop/views/pages/user/user.friends.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="friends">
+<div class="hozptpaliadatkehcmcayizwzwwctpbc">
 	<p class="title">%fa:users%%i18n:@title%</p>
 	<p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p>
 	<template v-if="!fetching && users.length != 0">
@@ -40,11 +40,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-.friends
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.hozptpaliadatkehcmcayizwzwwctpbc
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 	overflow hidden
 
 	> .title
@@ -54,8 +53,8 @@ root(isDark)
 		line-height 42px
 		font-size 0.9em
 		font-weight bold
-		background isDark ? #313543 : inherit
-		color isDark ? #e3e5e8 : #888
+		background var(--faceHeader)
+		color var(--faceHeaderText)
 		box-shadow 0 1px rgba(#000, 0.07)
 
 		> i
@@ -73,7 +72,7 @@ root(isDark)
 
 	> .user
 		padding 16px
-		border-bottom solid 1px isDark ? #21242f : #eee
+		border-bottom solid 1px var(--faceDivider)
 
 		&:last-child
 			border-bottom none
@@ -99,24 +98,19 @@ root(isDark)
 				margin 0
 				font-size 16px
 				line-height 24px
-				color isDark ? #ccc : #555
+				color var(--text)
 
 			> .username
 				display block
 				margin 0
 				font-size 15px
 				line-height 16px
-				color isDark ? #555 : #ccc
+				color var(--text)
+				opacity 0.7
 
 		> .mk-follow-button
 			position absolute
 			top 16px
 			right 16px
 
-.friends[data-darkmode]
-	root(true)
-
-.friends:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue
index d8f4656ed0..76eb8f9e1c 100644
--- a/src/client/app/desktop/views/pages/user/user.header.vue
+++ b/src/client/app/desktop/views/pages/user/user.header.vue
@@ -6,7 +6,7 @@
 		<div class="title">
 			<p class="name">{{ user | userName }}</p>
 			<div>
-				<span class="username"><mk-acct :user="user"/></span>
+				<span class="username"><mk-acct :user="user" :detail="true" /></span>
 				<span v-if="user.isBot" title="%i18n:@is-bot%">%fa:robot%</span>
 				<span class="location" v-if="user.host === null && user.profile.location">%fa:map-marker% {{ user.profile.location }}</span>
 				<span class="birthday" v-if="user.host === null && user.profile.birthday">%fa:birthday-cake% {{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</span>
@@ -100,12 +100,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	background isDark ? #282C37 : #fff
-	border 1px solid rgba(#000, 0.075)
-	border-radius 6px
+.header
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 	overflow hidden
 
 	&[data-is-dark-background]
@@ -182,12 +180,12 @@ root(isDark)
 
 	> .body
 		padding 16px 16px 16px 154px
-		color isDark ? #c5ced6 : #555
+		color var(--text)
 
 		> .status
 			margin-top 16px
 			padding-top 16px
-			border-top solid 1px rgba(#000, isDark ? 0.2 : 0.1)
+			border-top solid 1px var(--faceDivider)
 			font-size 80%
 
 			> *
@@ -196,24 +194,18 @@ root(isDark)
 				margin-right 16px
 
 				&:not(:last-child)
-					border-right solid 1px rgba(#000, isDark ? 0.2 : 0.1)
+					border-right solid 1px var(--faceDivider)
 
 				&.clickable
 					cursor pointer
 
 					&:hover
-						color isDark ? #fff : #000
+						color var(--faceTextButtonHover)
 
 				> b
 					margin-right 4px
 					font-size 1rem
 					font-weight bold
-					color $theme-color
-
-.header[data-darkmode]
-	root(true)
-
-.header:not([data-darkmode])
-	root(false)
+					color var(--primary)
 
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue
index 8397e56484..628d5b6d95 100644
--- a/src/client/app/desktop/views/pages/user/user.photos.vue
+++ b/src/client/app/desktop/views/pages/user/user.photos.vue
@@ -1,10 +1,10 @@
 <template>
-<div class="photos">
+<div class="dzsuvbsrrrwobdxifudxuefculdfiaxd">
 	<p class="title">%fa:camera%%i18n:@title%</p>
 	<p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p>
 	<div class="stream" v-if="!fetching && images.length > 0">
 		<div v-for="image in images" class="img"
-			:style="`background-image: url(${image.url})`"
+			:style="`background-image: url(${image.thumbnailUrl})`"
 		></div>
 	</div>
 	<p class="empty" v-if="!fetching && images.length == 0">%i18n:@no-photos%</p>
@@ -24,12 +24,12 @@ export default Vue.extend({
 	mounted() {
 		(this as any).api('users/notes', {
 			userId: this.user.id,
-			withMedia: true,
+			withFiles: true,
 			limit: 9
 		}).then(notes => {
 			notes.forEach(note => {
-				note.media.forEach(media => {
-					if (this.images.length < 9) this.images.push(media);
+				note.files.forEach(file => {
+					if (this.images.length < 9) this.images.push(file);
 				});
 			});
 			this.fetching = false;
@@ -39,11 +39,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-.photos
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.dzsuvbsrrrwobdxifudxuefculdfiaxd
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 	overflow hidden
 
 	> .title
@@ -53,8 +52,8 @@ root(isDark)
 		line-height 42px
 		font-size 0.9em
 		font-weight bold
-		background: isDark ? #313543 : inherit
-		color isDark ? #e3e5e8 : #888
+		background var(--faceHeader)
+		color var(--faceHeaderText)
 		box-shadow 0 1px rgba(#000, 0.07)
 
 		> i
@@ -88,10 +87,4 @@ root(isDark)
 		> i
 			margin-right 4px
 
-.photos[data-darkmode]
-	root(true)
-
-.photos:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue
index efd5be4672..fe10b54378 100644
--- a/src/client/app/desktop/views/pages/user/user.profile.vue
+++ b/src/client/app/desktop/views/pages/user/user.profile.vue
@@ -85,10 +85,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	background isDark ? #282C37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
+.profile
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
 
 	> *:first-child
 		border-top none !important
@@ -96,7 +96,7 @@ root(isDark)
 	> .friend-form
 		padding 16px
 		text-align center
-		border-bottom solid 1px isDark ? #21242f : #eee
+		border-bottom solid 1px var(--faceDivider)
 
 		> .followed
 			margin 12px 0 0 0
@@ -114,7 +114,7 @@ root(isDark)
 	> .action-form
 		padding 16px
 		text-align center
-		border-bottom solid 1px isDark ? #21242f : #eee
+		border-bottom solid 1px var(--faceDivider)
 
 		> *
 			width 100%
@@ -122,10 +122,4 @@ root(isDark)
 			&:not(:last-child)
 				margin-bottom 12px
 
-.profile[data-darkmode]
-	root(true)
-
-.profile:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue
index 67987fcb94..608c12b7e2 100644
--- a/src/client/app/desktop/views/pages/user/user.timeline.vue
+++ b/src/client/app/desktop/views/pages/user/user.timeline.vue
@@ -66,7 +66,7 @@ export default Vue.extend({
 					limit: fetchLimit + 1,
 					untilDate: this.date ? this.date.getTime() : undefined,
 					includeReplies: this.mode == 'with-replies',
-					withMedia: this.mode == 'with-media'
+					withFiles: this.mode == 'with-media'
 				}).then(notes => {
 					if (notes.length == fetchLimit + 1) {
 						notes.pop();
@@ -86,7 +86,7 @@ export default Vue.extend({
 				userId: this.user.id,
 				limit: fetchLimit + 1,
 				includeReplies: this.mode == 'with-replies',
-				withMedia: this.mode == 'with-media',
+				withFiles: this.mode == 'with-media',
 				untilId: (this.$refs.timeline as any).tail().id
 			});
 
@@ -112,17 +112,16 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	background isDark ? #282C37 : #fff
+.oh5y2r7l5lx8j6jj791ykeiwgihheguk
+	background var(--face)
+	border-radius var(--round)
+	overflow hidden
 
 	> header
 		padding 0 8px
 		z-index 10
-		background isDark ? #313543 : #fff
-		border-radius 6px 6px 0 0
-		box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08)
+		background var(--faceHeader)
+		box-shadow 0 1px var(--desktopTimelineHeaderShadow)
 
 		> span
 			display inline-block
@@ -132,7 +131,7 @@ root(isDark)
 			user-select none
 
 			&[data-active]
-				color $theme-color
+				color var(--primary)
 				cursor default
 				font-weight bold
 
@@ -144,14 +143,14 @@ root(isDark)
 					left -8px
 					width calc(100% + 16px)
 					height 2px
-					background $theme-color
+					background var(--primary)
 
 			&:not([data-active])
-				color isDark ? #9aa2a7 : #6f7477
+				color var(--desktopTimelineSrc)
 				cursor pointer
 
 				&:hover
-					color isDark ? #d9dcde : #525a5f
+					color var(--desktopTimelineSrcHover)
 
 	> .loading
 		padding 64px 0
@@ -170,10 +169,4 @@ root(isDark)
 			font-size 3em
 			color #ccc
 
-.oh5y2r7l5lx8j6jj791ykeiwgihheguk[data-darkmode]
-	root(true)
-
-.oh5y2r7l5lx8j6jj791ykeiwgihheguk:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue
index afb5e674d9..a8da890936 100644
--- a/src/client/app/desktop/views/pages/user/user.vue
+++ b/src/client/app/desktop/views/pages/user/user.vue
@@ -1,15 +1,16 @@
 <template>
 <mk-ui>
-	<div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching" :data-darkmode="$store.state.device.darkmode">
+	<div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching">
 		<div class="is-suspended" v-if="user.isSuspended">%fa:exclamation-triangle% %i18n:@is-suspended%</div>
 		<div class="is-remote" v-if="user.host != null">%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></div>
 		<main>
 			<div class="main">
 				<x-header :user="user"/>
-				<mk-note-detail v-if="user.pinnedNote" :note="user.pinnedNote" :compact="true"/>
+				<mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/>
 				<x-timeline class="timeline" ref="tl" :user="user"/>
 			</div>
 			<div class="side">
+				<div class="instance" v-if="!$store.getters.isSignedIn"><mk-instance/></div>
 				<x-profile :user="user"/>
 				<x-twitter :user="user" v-if="user.host === null && user.twitter"/>
 				<mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/>
@@ -28,7 +29,6 @@
 <script lang="ts">
 import Vue from 'vue';
 import parseAcct from '../../../../../../misc/acct/parse';
-import getUserName from '../../../../../../misc/get-user-name';
 import Progress from '../../../../common/scripts/loading';
 import XHeader from './user.header.vue';
 import XTimeline from './user.timeline.vue';
@@ -79,7 +79,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.xygkxeaeontfaokvqmiblezmhvhostak
 	width 980px
 	padding 16px
 	margin 0 auto
@@ -89,17 +89,16 @@ root(isDark)
 		margin-bottom 16px
 		padding 14px 16px
 		font-size 14px
-		border-radius 6px
+		box-shadow var(--shadow)
+		border-radius var(--round)
 
 		&.is-suspended
-			color isDark ? #ffb4b4 : #570808
-			background isDark ? #611d1d : #ffdbdb
-			border solid 1px isDark ? #d64a4a : #e09696
+			color var(--suspendedInfoFg)
+			background var(--suspendedInfoBg)
 
 		&.is-remote
-			color isDark ? #ffbd3e : #573c08
-			background isDark ? #42321c : #fff0db
-			border solid 1px isDark ? #90733c : #dcbb7b
+			color var(--remoteInfoFg)
+			background var(--remoteInfoBg)
 
 		> a
 			font-weight bold
@@ -119,8 +118,7 @@ root(isDark)
 			margin-right 16px
 
 			> .timeline
-				border 1px solid rgba(#000, 0.075)
-				border-radius 6px
+				box-shadow var(--shadow)
 
 		> .side
 			width 275px
@@ -134,24 +132,22 @@ root(isDark)
 				font-size 0.8em
 				color #aaa
 
+			> .instance
+				box-shadow var(--shadow)
+				border-radius var(--round)
+
 			> .nav
 				padding 16px
 				font-size 12px
-				color #aaa
-				background isDark ? #21242f : #fff
-				border solid 1px rgba(#000, 0.075)
-				border-radius 6px
+				color var(--text)
+				background var(--face)
+				box-shadow var(--shadow)
+				border-radius var(--round)
 
 				a
-					color #999
+					color var(--text)99
 
 				i
-					color #ccc
-
-.xygkxeaeontfaokvqmiblezmhvhostak[data-darkmode]
-	root(true)
-
-.xygkxeaeontfaokvqmiblezmhvhostak:not([data-darkmode])
-	root(false)
+					color var(--text)
 
 </style>
diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue
index ac2f921a21..65651f7ffc 100644
--- a/src/client/app/desktop/views/pages/welcome.vue
+++ b/src/client/app/desktop/views/pages/welcome.vue
@@ -1,45 +1,147 @@
 <template>
 <div class="mk-welcome">
-	<img ref="pointer" class="pointer" src="/assets/pointer.png" alt="">
+	<div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div>
+
 	<button @click="dark">
 		<template v-if="$store.state.device.darkmode">%fa:moon%</template>
 		<template v-else>%fa:R moon%</template>
 	</button>
-	<div class="body">
-		<div class="container">
-			<div class="info">
-				<span><b>{{ host }}</b></span>
-				<span class="stats" v-if="stats">
-					<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
-					<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
-				</span>
-			</div>
-			<main>
-				<div class="about">
+
+	<mk-forkit class="forkit"/>
+
+	<main>
+		<div class="body">
+			<div class="main block">
+				<div>
 					<h1 v-if="name != 'Misskey'">{{ name }}</h1>
-					<h1 v-else><img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"></h1>
-					<p class="powerd-by" v-if="name != 'Misskey'" v-html="'%i18n:@powered-by-misskey%'"></p>
-					<p class="desc" v-html="description || '%i18n:common.about%'"></p>
-					<a ref="signup" @click="signup">📦 %i18n:@signup%</a>
+					<h1 v-else><img svg-inline src="../../../../assets/title.svg" :alt="name"></h1>
+
+					<div class="info">
+						<span><b>{{ host }}</b> - <span v-html="'%i18n:@powered-by-misskey%'"></span></span>
+						<span class="stats" v-if="stats">
+							<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
+							<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
+						</span>
+					</div>
+
+					<div class="desc">
+						<span class="desc" v-html="description || '%i18n:common.about%'"></span>
+						<a class="about" @click="about">%i18n:@about%</a>
+					</div>
+
+					<p class="sign">
+						<span class="signup" @click="signup">%i18n:@signup%</span>
+						<span class="divider">|</span>
+						<span class="signin" @click="signin">%i18n:@signin%</span>
+					</p>
+
+					<img src="/assets/ai.png" alt="" title="藍" class="char">
+				</div>
+			</div>
+
+			<div class="announcements block">
+				<header>%fa:broadcast-tower% %i18n:@announcements%</header>
+				<div v-if="announcements && announcements.length > 0">
+					<div v-for="announcement in announcements">
+						<h1 v-html="announcement.title"></h1>
+						<div v-html="announcement.text"></div>
+					</div>
+				</div>
+			</div>
+
+			<div class="photos block">
+				<header>%fa:images% %i18n:@photos%</header>
+				<div>
+					<div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div>
+				</div>
+			</div>
+
+			<div class="tag-cloud block">
+				<div>
+					<mk-tag-cloud/>
+				</div>
+			</div>
+
+			<div class="nav block">
+				<div>
+					<mk-nav class="nav"/>
+				</div>
+			</div>
+
+			<div class="side">
+				<div class="trends block">
+					<div>
+						<mk-trends/>
+					</div>
+				</div>
+
+				<div class="tl block">
+					<header>%fa:comment-alt R% %i18n:@timeline%</header>
+					<div>
+						<mk-welcome-timeline class="tl" :max="20"/>
+					</div>
+				</div>
+
+				<div class="info block">
+					<header>%fa:info-circle% %i18n:@info%</header>
+					<div>
+						<div v-if="meta" class="body">
+							<p>Version: <b>{{ meta.version }}</b></p>
+							<p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p>
+						</div>
+					</div>
 				</div>
-				<div class="login">
-					<mk-signin/>
-				</div>
-			</main>
-			<div class="hashtags">
-				<router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link>
 			</div>
-			<mk-nav class="nav"/>
 		</div>
-		<mk-forkit class="forkit"/>
-		<img src="assets/title.dark.svg" :alt="name">
-	</div>
-	<div class="tl">
-		<mk-welcome-timeline :max="20"/>
-	</div>
-	<modal name="signup" width="500px" height="auto" scrollable>
-		<header :class="$style.signupFormHeader">%i18n:@signup%</header>
-		<mk-signup :class="$style.signupForm"/>
+	</main>
+
+	<modal name="about" class="about modal" width="800px" height="auto" scrollable>
+		<article class="fpdezooorhntlzyeszemrsqdlgbysvxq">
+			<h1>%i18n:common.intro.title%</h1>
+			<p v-html="'%i18n:common.intro.about%'"></p>
+			<section>
+				<h2>%i18n:common.intro.features%</h2>
+				<section>
+					<div class="body">
+						<h3>%i18n:common.intro.rich-contents%</h3>
+						<p v-html="'%i18n:common.intro.rich-contents-desc%'"></p>
+					</div>
+					<div class="image"><img src="/assets/about/post.png" alt=""></div>
+				</section>
+				<section>
+					<div class="body">
+						<h3>%i18n:common.intro.reaction%</h3>
+						<p v-html="'%i18n:common.intro.reaction-desc%'"></p>
+					</div>
+					<div class="image"><img src="/assets/about/reaction.png" alt=""></div>
+				</section>
+				<section>
+					<div class="body">
+						<h3>%i18n:common.intro.ui%</h3>
+						<p v-html="'%i18n:common.intro.ui-desc%'"></p>
+					</div>
+					<div class="image"><img src="/assets/about/ui.png" alt=""></div>
+				</section>
+				<section>
+					<div class="body">
+						<h3>%i18n:common.intro.drive%</h3>
+						<p v-html="'%i18n:common.intro.drive-desc%'"></p>
+					</div>
+					<div class="image"><img src="/assets/about/drive.png" alt=""></div>
+				</section>
+			</section>
+			<p v-html="'%i18n:common.intro.outro%'"></p>
+		</article>
+	</modal>
+
+	<modal name="signup" class="modal" width="450px" height="auto" scrollable>
+		<header class="formHeader">%i18n:@signup%</header>
+		<mk-signup class="form"/>
+	</modal>
+
+	<modal name="signin" class="modal" width="450px" height="auto" scrollable>
+		<header class="formHeader">%i18n:@signin%</header>
+		<mk-signin class="form"/>
 	</modal>
 </div>
 </template>
@@ -47,52 +149,65 @@
 <script lang="ts">
 import Vue from 'vue';
 import { host, copyright } from '../../../config';
+import { concat } from '../../../../../prelude/array';
 
 export default Vue.extend({
 	data() {
 		return {
+			meta: null,
 			stats: null,
+			banner: null,
 			copyright,
 			host,
 			name: 'Misskey',
 			description: '',
-			pointerInterval: null,
-			tags: []
+			announcements: [],
+			photos: []
 		};
 	},
+
 	created() {
 		(this as any).os.getMeta().then(meta => {
+			this.meta = meta;
 			this.name = meta.name;
 			this.description = meta.description;
+			this.announcements = meta.broadcasts;
+			this.banner = meta.bannerUrl;
 		});
 
 		(this as any).api('stats').then(stats => {
 			this.stats = stats;
 		});
 
-		(this as any).api('hashtags/trend').then(stats => {
-			this.tags = stats.map(x => x.tag);
+		const image = [
+			'image/jpeg',
+			'image/png',
+			'image/gif'
+		];
+
+		(this as any).api('notes/local-timeline', {
+			fileType: image,
+			excludeNsfw: true,
+			limit: 6
+		}).then((notes: any[]) => {
+			const files = concat(notes.map((n: any): any[] => n.files));
+			this.photos = files.filter(f => image.includes(f.type)).slice(0, 6);
 		});
 	},
-	mounted() {
-		this.point();
-		this.pointerInterval = setInterval(this.point, 100);
-	},
-	beforeDestroy() {
-		clearInterval(this.pointerInterval);
-	},
+
 	methods: {
-		point() {
-			const x = this.$refs.signup.getBoundingClientRect();
-			this.$refs.pointer.style.top = x.top + x.height + 'px';
-			this.$refs.pointer.style.left = x.left + 'px';
+		about() {
+			this.$modal.show('about');
 		},
+
 		signup() {
 			this.$modal.show('signup');
 		},
+
 		signin() {
 			this.$modal.show('signin');
 		},
+
 		dark() {
 			this.$store.commit('device/set', {
 				key: 'darkmode',
@@ -103,189 +218,289 @@ export default Vue.extend({
 });
 </script>
 
-<style>
-#wait {
-	right: auto;
-	left: 15px;
-}
+<style lang="stylus">
+#wait
+	right auto
+	left 15px
+
+.v--modal-overlay
+	background rgba(0, 0, 0, 0.6)
+
+.modal
+	.form
+		padding 24px 48px 48px 48px
+
+	.formHeader
+		text-align center
+		padding 48px 0 12px 0
+		margin 0 48px
+		font-size 1.5em
+
+	.v--modal-box
+		background var(--face)
+		color var(--text)
+
+		.formHeader
+			border-bottom solid 1px rgba(#000, 0.2)
+
+.v--modal-overlay.about
+	.v--modal-box.v--modal
+		margin 32px 0
+
+.fpdezooorhntlzyeszemrsqdlgbysvxq
+	padding 64px
+
+	> p:last-child
+		margin-bottom 0
+
+	> h1
+		margin-top 0
+
+	> section
+		> h2
+			border-bottom 1px solid var(--faceDivider)
+
+		> section
+			display grid
+			grid-template-rows 1fr
+			grid-template-columns 180px 1fr
+			gap 32px
+			margin-bottom 32px
+			padding-bottom 32px
+			border-bottom 1px solid var(--faceDivider)
+
+			&:nth-child(odd)
+				grid-template-columns 1fr 180px
+
+				> .body
+					grid-column 1
+
+				> .image
+					grid-column 2
+
+			> .body
+				grid-row 1
+				grid-column 2
+
+			> .image
+				grid-row 1
+				grid-column 1
+
+				> img
+					display block
+					width 100%
+					height 100%
+					object-fit cover
 </style>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-welcome
 	display flex
 	min-height 100vh
 
-	> .pointer
-		display block
+	> .banner
+		position absolute
+		top 0
+		left 0
+		width 100%
+		height 400px
+		background-position center
+		background-size cover
+		opacity 0.7
+
+		&:after
+			content ""
+			display block
+			position absolute
+			bottom 0
+			left 0
+			width 100%
+			height 100px
+			background linear-gradient(transparent, var(--bg))
+
+	> .forkit
 		position absolute
-		z-index 1
 		top 0
 		right 0
-		width 180px
-		margin 0 0 0 -180px
-		transform rotateY(180deg) translateX(-10px) translateY(-48px)
-		pointer-events none
 
 	> button
 		position fixed
 		z-index 1
-		top 0
-		left 0
+		bottom 16px
+		left 16px
 		padding 16px
 		font-size 18px
-		color #fff
+		color var(--text)
 
-		display none // TODO
+	> main
+		margin 0 auto
+		padding 64px
+		width 100%
+		max-width 1200px
 
-	> .body
-		flex 1
-		padding 64px 0 0 0
-		text-align center
-		background #578394
-		background-position center
-		background-size cover
-
-		&:before
-			content ''
-			display block
-			position absolute
-			top 0
-			left 0
-			right 0
-			bottom 0
-			background rgba(#000, 0.5)
-
-		> .forkit
-			position absolute
-			top 0
-			right 0
-
-		> img
-			position absolute
-			bottom 16px
-			right 16px
-			width 150px
-
-		> .container
-			$aboutWidth = 380px
-			$loginWidth = 340px
-			$width = $aboutWidth + $loginWidth
-
-			> .info
-				margin 0 auto 16px auto
-				width $width
-				font-size 14px
-				color #fff
-
-				> .stats
-					margin-left 16px
-					padding-left 16px
-					border-left solid 1px #fff
-
-					> *
-						margin-right 16px
-
-			> main
-				display flex
-				margin auto
-				width $width
-				border-radius 8px
-				overflow hidden
-				box-shadow 0 2px 8px rgba(#000, 0.3)
-
-				> .about
-					width $aboutWidth
-					color #444
-					background #fff
-
-					> h1
-						margin 0 0 16px 0
-						padding 32px 32px 0 32px
-						color #444
-
-						> img
-							width 170px
-							vertical-align bottom
-
-					> .powerd-by
-						margin 16px
-						opacity 0.7
-
-					> .desc
-						margin 0
-						padding 0 32px 16px 32px
-
-					> a
-						display inline-block
-						margin 0 0 32px 0
-						font-weight bold
-
-				> .login
-					width $loginWidth
-					padding 16px 32px 32px 32px
-					background isDark ? #2e3440 : #f5f5f5
-
-			> .hashtags
-				margin 16px auto
-				width $width
-				font-size 14px
-				color #fff
-				background rgba(#000, 0.3)
-				border-radius 8px
-
-				> *
-					display inline-block
-					margin 14px
-
-			> .nav
-				display block
-				margin 16px 0
-				font-size 14px
-				color #fff
-
-	> .tl
-		margin 0
-		width 410px
-		height 100vh
-		text-align left
-		background isDark ? #313543 : #fff
-
-		> *
-			max-height 100%
+		.block
+			color var(--text)
+			background var(--face)
+			box-shadow var(--shadow)
+			//border-radius 8px
 			overflow auto
 
-.mk-welcome[data-darkmode]
-	root(true)
+			> header
+				z-index 1
+				padding 0 16px
+				line-height 48px
+				background var(--faceHeader)
+				box-shadow 0 1px 0px rgba(0, 0, 0, 0.1)
 
-.mk-welcome:not([data-darkmode])
-	root(false)
+				& + div
+					max-height calc(100% - 48px)
+
+			> div
+				overflow auto
+
+		> .body
+			display grid
+			grid-template-rows 390px 1fr 256px 64px
+			grid-template-columns 1fr 1fr 350px
+			gap 16px
+			height 1150px
+
+			> .main
+				grid-row 1
+				grid-column 1 / 3
+				border-top solid 5px var(--primary)
+
+				> div
+					padding 32px
+					min-height 100%
+
+					> h1
+						margin 0
+
+						> svg
+							margin -8px 0 0 -16px
+							width 280px
+							height 100px
+							fill currentColor
+
+					> .info
+						margin 0 auto 16px auto
+						width $width
+						font-size 14px
+
+						> .stats
+							margin-left 16px
+							padding-left 16px
+							border-left solid 1px var(--faceDivider)
+
+							> *
+								margin-right 16px
+
+					> .desc
+						max-width calc(100% - 150px)
+
+					> .sign
+						font-size 120%
+						margin-bottom 0
+
+						> .divider
+							margin 0 16px
+
+						> .signin
+						> .signup
+							cursor pointer
+
+							&:hover
+								color var(--primary)
+
+					> .char
+						display block
+						position absolute
+						right 16px
+						bottom 0
+						height 320px
+						opacity 0.7
+
+					> *:not(.char)
+						z-index 1
+
+			> .announcements
+				grid-row 2
+				grid-column 1
+
+				> div
+					padding 32px
+
+					> div
+						padding 0 0 16px 0
+						margin 0 0 16px 0
+						border-bottom 1px solid var(--faceDivider)
+
+						> h1
+							margin 0
+							font-size 1.25em
+
+			> .photos
+				grid-row 2
+				grid-column 2
+
+				> div
+					display grid
+					grid-template-rows 1fr 1fr 1fr
+					grid-template-columns 1fr 1fr
+					gap 8px
+					height 100%
+					padding 16px
+
+					> div
+						//border-radius 4px
+						background-position center center
+						background-size cover
+
+			> .tag-cloud
+				grid-row 3
+				grid-column 1 / 3
+
+				> div
+					height 256px
+					padding 32px
+
+			> .nav
+				display flex
+				justify-content center
+				align-items center
+				grid-row 4
+				grid-column 1 / 3
+				font-size 14px
+
+			> .side
+				display grid
+				grid-row 1 / 5
+				grid-column 3
+				grid-template-rows 1fr 350px
+				grid-template-columns 1fr
+				gap 16px
+
+				> .tl
+					grid-row 1
+					grid-column 1
+					overflow auto
+
+				> .trends
+					grid-row 2
+					grid-column 1
+					padding 8px
+
+				> .info
+					grid-row 3
+					grid-column 1
+
+					> div
+						padding 16px
+
+						> .body
+							> p
+								display block
+								margin 0
 
 </style>
-
-<style lang="stylus" module>
-.signupForm
-	padding 24px 48px 48px 48px
-
-.signupFormHeader
-	padding 48px 0 12px 0
-	margin: 0 48px
-	font-size 1.5em
-	color #777
-	border-bottom solid 1px #eee
-
-.signinForm
-	padding 24px 48px 48px 48px
-
-.signinFormHeader
-	padding 48px 0 12px 0
-	margin: 0 48px
-	font-size 1.5em
-	color #777
-	border-bottom solid 1px #eee
-
-.nav
-	a
-		color #666
-</style>
diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue
index 8ff0bb5d0d..c10ac1ca17 100644
--- a/src/client/app/desktop/views/widgets/polls.vue
+++ b/src/client/app/desktop/views/widgets/polls.vue
@@ -4,7 +4,7 @@
 		<template slot="header">%fa:chart-pie%%i18n:@title%</template>
 		<button slot="func" title="%i18n:@refresh%" @click="fetch">%fa:sync%</button>
 
-		<div class="mkw-polls--body" :data-darkmode="$store.state.device.darkmode">
+		<div class="mkw-polls--body">
 			<div class="poll" v-if="!fetching && poll != null">
 				<p v-if="poll.text"><router-link :to="poll | notePage">{{ poll.text }}</router-link></p>
 				<p v-if="!poll.text"><router-link :to="poll | notePage">%fa:link%</router-link></p>
@@ -64,11 +64,11 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mkw-polls--body
 	> .poll
 		padding 16px
 		font-size 12px
-		color isDark ? #9ea4ad : #555
+		color var(--text)
 
 		> p
 			margin 0 0 8px 0
@@ -91,10 +91,4 @@ root(isDark)
 		> [data-fa]
 			margin-right 4px
 
-.mkw-polls--body[data-darkmode]
-	root(true)
-
-.mkw-polls--body:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue
index 19a2790d95..a763f4d17c 100644
--- a/src/client/app/desktop/views/widgets/post-form.vue
+++ b/src/client/app/desktop/views/widgets/post-form.vue
@@ -68,7 +68,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mkw-post-form
 	background #fff
@@ -107,8 +107,8 @@ export default define({
 		margin 0
 		padding 0 10px
 		height 28px
-		color $theme-color-foreground
-		background $theme-color !important
+		color var(--primaryForeground)
+		background var(--primary) !important
 		outline none
 		border none
 		border-radius 4px
@@ -116,10 +116,10 @@ export default define({
 		cursor pointer
 
 		&:hover
-			background lighten($theme-color, 10%) !important
+			background var(--primaryLighten10) !important
 
 		&:active
-			background darken($theme-color, 10%) !important
+			background var(--primaryDarken10) !important
 			transition background 0s ease
 
 </style>
diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue
index a22607b612..30b7b95d35 100644
--- a/src/client/app/desktop/views/widgets/profile.vue
+++ b/src/client/app/desktop/views/widgets/profile.vue
@@ -1,20 +1,24 @@
 <template>
-<div class="mkw-profile"
-	:data-compact="props.design == 1 || props.design == 2"
-	:data-melt="props.design == 2"
->
-	<div class="banner"
-		:style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''"
-		title="%i18n:@update-banner%"
-		@click="() => os.apis.updateBanner()"
-	></div>
-	<mk-avatar class="avatar" :user="$store.state.i"
-		:disable-link="true"
-		@click="() => os.apis.updateAvatar()"
-		title="%i18n:@update-avatar%"
-	/>
-	<router-link class="name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link>
-	<p class="username">@{{ $store.state.i | acct }}</p>
+<div class="egwyvoaaryotefqhqtmiyawwefemjfsd">
+	<mk-widget-container :show-header="false" :naked="props.design == 2">
+		<div class="egwyvoaaryotefqhqtmiyawwefemjfsd-body"
+			:data-compact="props.design == 1 || props.design == 2"
+			:data-melt="props.design == 2"
+		>
+			<div class="banner"
+				:style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''"
+				title="%i18n:@update-banner%"
+				@click="() => os.apis.updateBanner()"
+			></div>
+			<mk-avatar class="avatar" :user="$store.state.i"
+				:disable-link="true"
+				@click="() => os.apis.updateAvatar()"
+				title="%i18n:@update-avatar%"
+			/>
+			<router-link class="name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link>
+			<p class="username">@{{ $store.state.i | acct }}</p>
+		</div>
+	</mk-widget-container>
 </div>
 </template>
 
@@ -41,12 +45,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	overflow hidden
-	background isDark ? #282c37 : #fff
-	border solid 1px rgba(#000, 0.075)
-	border-radius 6px
-
+.egwyvoaaryotefqhqtmiyawwefemjfsd-body
 	&[data-compact]
 		> .banner:before
 			content ""
@@ -75,9 +74,6 @@ root(isDark)
 			display none
 
 	&[data-melt]
-		background transparent !important
-		border none !important
-
 		> .banner
 			visibility hidden
 
@@ -90,7 +86,7 @@ root(isDark)
 
 	> .banner
 		height 100px
-		background-color isDark ? #303e4a : #f5f5f5
+		background-color var(--primaryAlpha01)
 		background-size cover
 		background-position center
 		cursor pointer
@@ -102,7 +98,7 @@ root(isDark)
 		left 16px
 		width 58px
 		height 58px
-		border solid 3px isDark ? #282c37 : #fff
+		border solid 3px var(--face)
 		border-radius 8px
 		cursor pointer
 
@@ -111,19 +107,14 @@ root(isDark)
 		margin 10px 0 0 84px
 		line-height 16px
 		font-weight bold
-		color isDark ? #fff : #555
+		color var(--text)
 
 	> .username
 		display block
 		margin 4px 0 8px 84px
 		line-height 16px
 		font-size 0.9em
-		color isDark ? #606984 : #999
-
-.mkw-profile[data-darkmode]
-	root(true)
-
-.mkw-profile:not([data-darkmode])
-	root(false)
+		color var(--text)
+		opacity 0.7
 
 </style>
diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue
index c33bf2f2f2..a886796132 100644
--- a/src/client/app/desktop/views/widgets/trends.vue
+++ b/src/client/app/desktop/views/widgets/trends.vue
@@ -49,7 +49,7 @@ export default define({
 				offset: this.offset,
 				renote: false,
 				reply: false,
-				media: false,
+				file: false,
 				poll: false
 			}).then(notes => {
 				const note = notes ? notes[0] : null;
@@ -67,7 +67,7 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mkw-trends
 	.mkw-trends--body
 		> .note
 			padding 16px
@@ -98,10 +98,4 @@ root(isDark)
 			> [data-fa]
 				margin-right 4px
 
-.mkw-trends[data-darkmode]
-	root(true)
-
-.mkw-trends:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue
index 328fa56697..28c6372b6f 100644
--- a/src/client/app/desktop/views/widgets/users.vue
+++ b/src/client/app/desktop/views/widgets/users.vue
@@ -73,11 +73,11 @@ export default define({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mkw-users
 	.mkw-users--body
 		> .user
 			padding 16px
-			border-bottom solid 1px isDark ? #1c2023 : #eee
+			border-bottom solid 1px var(--faceDivider)
 
 			&:last-child
 				border-bottom none
@@ -103,14 +103,15 @@ root(isDark)
 					margin 0
 					font-size 16px
 					line-height 24px
-					color isDark ? #fff : #555
+					color var(--text)
 
 				> .username
 					display block
 					margin 0
 					font-size 15px
 					line-height 16px
-					color isDark ? #606984 : #ccc
+					color var(--text)
+					opacity 0.7
 
 			> .mk-follow-button
 				position absolute
@@ -132,10 +133,4 @@ root(isDark)
 			> [data-fa]
 				margin-right 4px
 
-.mkw-users[data-darkmode]
-	root(true)
-
-.mkw-users:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/init.css b/src/client/app/init.css
index 6ee25d64e2..92bb1d8cf4 100644
--- a/src/client/app/init.css
+++ b/src/client/app/init.css
@@ -32,7 +32,7 @@ body > noscript {
 	left: 0;
 	width: 100%;
 	height: 100%;
-	background: #fff;
+	background: var(--bg);
 	cursor: wait;
 }
 	#ini > svg {
@@ -47,10 +47,6 @@ body > noscript {
 		animation: ini 0.6s infinite linear;
 	}
 
-html[data-darkmode] #ini {
-	background: #191b22;
-}
-
 @keyframes ini {
 	from {
 		transform: rotate(0deg);
diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index cf97957400..c2381067da 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -5,31 +5,27 @@
 import Vue from 'vue';
 import Vuex from 'vuex';
 import VueRouter from 'vue-router';
-import VModal from 'vue-js-modal';
 import * as TreeView from 'vue-json-tree-view';
 import VAnimateCss from 'v-animate-css';
-import Element from 'element-ui';
-import ElementLocaleEn from 'element-ui/lib/locale/lang/en';
-import ElementLocaleJa from 'element-ui/lib/locale/lang/ja';
+import VModal from 'vue-js-modal';
 
+import VueHotkey from './common/hotkey';
 import App from './app.vue';
 import checkForUpdate from './common/scripts/check-for-update';
 import MiOS, { API } from './mios';
 import { version, codename, lang } from './config';
+import { builtinThemes, lightTheme, applyTheme } from './theme';
 
-let elementLocale;
-switch (lang) {
-	case 'ja-JP': elementLocale = ElementLocaleJa; break;
-	case 'en-US': elementLocale = ElementLocaleEn; break;
-	default: elementLocale = ElementLocaleEn; break;
+if (localStorage.getItem('theme') == null) {
+	applyTheme(lightTheme);
 }
 
 Vue.use(Vuex);
 Vue.use(VueRouter);
-Vue.use(VModal);
 Vue.use(TreeView);
 Vue.use(VAnimateCss);
-Vue.use(Element, { locale: elementLocale });
+Vue.use(VModal);
+Vue.use(VueHotkey);
 
 // Register global directives
 require('./common/views/directives');
@@ -42,9 +38,13 @@ require('./common/views/widgets');
 require('./common/views/filters');
 
 Vue.mixin({
-	destroyed(this: any) {
-		if (this.$el.parentNode) {
-			this.$el.parentNode.removeChild(this.$el);
+	methods: {
+		destroyDom() {
+			this.$destroy();
+
+			if (this.$el.parentNode) {
+				this.$el.parentNode.removeChild(this.$el);
+			}
 		}
 	}
 });
@@ -91,43 +91,53 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API)
 		const launch = (router: VueRouter, api?: (os: MiOS) => API) => {
 			os.apis = api ? api(os) : null;
 
-			//#region Dark/Light
-			Vue.mixin({
-				data() {
-					return {
-						_unwatchDarkmode_: null
-					};
-				},
-				mounted() {
-					const apply = v => {
-						if (this.$el.setAttribute == null) return;
-						if (v) {
-							this.$el.setAttribute('data-darkmode', 'true');
-						} else {
-							this.$el.removeAttribute('data-darkmode');
-						}
-					};
-
-					apply(os.store.state.device.darkmode);
-
-					this._unwatchDarkmode_ = os.store.watch(s => {
-						return s.device.darkmode;
-					}, apply);
-				},
-				beforeDestroy() {
-					this._unwatchDarkmode_();
-				}
-			});
-
+			//#region theme
 			os.store.watch(s => {
 				return s.device.darkmode;
 			}, v => {
-				if (v) {
-					document.documentElement.setAttribute('data-darkmode', 'true');
-				} else {
-					document.documentElement.removeAttribute('data-darkmode');
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const dark = themes.find(t => t.id == os.store.state.device.darkTheme);
+				const light = themes.find(t => t.id == os.store.state.device.lightTheme);
+				applyTheme(v ? dark : light);
+			});
+			os.store.watch(s => {
+				return s.device.lightTheme;
+			}, v => {
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const theme = themes.find(t => t.id == v);
+				if (!os.store.state.device.darkmode) {
+					applyTheme(theme);
 				}
 			});
+			os.store.watch(s => {
+				return s.device.darkTheme;
+			}, v => {
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const theme = themes.find(t => t.id == v);
+				if (os.store.state.device.darkmode) {
+					applyTheme(theme);
+				}
+			});
+			//#endregion
+
+			//#region shadow
+			const shadow = '0 3px 8px rgba(0, 0, 0, 0.2)';
+			if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadow', shadow);
+			os.store.watch(s => {
+				return s.settings.useShadow;
+			}, v => {
+				document.documentElement.style.setProperty('--shadow', v ? shadow : 'none');
+			});
+			//#endregion
+
+			//#region rounded corners
+			const round = '6px';
+			if (os.store.state.settings.roundedCorners) document.documentElement.style.setProperty('--round', round);
+			os.store.watch(s => {
+				return s.settings.roundedCorners;
+			}, v => {
+				document.documentElement.style.setProperty('--round', v ? round : '0');
+			});
 			//#endregion
 
 			Vue.mixin({
diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts
index 664848b5e7..42171e71fa 100644
--- a/src/client/app/mios.ts
+++ b/src/client/app/mios.ts
@@ -1,22 +1,14 @@
+import autobind from 'autobind-decorator';
 import Vue from 'vue';
 import { EventEmitter } from 'eventemitter3';
 import * as uuid from 'uuid';
 
 import initStore from './store';
-import { apiUrl, swPublickey, version, lang, googleMapsApiKey } from './config';
+import { apiUrl, version, lang } from './config';
 import Progress from './common/scripts/loading';
-import Connection from './common/scripts/streaming/stream';
-import { HomeStreamManager } from './common/scripts/streaming/home';
-import { DriveStreamManager } from './common/scripts/streaming/drive';
-import { ServerStatsStreamManager } from './common/scripts/streaming/server-stats';
-import { NotesStatsStreamManager } from './common/scripts/streaming/notes-stats';
-import { MessagingIndexStreamManager } from './common/scripts/streaming/messaging-index';
-import { ReversiStreamManager } from './common/scripts/streaming/games/reversi/reversi';
 
 import Err from './common/views/components/connect-failed.vue';
-import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline';
-import { HybridTimelineStreamManager } from './common/scripts/streaming/hybrid-timeline';
-import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline';
+import Stream from './common/scripts/stream';
 
 //#region api requests
 let spinner = null;
@@ -101,30 +93,7 @@ export default class MiOS extends EventEmitter {
 	/**
 	 * A connection manager of home stream
 	 */
-	public stream: HomeStreamManager;
-
-	/**
-	 * Connection managers
-	 */
-	public streams: {
-		localTimelineStream: LocalTimelineStreamManager;
-		hybridTimelineStream: HybridTimelineStreamManager;
-		globalTimelineStream: GlobalTimelineStreamManager;
-		driveStream: DriveStreamManager;
-		serverStatsStream: ServerStatsStreamManager;
-		notesStatsStream: NotesStatsStreamManager;
-		messagingIndexStream: MessagingIndexStreamManager;
-		reversiStream: ReversiStreamManager;
-	} = {
-		localTimelineStream: null,
-		hybridTimelineStream: null,
-		globalTimelineStream: null,
-		driveStream: null,
-		serverStatsStream: null,
-		notesStatsStream: null,
-		messagingIndexStream: null,
-		reversiStream: null
-	};
+	public stream: Stream;
 
 	/**
 	 * A registration of service worker
@@ -150,71 +119,36 @@ export default class MiOS extends EventEmitter {
 
 		this.shouldRegisterSw = shouldRegisterSw;
 
-		//#region BIND
-		this.log = this.log.bind(this);
-		this.logInfo = this.logInfo.bind(this);
-		this.logWarn = this.logWarn.bind(this);
-		this.logError = this.logError.bind(this);
-		this.init = this.init.bind(this);
-		this.api = this.api.bind(this);
-		this.getMeta = this.getMeta.bind(this);
-		this.registerSw = this.registerSw.bind(this);
-		//#endregion
-
 		if (this.debug) {
 			(window as any).os = this;
 		}
 	}
 
-	private googleMapsIniting = false;
-
-	public getGoogleMaps() {
-		return new Promise((res, rej) => {
-			if ((window as any).google && (window as any).google.maps) {
-				res((window as any).google.maps);
-			} else {
-				this.once('init-google-maps', () => {
-					res((window as any).google.maps);
-				});
-
-				//#region load google maps api
-				if (!this.googleMapsIniting) {
-					this.googleMapsIniting = true;
-					(window as any).initGoogleMaps = () => {
-						this.emit('init-google-maps');
-					};
-					const head = document.getElementsByTagName('head')[0];
-					const script = document.createElement('script');
-					script.setAttribute('src', `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}&callback=initGoogleMaps`);
-					script.setAttribute('async', 'true');
-					script.setAttribute('defer', 'true');
-					head.appendChild(script);
-				}
-				//#endregion
-			}
-		});
-	}
-
+	@autobind
 	public log(...args) {
 		if (!this.debug) return;
 		console.log.apply(null, args);
 	}
 
+	@autobind
 	public logInfo(...args) {
 		if (!this.debug) return;
 		console.info.apply(null, args);
 	}
 
+	@autobind
 	public logWarn(...args) {
 		if (!this.debug) return;
 		console.warn.apply(null, args);
 	}
 
+	@autobind
 	public logError(...args) {
 		if (!this.debug) return;
 		console.error.apply(null, args);
 	}
 
+	@autobind
 	public signout() {
 		this.store.dispatch('logout');
 		location.href = '/';
@@ -224,27 +158,10 @@ export default class MiOS extends EventEmitter {
 	 * Initialize MiOS (boot)
 	 * @param callback A function that call when initialized
 	 */
+	@autobind
 	public async init(callback) {
 		this.store = initStore(this);
 
-		//#region Init stream managers
-		this.streams.serverStatsStream = new ServerStatsStreamManager(this);
-		this.streams.notesStatsStream = new NotesStatsStreamManager(this);
-
-		this.once('signedin', () => {
-			// Init home stream manager
-			this.stream = new HomeStreamManager(this, this.store.state.i);
-
-			// Init other stream manager
-			this.streams.localTimelineStream = new LocalTimelineStreamManager(this, this.store.state.i);
-			this.streams.hybridTimelineStream = new HybridTimelineStreamManager(this, this.store.state.i);
-			this.streams.globalTimelineStream = new GlobalTimelineStreamManager(this, this.store.state.i);
-			this.streams.driveStream = new DriveStreamManager(this, this.store.state.i);
-			this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.store.state.i);
-			this.streams.reversiStream = new ReversiStreamManager(this, this.store.state.i);
-		});
-		//#endregion
-
 		// ユーザーをフェッチしてコールバックする
 		const fetchme = (token, cb) => {
 			let me = null;
@@ -264,7 +181,7 @@ export default class MiOS extends EventEmitter {
 			// When success
 			.then(res => {
 				// When failed to authenticate user
-				if (res.status !== 200) {
+				if (res.status !== 200 && res.status < 500) {
 					return this.signout();
 				}
 
@@ -295,6 +212,8 @@ export default class MiOS extends EventEmitter {
 		const fetched = () => {
 			this.emit('signedin');
 
+			this.stream = new Stream(this);
+
 			// Finish init
 			callback();
 
@@ -327,6 +246,8 @@ export default class MiOS extends EventEmitter {
 				} else {
 					// Finish init
 					callback();
+
+					this.stream = new Stream(this);
 				}
 			});
 		}
@@ -335,6 +256,7 @@ export default class MiOS extends EventEmitter {
 	/**
 	 * Register service worker
 	 */
+	@autobind
 	private registerSw() {
 		// Check whether service worker and push manager supported
 		const isSwSupported =
@@ -361,7 +283,7 @@ export default class MiOS extends EventEmitter {
 
 				// A public key your push server will use to send
 				// messages to client apps via a push server.
-				applicationServerKey: urlBase64ToUint8Array(swPublickey)
+				applicationServerKey: urlBase64ToUint8Array(this.meta.data.swPublickey)
 			};
 
 			// Subscribe push notification
@@ -417,7 +339,8 @@ export default class MiOS extends EventEmitter {
 	 * @param endpoint エンドポイント名
 	 * @param data パラメータ
 	 */
-	public api(endpoint: string, data: { [x: string]: any } = {}): Promise<{ [x: string]: any }> {
+	@autobind
+	public api(endpoint: string, data: { [x: string]: any } = {}, forceFetch = false): Promise<{ [x: string]: any }> {
 		if (++pending === 1) {
 			spinner = document.createElement('div');
 			spinner.setAttribute('id', 'wait');
@@ -429,13 +352,12 @@ export default class MiOS extends EventEmitter {
 		};
 
 		const promise = new Promise((resolve, reject) => {
-			const viaStream = this.stream && this.stream.hasConnection && this.store.state.device.apiViaStream;
+			const viaStream = this.stream && this.store.state.device.apiViaStream && !forceFetch;
 
 			if (viaStream) {
-				const stream = this.stream.borrow();
 				const id = Math.random().toString();
 
-				stream.once(`api-res:${id}`, res => {
+				this.stream.once(`api:${id}`, res => {
 					if (res == null || Object.keys(res).length == 0) {
 						resolve(null);
 					} else if (res.res) {
@@ -445,11 +367,10 @@ export default class MiOS extends EventEmitter {
 					}
 				});
 
-				stream.send({
-					type: 'api',
-					id,
-					endpoint,
-					data
+				this.stream.send('api', {
+					id: id,
+					ep: endpoint,
+					data: data
 				});
 			} else {
 				// Append a credential
@@ -502,6 +423,7 @@ export default class MiOS extends EventEmitter {
 	 * Misskeyのメタ情報を取得します
 	 * @param force キャッシュを無視するか否か
 	 */
+	@autobind
 	public getMeta(force = false) {
 		return new Promise<{ [x: string]: any }>(async (res, rej) => {
 			if (this.isMetaFetching) {
@@ -529,16 +451,6 @@ export default class MiOS extends EventEmitter {
 			}
 		});
 	}
-
-	public connections: Connection[] = [];
-
-	public registerStreamConnection(connection: Connection) {
-		this.connections.push(connection);
-	}
-
-	public unregisterStreamConnection(connection: Connection) {
-		this.connections = this.connections.filter(c => c != connection);
-	}
 }
 
 class WindowSystem extends EventEmitter {
diff --git a/src/client/app/mobile/api/post.ts b/src/client/app/mobile/api/post.ts
index 15b2f6b691..5c0f0af852 100644
--- a/src/client/app/mobile/api/post.ts
+++ b/src/client/app/mobile/api/post.ts
@@ -1,13 +1,12 @@
-import PostForm from '../views/components/post-form.vue';
+import PostForm from '../views/components/post-form-dialog.vue';
 
 export default (os) => (opts) => {
 	const o = opts || {};
 
-	const app = document.getElementById('app');
-	app.style.display = 'none';
+	document.documentElement.style.overflow = 'hidden';
 
 	function recover() {
-		app.style.display = 'block';
+		document.documentElement.style.overflow = 'auto';
 	}
 
 	const vm = new PostForm({
diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts
index 5b9d45462a..9412c85980 100644
--- a/src/client/app/mobile/script.ts
+++ b/src/client/app/mobile/script.ts
@@ -6,7 +6,6 @@ import VueRouter from 'vue-router';
 
 // Style
 import './style.styl';
-import '../../element.scss';
 
 import init from '../init';
 
diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl
index df8f4a8fae..095e5266fd 100644
--- a/src/client/app/mobile/style.styl
+++ b/src/client/app/mobile/style.styl
@@ -8,12 +8,4 @@
 
 html
 	height 100%
-	background #ececed !important
-
-	&[data-darkmode]
-		background #191B22 !important
-
-body
-	display flex
-	flex-direction column
-	min-height 100%
+	background var(--bg)
diff --git a/src/client/app/mobile/views/components/dialog.vue b/src/client/app/mobile/views/components/dialog.vue
index 9ee01cb782..fff44a28c3 100644
--- a/src/client/app/mobile/views/components/dialog.vue
+++ b/src/client/app/mobile/views/components/dialog.vue
@@ -78,7 +78,7 @@ export default Vue.extend({
 				scale: 0.8,
 				duration: 300,
 				easing: [ 0.5, -0.5, 1, 0.5 ],
-				complete: () => this.$destroy()
+				complete: () => this.destroyDom()
 			});
 		},
 		onBgClick() {
@@ -91,7 +91,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-dialog
 	> .bg
@@ -145,20 +145,20 @@ export default Vue.extend({
 					margin 0 0.375em
 
 				&:hover
-					color $theme-color
+					color var(--primary)
 
 				&:active
-					color darken($theme-color, 10%)
+					color var(--primaryDarken10)
 					transition color 0s ease
 
 </style>
 
 <style lang="stylus" module>
-@import '~const.styl'
+
 
 .header
 	margin 0 0 1em 0
-	color $theme-color
+	color var(--primary)
 	// color #43A4EC
 	font-weight bold
 
diff --git a/src/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue
index d95d5fa223..5fca19939e 100644
--- a/src/client/app/mobile/views/components/drive-file-chooser.vue
+++ b/src/client/app/mobile/views/components/drive-file-chooser.vue
@@ -1,12 +1,12 @@
 <template>
-<div class="mk-drive-file-chooser">
+<div class="cdxzvcfawjxdyxsekbxbfgtplebnoneb">
 	<div class="body">
 		<header>
 			<h1>%i18n:@select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1>
 			<button class="close" @click="cancel">%fa:times%</button>
 			<button v-if="multiple" class="ok" @click="ok">%fa:check%</button>
 		</header>
-		<mk-drive ref="browser"
+		<mk-drive class="drive" ref="browser"
 			:select-file="true"
 			:multiple="multiple"
 			@change-selection="onChangeSelection"
@@ -31,24 +31,24 @@ export default Vue.extend({
 		},
 		onSelected(file) {
 			this.$emit('selected', file);
-			this.$destroy();
+			this.destroyDom();
 		},
 		cancel() {
 			this.$emit('canceled');
-			this.$destroy();
+			this.destroyDom();
 		},
 		ok() {
 			this.$emit('selected', this.files);
-			this.$destroy();
+			this.destroyDom();
 		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-.mk-drive-file-chooser
+.cdxzvcfawjxdyxsekbxbfgtplebnoneb
 	position fixed
-	z-index 2048
+	z-index 20000
 	top 0
 	left 0
 	width 100%
@@ -59,10 +59,11 @@ export default Vue.extend({
 	> .body
 		width 100%
 		height 100%
-		background #fff
+		background var(--faceHeader)
 
 		> header
-			border-bottom solid 1px #eee
+			border-bottom solid 1px var(--faceDivider)
+			color var(--text)
 
 			> h1
 				margin 0
@@ -90,7 +91,7 @@ export default Vue.extend({
 				line-height 42px
 				width 42px
 
-		> .mk-drive
+		> .drive
 			height calc(100% - 42px)
 			overflow scroll
 			-webkit-overflow-scrolling touch
diff --git a/src/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue
index 7934fb7816..6d3fba1efd 100644
--- a/src/client/app/mobile/views/components/drive-folder-chooser.vue
+++ b/src/client/app/mobile/views/components/drive-folder-chooser.vue
@@ -19,11 +19,11 @@ export default Vue.extend({
 	methods: {
 		cancel() {
 			this.$emit('canceled');
-			this.$destroy();
+			this.destroyDom();
 		},
 		ok() {
 			this.$emit('selected', (this.$refs.browser as any).folder);
-			this.$destroy();
+			this.destroyDom();
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue
index deb9941be8..7425afe1e2 100644
--- a/src/client/app/mobile/views/components/drive.file-detail.vue
+++ b/src/client/app/mobile/views/components/drive.file-detail.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="file-detail">
+<div class="pyvicwrksnfyhpfgkjwqknuururpaztw">
 	<div class="preview">
 		<img v-if="kind == 'image'" ref="img"
 			:src="file.url"
@@ -25,7 +25,7 @@
 	</div>
 	<div class="info">
 		<div>
-			<span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span>
+			<span class="type"><mk-file-type-icon :type="file.type"/> {{ file.type }}</span>
 			<span class="separator"></span>
 			<span class="data-size">{{ file.datasize | bytes }}</span>
 			<span class="separator"></span>
@@ -38,10 +38,10 @@
 	</div>
 	<div class="menu">
 		<div>
-			<a :href="`${file.url}?download`" :download="file.name">%fa:download%%i18n:@download%</a>
-			<button @click="rename">%fa:pencil-alt%%i18n:@rename%</button>
-			<button @click="move">%fa:R folder-open%%i18n:@move%</button>
-			<button @click="del">%fa:trash-alt R%%i18n:@delete%</button>
+			<ui-button link :href="`${file.url}?download`" :download="file.name">%fa:download% %i18n:@download%</ui-button>
+			<ui-button @click="rename">%fa:pencil-alt% %i18n:@rename%</ui-button>
+			<ui-button @click="move">%fa:R folder-open% %i18n:@move%</ui-button>
+			<ui-button @click="del">%fa:trash-alt R% %i18n:@delete%</ui-button>
 		</div>
 	</div>
 	<div class="exif" v-show="exif">
@@ -67,7 +67,7 @@
 import Vue from 'vue';
 import * as EXIF from 'exif-js';
 import * as hljs from 'highlight.js';
-import gcd from '../../../common/scripts/gcd';
+import { gcd } from '../../../../../prelude/math';
 
 export default Vue.extend({
 	props: ['file'],
@@ -134,11 +134,10 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.file-detail
-
+.pyvicwrksnfyhpfgkjwqknuururpaztw
 	> .preview
 		padding 8px
-		background #f0f0f0
+		background var(--bg)
 
 		> img
 			display block
@@ -149,9 +148,10 @@ export default Vue.extend({
 
 		> footer
 			padding 8px 8px 0 8px
-			font-size 0.8em
-			color #888
 			text-align center
+			font-size 0.8em
+			color var(--text)
+			opacity 0.7
 
 			> .separator
 				display inline
@@ -179,25 +179,17 @@ export default Vue.extend({
 	> .info
 		padding 14px
 		font-size 0.8em
-		border-top solid 1px #dfdfdf
+		border-top solid 1px var(--faceDivider)
 
 		> div
 			max-width 500px
 			margin 0 auto
+			color var(--text)
 
 			> .separator
 				padding 0 4px
-				color #cdcdcd
-
-			> .type
-			> .data-size
-				color #9d9d9d
-
-				> mk-file-type-icon
-					margin-right 4px
 
 			> .created-at
-				color #bdbdbd
 
 				> [data-fa]
 					margin-right 2px
@@ -207,42 +199,15 @@ export default Vue.extend({
 
 	> .menu
 		padding 14px
-		border-top solid 1px #dfdfdf
+		border-top solid 1px var(--faceDivider)
 
 		> div
 			max-width 500px
 			margin 0 auto
 
-			> *
-				display block
-				width 100%
-				padding 10px 16px
-				margin 0 0 12px 0
-				color #333
-				font-size 0.9em
-				text-align center
-				text-decoration none
-				text-shadow 0 1px 0 rgba(255, 255, 255, 0.9)
-				background-image linear-gradient(#fafafa, #eaeaea)
-				border 1px solid #ddd
-				border-bottom-color #cecece
-				border-radius 3px
-
-				&:last-child
-					margin-bottom 0
-
-				&:active
-					background-color #767676
-					background-image none
-					border-color #444
-					box-shadow 0 1px 3px rgba(#000, 0.075), inset 0 0 5px rgba(#000, 0.2)
-
-				> [data-fa]
-					margin-right 4px
-
 	> .hash
 		padding 14px
-		border-top solid 1px #dfdfdf
+		border-top solid 1px var(--faceDivider)
 
 		> div
 			max-width 500px
@@ -252,7 +217,7 @@ export default Vue.extend({
 				display block
 				margin 0
 				padding 0
-				color #555
+				color var(--text)
 				font-size 0.9em
 
 				> [data-fa]
@@ -273,7 +238,7 @@ export default Vue.extend({
 
 	> .exif
 		padding 14px
-		border-top solid 1px #dfdfdf
+		border-top solid 1px var(--faceDivider)
 
 		> div
 			max-width 500px
@@ -283,7 +248,7 @@ export default Vue.extend({
 				display block
 				margin 0
 				padding 0
-				color #555
+				color var(--text)
 				font-size 0.9em
 
 				> [data-fa]
diff --git a/src/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue
index 6dec4b9f4f..68978bb944 100644
--- a/src/client/app/mobile/views/components/drive.file.vue
+++ b/src/client/app/mobile/views/components/drive.file.vue
@@ -1,5 +1,5 @@
 <template>
-<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected">
+<a class="vupkuhvjnjyqaqhsiogfbywvjxynrgsm" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected">
 	<div class="container">
 		<div class="thumbnail" :style="thumbnail"></div>
 		<div class="body">
@@ -7,20 +7,12 @@
 				<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
 				<span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
 			</p>
-			<!--
-			if file.tags.length > 0
-				ul.tags
-					each tag in file.tags
-						li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name
-			-->
 			<footer>
 				<span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span>
 				<span class="separator"></span>
 				<span class="data-size">{{ file.datasize | bytes }}</span>
 				<span class="separator"></span>
-				<span class="created-at">
-					%fa:R clock%<mk-time :time="file.createdAt"/>
-				</span>
+				<span class="created-at">%fa:R clock%<mk-time :time="file.createdAt"/></span>
 				<template v-if="file.isSensitive">
 					<span class="separator"></span>
 					<span class="nsfw">%fa:eye-slash% %i18n:@nsfw%</span>
@@ -71,9 +63,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-.file
+.vupkuhvjnjyqaqhsiogfbywvjxynrgsm
 	display block
 	text-decoration none !important
 
@@ -111,7 +101,7 @@ export default Vue.extend({
 				padding 0
 				font-size 0.9em
 				font-weight bold
-				color #555
+				color var(--text)
 				text-overflow ellipsis
 				overflow-wrap break-word
 
@@ -135,22 +125,22 @@ export default Vue.extend({
 				display block
 				margin 4px 0 0 0
 				font-size 0.7em
+				color var(--text)
 
 				> .separator
 					padding 0 4px
-					color #CDCDCD
 
 				> .type
-					color #9D9D9D
+					opacity 0.7
 
 					> .mk-file-type-icon
 						margin-right 4px
 
 				> .data-size
-					color #9D9D9D
+					opacity 0.7
 
 				> .created-at
-					color #BDBDBD
+					opacity 0.7
 
 					> [data-fa]
 						margin-right 2px
@@ -159,7 +149,7 @@ export default Vue.extend({
 					color #bf4633
 
 	&[data-is-selected]
-		background $theme-color
+		background var(--primary)
 
 		&, *
 			color #fff !important
diff --git a/src/client/app/mobile/views/components/drive.folder.vue b/src/client/app/mobile/views/components/drive.folder.vue
index 22ff38fecb..05dcbd083e 100644
--- a/src/client/app/mobile/views/components/drive.folder.vue
+++ b/src/client/app/mobile/views/components/drive.folder.vue
@@ -1,5 +1,5 @@
 <template>
-<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`">
+<a class="jvwxssxsytqlqvrpiymarjlzlsxskqsr" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`">
 	<div class="container">
 		<p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right%
 	</div>
@@ -24,9 +24,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.root.folder
+.jvwxssxsytqlqvrpiymarjlzlsxskqsr
 	display block
-	color #777
+	color var(--text)
 	text-decoration none !important
 
 	*
diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue
index c313d225e4..469f6da240 100644
--- a/src/client/app/mobile/views/components/drive.vue
+++ b/src/client/app/mobile/views/components/drive.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mk-drive">
+<div class="kmmwchoexgckptowjmjgfsygeltxfeqs">
 	<nav ref="nav">
 		<a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:@drive%</a>
 		<template v-for="folder in hierarchyFolders">
@@ -26,11 +26,11 @@
 			</p>
 		</div>
 		<div class="folders" v-if="folders.length > 0">
-			<x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/>
+			<x-folder class="folder" v-for="folder in folders" :key="folder.id" :folder="folder"/>
 			<p v-if="moreFolders">%i18n:@load-more%</p>
 		</div>
 		<div class="files" v-if="files.length > 0">
-			<x-file v-for="file in files" :key="file.id" :file="file"/>
+			<x-file class="file" v-for="file in files" :key="file.id" :file="file"/>
 			<button class="more" v-if="moreFiles" @click="fetchMoreFiles">
 				{{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:@load-more%' }}
 			</button>
@@ -81,8 +81,7 @@ export default Vue.extend({
 			hierarchyFolders: [],
 			selectedFiles: [],
 			info: null,
-			connection: null,
-			connectionId: null,
+			connection: null
 
 			fetching: true,
 			fetchingMoreFiles: false,
@@ -94,9 +93,15 @@ export default Vue.extend({
 			return this.selectFile;
 		}
 	},
+	watch: {
+		top() {
+			if (this.isNaked) {
+				(this.$refs.nav as any).style.top = `${this.top}px`;
+			}
+		}
+	},
 	mounted() {
-		this.connection = (this as any).os.streams.driveStream.getConnection();
-		this.connectionId = (this as any).os.streams.driveStream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('drive');
 
 		this.connection.on('file_created', this.onStreamDriveFileCreated);
 		this.connection.on('file_updated', this.onStreamDriveFileUpdated);
@@ -117,12 +122,7 @@ export default Vue.extend({
 		}
 	},
 	beforeDestroy() {
-		this.connection.off('file_created', this.onStreamDriveFileCreated);
-		this.connection.off('file_updated', this.onStreamDriveFileUpdated);
-		this.connection.off('file_deleted', this.onStreamDriveFileDeleted);
-		this.connection.off('folder_created', this.onStreamDriveFolderCreated);
-		this.connection.off('folder_updated', this.onStreamDriveFolderUpdated);
-		(this as any).os.streams.driveStream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 		onStreamDriveFileCreated(file) {
@@ -466,8 +466,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.mk-drive
-	background #fff
+.kmmwchoexgckptowjmjgfsygeltxfeqs
+	background var(--face)
 
 	> nav
 		display block
@@ -480,10 +480,10 @@ export default Vue.extend({
 		overflow auto
 		white-space nowrap
 		font-size 0.9em
-		color rgba(#000, 0.67)
+		color var(--text)
 		-webkit-backdrop-filter blur(12px)
 		backdrop-filter blur(12px)
-		background-color rgba(#fff, 0.75)
+		background-color var(--mobileDriveNavBg)
 		border-bottom solid 1px rgba(#000, 0.13)
 
 		> p
@@ -509,7 +509,7 @@ export default Vue.extend({
 			opacity 0.5
 
 		> .info
-			border-bottom solid 1px #eee
+			border-bottom solid 1px var(--faceDivider)
 
 			&:empty
 				display none
@@ -520,15 +520,15 @@ export default Vue.extend({
 				margin 0 auto
 				padding 4px 16px
 				font-size 10px
-				color #777
+				color var(--text)
 
 		> .folders
 			> .folder
-				border-bottom solid 1px #eee
+				border-bottom solid 1px var(--faceDivider)
 
 		> .files
 			> .file
-				border-bottom solid 1px #eee
+				border-bottom solid 1px var(--faceDivider)
 
 			> .more
 				display block
diff --git a/src/client/app/mobile/views/components/follow-button.vue b/src/client/app/mobile/views/components/follow-button.vue
index 360ee91d4b..3c8b2f98e6 100644
--- a/src/client/app/mobile/views/components/follow-button.vue
+++ b/src/client/app/mobile/views/components/follow-button.vue
@@ -5,7 +5,8 @@
 	:disabled="wait"
 >
 	<template v-if="!wait">
-		<template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half% %i18n:@request-pending%</template>
+		<template v-if="u.hasPendingFollowRequestFromYou && u.isLocked">%fa:hourglass-half% %i18n:@request-pending%</template>
+		<template v-else-if="u.hasPendingFollowRequestFromYou && !u.isLocked">%fa:hourglass-start% %i18n:@follow-processing%</template>
 		<template v-else-if="u.isFollowing">%fa:minus% %i18n:@following%</template>
 		<template v-else-if="!u.isFollowing && u.isLocked">%fa:plus% %i18n:@follow-request%</template>
 		<template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus% %i18n:@follow%</template>
@@ -27,33 +28,31 @@ export default Vue.extend({
 		return {
 			u: this.user,
 			wait: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
 		this.connection.on('follow', this.onFollow);
 		this.connection.on('unfollow', this.onUnfollow);
 	},
 	beforeDestroy() {
-		this.connection.off('follow', this.onFollow);
-		this.connection.off('unfollow', this.onUnfollow);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 	methods: {
 
 		onFollow(user) {
 			if (user.id == this.u.id) {
 				this.u.isFollowing = user.isFollowing;
+				this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
 			}
 		},
 
 		onUnfollow(user) {
 			if (user.id == this.u.id) {
 				this.u.isFollowing = user.isFollowing;
+				this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
 			}
 		},
 
@@ -66,7 +65,7 @@ export default Vue.extend({
 						userId: this.u.id
 					});
 				} else {
-					if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) {
+					if (this.u.hasPendingFollowRequestFromYou) {
 						this.u = await (this as any).api('following/requests/cancel', {
 							userId: this.u.id
 						});
@@ -91,7 +90,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-follow-button
 	display block
@@ -103,29 +102,29 @@ export default Vue.extend({
 	line-height 36px
 	font-size 14px
 	font-weight bold
-	color $theme-color
+	color var(--primary)
 	background transparent
 	outline none
-	border solid 1px $theme-color
+	border solid 1px var(--primary)
 	border-radius 36px
 
 	&:hover
-		background rgba($theme-color, 0.1)
+		background var(--primaryAlpha01)
 
 	&:active
-		background rgba($theme-color, 0.2)
+		background var(--primaryAlpha02)
 
 	&.active
-		color $theme-color-foreground
-		background $theme-color
+		color var(--primaryForeground)
+		background var(--primary)
 
 		&:hover
-			background lighten($theme-color, 10%)
-			border-color lighten($theme-color, 10%)
+			background var(--primaryLighten10)
+			border-color var(--primaryLighten10)
 
 		&:active
-			background darken($theme-color, 10%)
-			border-color darken($theme-color, 10%)
+			background var(--primaryDarken10)
+			border-color var(--primaryDarken10)
 
 	&.wait
 		cursor wait !important
diff --git a/src/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue
index e0461d2bc2..dbb82f4b18 100644
--- a/src/client/app/mobile/views/components/friends-maker.vue
+++ b/src/client/app/mobile/views/components/friends-maker.vue
@@ -47,7 +47,7 @@ export default Vue.extend({
 			this.fetch();
 		},
 		close() {
-			this.$destroy();
+			this.destroyDom();
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue
index e40069bbe3..652a2ad3a4 100644
--- a/src/client/app/mobile/views/components/media-image.vue
+++ b/src/client/app/mobile/views/components/media-image.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide" @click="hide = false">
+<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
 	<div>
 		<b>%fa:exclamation-triangle% %i18n:@sensitive%</b>
 		<span>%i18n:@click-to-show%</span>
@@ -19,12 +19,13 @@ export default Vue.extend({
 		},
 		raw: {
 			default: false
-		},
-		hide: {
-			type: Boolean,
-			default: true
 		}
 	},
+	data() {
+		return {
+			hide: true
+		};
+	}
 	computed: {
 		style(): any {
 			let url = `url(${this.image.thumbnailUrl})`;
@@ -65,7 +66,7 @@ export default Vue.extend({
 		text-align center
 		font-size 12px
 
-		> b
+		> *
 			display block
 
 </style>
diff --git a/src/client/app/mobile/views/components/media-video.vue b/src/client/app/mobile/views/components/media-video.vue
index aea7f41460..59ba695b93 100644
--- a/src/client/app/mobile/views/components/media-video.vue
+++ b/src/client/app/mobile/views/components/media-video.vue
@@ -9,31 +9,35 @@
 	:href="video.url"
 	target="_blank"
 	:style="imageStyle"
-	:title="video.name">
+	:title="video.name"
+>
 	%fa:R play-circle%
 </a>
 </template>
 
 <script lang="ts">
-import Vue from 'vue'
+import Vue from 'vue';
+
 export default Vue.extend({
 	props: {
 		video: {
 			type: Object,
 			required: true
-		},
-		hide: {
-			type: Boolean,
-			default: true
 		}
 	},
+	data() {
+		return {
+			hide: true
+		};
+	},
 	computed: {
 		imageStyle(): any {
 			return {
-				'background-image': `url(${this.video.url})`
+				'background-image': null // TODO `url(${this.video.thumbnailUrl})`
 			};
 		}
-	},})
+	}
+});
 </script>
 
 <style lang="stylus" scoped>
diff --git a/src/client/app/mobile/views/components/mute-button.vue b/src/client/app/mobile/views/components/mute-button.vue
index 3cb568615d..316fbda8f1 100644
--- a/src/client/app/mobile/views/components/mute-button.vue
+++ b/src/client/app/mobile/views/components/mute-button.vue
@@ -41,11 +41,11 @@ export default Vue.extend({
 
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-mute-button
   display block
-  user-select none 
+  user-select none
   cursor pointer
   padding 0 16px
   margin 0
@@ -53,27 +53,27 @@ export default Vue.extend({
   line-height 36px
   font-size 14px
   font-weight bold
-  color $theme-color
+  color var(--primary)
   background transparent
   outline none
-  border solid 1px $theme-color
+  border solid 1px var(--primary)
   border-radius 36px
 
   &:hover
-    background rgba($theme-color, 0.1)
+    background var(--primaryAlpha01)
 
   &:active
-    background rgba($theme-color, 0.2)
+    background var(--primaryAlpha02)
 
   &.active
-    color $theme-color-foreground
-    background $theme-color
+    color var(--primaryForeground)
+    background var(--primary)
 
     &:hover
-      background lighten($theme-color, 10%)
-      border-color lighten($theme-color, 10%)
+      background var(--primaryLighten10)
+      border-color var(--primaryLighten10)
     &:active
-      background darken($theme-color, 10%)
-      border-color darken($theme-color, 10%)
+      background var(--primaryDarken10)
+      border-color var(--primaryDarken10)
 
 </style>
diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue
index e8427798cd..de9c9c1450 100644
--- a/src/client/app/mobile/views/components/note-card.vue
+++ b/src/client/app/mobile/views/components/note-card.vue
@@ -27,17 +27,18 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-note-card
 	display inline-block
 	width 150px
 	//height 120px
 	font-size 12px
-	background isDark ? #282c37 : #fff
+	background var(--face)
 	border-radius 4px
+	box-shadow 0 2px 8px rgba(0, 0, 0, 0.2)
 
 	> a
 		display block
-		color isDark ? #fff : #2c3940
+		color var(--noteText)
 
 		&:hover
 			text-decoration none
@@ -75,17 +76,11 @@ root(isDark)
 				left 0
 				width 100%
 				height 20px
-				background isDark ? linear-gradient(to bottom, rgba(#282c37, 0) 0%, #282c37 100%) : linear-gradient(to bottom, rgba(#fff, 0) 0%, #fff 100%)
+				background linear-gradient(to bottom, transparent 0%, var(--face) 100%)
 
 		> .mk-time
 			display inline-block
 			padding 8px
 			color #aaa
 
-.mk-note-card[data-darkmode]
-	root(true)
-
-.mk-note-card:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue
index f9996f9da6..082f72f1a9 100644
--- a/src/client/app/mobile/views/components/note-detail.vue
+++ b/src/client/app/mobile/views/components/note-detail.vue
@@ -35,20 +35,26 @@
 			</div>
 		</header>
 		<div class="body">
-			<div class="text">
-				<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
-				<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
-				<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
-			</div>
-			<div class="media" v-if="p.media.length > 0">
-				<mk-media-list :media-list="p.media" :raw="true"/>
-			</div>
-			<mk-poll v-if="p.poll" :note="p"/>
-			<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
-			<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
-			<div class="map" v-if="p.geo" ref="map"></div>
-			<div class="renote" v-if="p.renote">
-				<mk-note-preview :note="p.renote"/>
+			<p v-if="p.cw != null" class="cw">
+				<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="p.cw == null || showContent">
+				<div class="text">
+					<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
+					<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
+					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
+				</div>
+				<div class="files" v-if="p.files.length > 0">
+					<mk-media-list :media-list="p.files" :raw="true"/>
+				</div>
+				<mk-poll v-if="p.poll" :note="p"/>
+				<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
+				<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+				<div class="map" v-if="p.geo" ref="map"></div>
+				<div class="renote" v-if="p.renote">
+					<mk-note-preview :note="p.renote"/>
+				</div>
 			</div>
 		</div>
 		<router-link class="time" :to="p | notePage">
@@ -85,12 +91,16 @@ import parse from '../../../../../mfm/parse';
 import MkNoteMenu from '../../../common/views/components/note-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './note.sub.vue';
+import { sum } from '../../../../../prelude/array';
+import noteSubscriber from '../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
 	components: {
 		XSub
 	},
 
+	mixins: [noteSubscriber('note')],
+
 	props: {
 		note: {
 			type: Object,
@@ -103,6 +113,7 @@ export default Vue.extend({
 
 	data() {
 		return {
+			showContent: false,
 			conversation: [],
 			conversationFetching: false,
 			replies: []
@@ -113,19 +124,20 @@ export default Vue.extend({
 		isRenote(): boolean {
 			return (this.note.renote &&
 				this.note.text == null &&
-				this.note.mediaIds.length == 0 &&
+				this.note.fileIds.length == 0 &&
 				this.note.poll == null);
 		},
+
 		p(): any {
 			return this.isRenote ? this.note.renote : this.note;
 		},
+
 		reactionsCount(): number {
 			return this.p.reactionCounts
-				? Object.keys(this.p.reactionCounts)
-					.map(key => this.p.reactionCounts[key])
-					.reduce((a, b) => a + b)
+				? sum(Object.values(this.p.reactionCounts))
 				: 0;
 		},
+
 		urls(): string[] {
 			if (this.p.text) {
 				const ast = parse(this.p.text);
@@ -180,16 +192,19 @@ export default Vue.extend({
 				this.conversation = conversation.reverse();
 			});
 		},
+
 		reply() {
 			(this as any).apis.post({
 				reply: this.p
 			});
 		},
+
 		renote() {
 			(this as any).apis.post({
 				renote: this.p
 			});
 		},
+
 		react() {
 			(this as any).os.new(MkReactionPicker, {
 				source: this.$refs.reactButton,
@@ -198,6 +213,7 @@ export default Vue.extend({
 				big: true
 			});
 		},
+
 		menu() {
 			(this as any).os.new(MkNoteMenu, {
 				source: this.$refs.menuButton,
@@ -210,13 +226,11 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-note-detail
 	overflow hidden
 	width 100%
 	text-align left
-	background isDark ? #282C37 : #fff
+	background var(--face)
 	border-radius 8px
 	box-shadow 0 0 2px rgba(#000, 0.1)
 
@@ -235,26 +249,26 @@ root(isDark)
 		text-align center
 		color #999
 		cursor pointer
-		background isDark ? #21242d : #fafafa
+		background var(--subNoteBg)
 		outline none
 		border none
-		border-bottom solid 1px isDark ? #1c2023 : #eef0f2
+		border-bottom solid 1px var(--faceDivider)
 		border-radius 6px 6px 0 0
 		box-shadow none
 
 		&:hover
-			background isDark ? #16181d : #f6f6f6
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
 
-		&:disabled
-			color #ccc
+		&:active
+			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
 
 	> .conversation
 		> *
-			border-bottom 1px solid isDark ? #1c2023 : #eef0f2
+			border-bottom 1px solid var(--faceDivider)
 
 	> .renote
-		color #9dbb00
-		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+		color var(--renoteText)
+		background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
 
 		> p
 			margin 0
@@ -277,7 +291,7 @@ root(isDark)
 			padding-top 8px
 
 	> .reply-to
-		border-bottom 1px solid isDark ? #1c2023 : #eef0f2
+		border-bottom 1px solid var(--faceDivider)
 
 	> article
 		padding 14px 16px 9px 16px
@@ -310,7 +324,7 @@ root(isDark)
 				> .name
 					display inline-block
 					margin .4em 0
-					color isDark ? #fff : #627079
+					color var(--noteHeaderName)
 					font-size 16px
 					font-weight bold
 					text-align left
@@ -323,53 +337,66 @@ root(isDark)
 					display block
 					text-align left
 					margin 0
-					color isDark ? #606984 : #ccc
+					color var(--noteHeaderAcct)
 
 		> .body
 			padding 8px 0
 
-			> .text
+			> .cw
+				cursor default
 				display block
 				margin 0
 				padding 0
 				overflow-wrap break-word
-				font-size 16px
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
-				@media (min-width 500px)
-					font-size 24px
+				> .text
+					margin-right 8px
 
-			> .renote
-				margin 8px 0
+			> .content
 
-				> .mk-note-preview
-					padding 16px
-					border dashed 1px #c0dac6
-					border-radius 8px
-
-			> .location
-				margin 4px 0
-				font-size 12px
-				color #ccc
-
-			> .map
-				width 100%
-				height 200px
-
-				&:empty
-					display none
-
-			> .mk-url-preview
-				margin-top 8px
-
-			> .media
-				> img
+				> .text
 					display block
-					max-width 100%
+					margin 0
+					padding 0
+					overflow-wrap break-word
+					font-size 16px
+					color var(--noteText)
+
+					@media (min-width 500px)
+						font-size 24px
+
+				> .renote
+					margin 8px 0
+
+					> *
+						padding 16px
+						border dashed 1px var(--quoteBorder)
+						border-radius 8px
+
+				> .location
+					margin 4px 0
+					font-size 12px
+					color #ccc
+
+				> .map
+					width 100%
+					height 200px
+
+					&:empty
+						display none
+
+				> .mk-url-preview
+					margin-top 8px
+
+				> .files
+					> img
+						display block
+						max-width 100%
 
 		> .time
 			font-size 16px
-			color isDark ? #606984 : #c0c0c0
+			color var(--noteHeaderInfo)
 
 		> footer
 			font-size 1.2em
@@ -381,14 +408,14 @@ root(isDark)
 				border none
 				box-shadow none
 				font-size 1em
-				color isDark ? #606984 : #ddd
+				color var(--noteActions)
 				cursor pointer
 
 				&:not(:last-child)
 					margin-right 28px
 
 				&:hover
-					color isDark ? #9198af : #666
+					color var(--noteActionsHover)
 
 				> .count
 					display inline
@@ -396,16 +423,10 @@ root(isDark)
 					color #999
 
 				&.reacted
-					color $theme-color
+					color var(--primary)
 
 	> .replies
 		> *
-			border-top 1px solid isDark ? #1c2023 : #eef0f2
-
-.mk-note-detail[data-darkmode]
-	root(true)
-
-.mk-note-detail:not([data-darkmode])
-	root(false)
+			border-top 1px solid var(--faceDivider)
 
 </style>
diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue
index 5d56d2d326..525f54998e 100644
--- a/src/client/app/mobile/views/components/note-preview.vue
+++ b/src/client/app/mobile/views/components/note-preview.vue
@@ -1,10 +1,16 @@
 <template>
-<div class="mk-note-preview" :class="{ smart: $store.state.device.postStyle == 'smart' }">
+<div class="yohlumlkhizgfkvvscwfcrcggkotpvry" :class="{ smart: $store.state.device.postStyle == 'smart' }">
 	<mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/>
 	<div class="main">
 		<mk-note-header class="header" :note="note" :mini="true"/>
 		<div class="body">
-			<mk-sub-note-content class="text" :note="note"/>
+			<p v-if="note.cw != null" class="cw">
+				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="note.cw == null || showContent">
+				<mk-sub-note-content class="text" :note="note"/>
+			</div>
 		</div>
 	</div>
 </div>
@@ -14,12 +20,23 @@
 import Vue from 'vue';
 
 export default Vue.extend({
-	props: ['note']
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			showContent: false
+		};
+	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.yohlumlkhizgfkvvscwfcrcggkotpvry
 	display flex
 	margin 0
 	padding 0
@@ -65,16 +82,22 @@ root(isDark)
 
 		> .body
 
-			> .text
+			> .cw
 				cursor default
+				display block
 				margin 0
 				padding 0
-				color isDark ? #959ba7 : #717171
+				overflow-wrap break-word
+				color var(--noteText)
 
-.mk-note-preview[data-darkmode]
-	root(true)
+				> .text
+					margin-right 8px
 
-.mk-note-preview:not([data-darkmode])
-	root(false)
+			> .content
+				> .text
+					cursor default
+					margin 0
+					padding 0
+					color var(--subNoteText)
 
 </style>
diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue
index a68aec40a1..24f5be160c 100644
--- a/src/client/app/mobile/views/components/note.sub.vue
+++ b/src/client/app/mobile/views/components/note.sub.vue
@@ -1,10 +1,16 @@
 <template>
-<div class="sub" :class="{ smart: $store.state.device.postStyle == 'smart' }">
+<div class="zlrxdaqttccpwhpaagdmkawtzklsccam" :class="{ smart: $store.state.device.postStyle == 'smart' }">
 	<mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/>
 	<div class="main">
 		<mk-note-header class="header" :note="note" :mini="true"/>
 		<div class="body">
-			<mk-sub-note-content class="text" :note="note"/>
+			<p v-if="note.cw != null" class="cw">
+				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+				<mk-cw-button v-model="showContent"/>
+			</p>
+			<div class="content" v-show="note.cw == null || showContent">
+				<mk-sub-note-content class="text" :note="note"/>
+			</div>
 		</div>
 	</div>
 </div>
@@ -24,16 +30,22 @@ export default Vue.extend({
 			type: Boolean,
 			default: true
 		}
+	},
+
+	data() {
+		return {
+			showContent: false
+		};
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.zlrxdaqttccpwhpaagdmkawtzklsccam
 	display flex
 	padding 16px
 	font-size 10px
-	background isDark ? #21242d : #fcfcfc
+	background var(--subNoteBg)
 
 	@media (min-width 350px)
 		font-size 12px
@@ -77,20 +89,25 @@ root(isDark)
 			margin-bottom 2px
 
 		> .body
-
-			> .text
+			> .cw
+				cursor default
+				display block
 				margin 0
 				padding 0
-				color isDark ? #959ba7 : #717171
+				overflow-wrap break-word
+				color var(--noteText)
 
-				pre
-					max-height 120px
-					font-size 80%
+				> .text
+					margin-right 8px
 
-.sub[data-darkmode]
-	root(true)
+			> .content
+				> .text
+					margin 0
+					padding 0
+					color var(--subNoteText)
 
-.sub:not([data-darkmode])
-	root(false)
+					pre
+						max-height 120px
+						font-size 80%
 
 </style>
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue
index d0cea135f9..f370fbf874 100644
--- a/src/client/app/mobile/views/components/note.vue
+++ b/src/client/app/mobile/views/components/note.vue
@@ -18,7 +18,7 @@
 			<div class="body">
 				<p v-if="p.cw != null" class="cw">
 					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
-					<span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span>
+					<mk-cw-button v-model="showContent"/>
 				</p>
 				<div class="content" v-show="p.cw == null || showContent">
 					<div class="text">
@@ -28,20 +28,18 @@
 						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
 						<a class="rp" v-if="p.renote != null">RP:</a>
 					</div>
-					<div class="media" v-if="p.media.length > 0">
-						<mk-media-list :media-list="p.media"/>
+					<div class="files" v-if="p.files.length > 0">
+						<mk-media-list :media-list="p.files"/>
 					</div>
 					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
 					<div class="map" v-if="p.geo" ref="map"></div>
-					<div class="renote" v-if="p.renote">
-						<mk-note-preview :note="p.renote"/>
-					</div>
+					<div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div>
 				</div>
 				<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
 			</div>
-			<footer>
+			<footer v-if="p.deletedAt == null">
 				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
 				<button @click="reply">
 					<template v-if="p.reply">%fa:reply-all%</template>
@@ -70,19 +68,21 @@ import parse from '../../../../../mfm/parse';
 import MkNoteMenu from '../../../common/views/components/note-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './note.sub.vue';
+import { sum } from '../../../../../prelude/array';
+import noteSubscriber from '../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
 	components: {
 		XSub
 	},
 
+	mixins: [noteSubscriber('note')],
+
 	props: ['note'],
 
 	data() {
 		return {
-			showContent: false,
-			connection: null,
-			connectionId: null
+			showContent: false
 		};
 	},
 
@@ -90,7 +90,7 @@ export default Vue.extend({
 		isRenote(): boolean {
 			return (this.note.renote &&
 				this.note.text == null &&
-				this.note.mediaIds.length == 0 &&
+				this.note.fileIds.length == 0 &&
 				this.note.poll == null);
 		},
 
@@ -100,9 +100,7 @@ export default Vue.extend({
 
 		reactionsCount(): number {
 			return this.p.reactionCounts
-				? Object.keys(this.p.reactionCounts)
-					.map(key => this.p.reactionCounts[key])
-					.reduce((a, b) => a + b)
+				? sum(Object.values(this.p.reactionCounts))
 				: 0;
 		},
 
@@ -118,82 +116,7 @@ export default Vue.extend({
 		}
 	},
 
-	created() {
-		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
-		}
-	},
-
-	mounted() {
-		this.capture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.on('_connected_', this.onStreamConnected);
-		}
-
-		// Draw map
-		if (this.p.geo) {
-			const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true;
-			if (shouldShowMap) {
-				(this as any).os.getGoogleMaps().then(maps => {
-					const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
-					const map = new maps.Map(this.$refs.map, {
-						center: uluru,
-						zoom: 15
-					});
-					new maps.Marker({
-						position: uluru,
-						map: map
-					});
-				});
-			}
-		}
-	},
-
-	beforeDestroy() {
-		this.decapture(true);
-
-		if (this.$store.getters.isSignedIn) {
-			this.connection.off('_connected_', this.onStreamConnected);
-			(this as any).os.stream.dispose(this.connectionId);
-		}
-	},
-
 	methods: {
-		capture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'capture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		decapture(withHandler = false) {
-			if (this.$store.getters.isSignedIn) {
-				this.connection.send({
-					type: 'decapture',
-					id: this.p.id
-				});
-				if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated);
-			}
-		},
-
-		onStreamConnected() {
-			this.capture();
-		},
-
-		onStreamNoteUpdated(data) {
-			const note = data.note;
-			if (note.id == this.note.id) {
-				this.$emit('update:note', note);
-			} else if (note.id == this.note.renoteId) {
-				this.note.renote = note;
-			}
-		},
-
 		reply() {
 			(this as any).apis.post({
 				reply: this.p
@@ -227,11 +150,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.note
 	font-size 12px
-	border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+	border-bottom solid 1px var(--faceDivider)
 
 	&:last-of-type
 		border-bottom none
@@ -255,8 +176,8 @@ root(isDark)
 		padding 8px 16px
 		line-height 28px
 		white-space pre
-		color #9dbb00
-		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+		color var(--renoteText)
+		background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
 
 		@media (min-width 500px)
 			padding 16px
@@ -348,24 +269,11 @@ root(isDark)
 					margin 0
 					padding 0
 					overflow-wrap break-word
-					color isDark ? #fff : #717171
+					color var(--noteText)
 
 					> .text
 						margin-right 8px
 
-					> .toggle
-						display inline-block
-						padding 4px 8px
-						font-size 0.7em
-						color isDark ? #393f4f : #fff
-						background isDark ? #687390 : #b1b9c1
-						border-radius 2px
-						cursor pointer
-						user-select none
-
-						&:hover
-							background isDark ? #707b97 : #bbc4ce
-
 				> .content
 
 					> .text
@@ -373,7 +281,7 @@ root(isDark)
 						margin 0
 						padding 0
 						overflow-wrap break-word
-						color isDark ? #fff : #717171
+						color var(--noteText)
 
 						>>> .title
 							display block
@@ -381,7 +289,7 @@ root(isDark)
 							padding 4px
 							font-size 90%
 							text-align center
-							background isDark ? #2f3944 : #eef1f3
+							background var(--mfmTitleBg)
 							border-radius 4px
 
 						>>> .code
@@ -390,31 +298,31 @@ root(isDark)
 						>>> .quote
 							margin 8px
 							padding 6px 12px
-							color isDark ? #6f808e : #aaa
-							border-left solid 3px isDark ? #637182 : #eee
+							color var(--mfmQuote)
+							border-left solid 3px var(--mfmQuoteLine)
 
 						> .reply
 							margin-right 8px
-							color isDark ? #99abbf : #717171
+							color var(--noteText)
 
 						> .rp
 							margin-left 4px
 							font-style oblique
-							color #a0bf46
+							color var(--renoteText)
 
 						[data-is-me]:after
 							content "you"
 							padding 0 4px
 							margin-left 4px
 							font-size 80%
-							color $theme-color-foreground
-							background $theme-color
+							color var(--primaryForeground)
+							background var(--primary)
 							border-radius 4px
 
 					.mk-url-preview
 						margin-top 8px
 
-					> .media
+					> .files
 						> img
 							display block
 							max-width 100%
@@ -437,9 +345,9 @@ root(isDark)
 					> .renote
 						margin 8px 0
 
-						> .mk-note-preview
+						> *
 							padding 16px
-							border dashed 1px isDark ? #4e945e : #c0dac6
+							border dashed 1px var(--quoteBorder)
 							border-radius 8px
 
 				> .app
@@ -454,14 +362,14 @@ root(isDark)
 					border none
 					box-shadow none
 					font-size 1em
-					color isDark ? #606984 : #ddd
+					color var(--noteActions)
 					cursor pointer
 
 					&:not(:last-child)
 						margin-right 28px
 
 					&:hover
-						color isDark ? #9198af : #666
+						color var(--noteActionsHover)
 
 					> .count
 						display inline
@@ -469,17 +377,7 @@ root(isDark)
 						color #999
 
 					&.reacted
-						color $theme-color
-
-					&.menu
-						@media (max-width 350px)
-							display none
-
-.note[data-darkmode]
-	root(true)
-
-.note:not([data-darkmode])
-	root(false)
+						color var(--primary)
 
 </style>
 
diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue
index 714e521c0f..8f0a1ef196 100644
--- a/src/client/app/mobile/views/components/notes.vue
+++ b/src/client/app/mobile/views/components/notes.vue
@@ -14,8 +14,7 @@
 	</div>
 
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
-	<!-- <transition-group name="mk-notes" class="transition"> -->
-	<div class="transition">
+	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div">
 		<template v-for="(note, i) in _notes">
 			<mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
 			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
@@ -23,8 +22,7 @@
 				<span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
 			</p>
 		</template>
-	</div>
-	<!-- </transition-group> -->
+	</component>
 
 	<footer v-if="more">
 		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
@@ -125,7 +123,7 @@ export default Vue.extend({
 		prepend(note, silent = false) {
 			//#region 弾く
 			const isMyNote = note.userId == this.$store.state.i.id;
-			const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+			const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
 
 			if (this.$store.state.settings.showMyRenotes === false) {
 				if (isMyNote && isPureRenote) {
@@ -219,11 +217,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-notes
 	overflow hidden
-	background isDark ? #282C37 : #fff
+	background var(--face)
 	border-radius 8px
 	box-shadow 0 0 2px rgba(#000, 0.1)
 
@@ -245,9 +241,9 @@ root(isDark)
 			line-height 32px
 			text-align center
 			font-size 0.9em
-			color isDark ? #666b79 : #aaa
-			background isDark ? #242731 : #fdfdfd
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			color var(--dateDividerFg)
+			background var(--dateDividerBg)
+			border-bottom solid 1px var(--faceDivider)
 
 			span
 				margin 0 16px
@@ -278,7 +274,7 @@ root(isDark)
 
 	> footer
 		text-align center
-		border-top solid 1px isDark ? #1c2023 : #eaeaea
+		border-top solid 1px var(--faceDivider)
 
 		&:empty
 			display none
@@ -295,10 +291,4 @@ root(isDark)
 			&:disabled
 				opacity 0.7
 
-.mk-notes[data-darkmode]
-	root(true)
-
-.mk-notes:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue
index ee90c6b46b..4a09104341 100644
--- a/src/client/app/mobile/views/components/notification.vue
+++ b/src/client/app/mobile/views/components/notification.vue
@@ -105,7 +105,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-notification
 	> .notification
 		padding 16px
 		font-size 12px
@@ -154,14 +154,14 @@ root(isDark)
 
 				> .mk-time
 					margin-left auto
-					color isDark ? #606984 : #c0c0c0
+					color var(--noteHeaderInfo)
 					font-size 0.9em
 
 			> .note-preview
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
 			> .note-ref
-				color isDark ? #fff : #717171
+				color var(--noteText)
 
 				[data-fa]
 					font-size 1em
@@ -182,10 +182,4 @@ root(isDark)
 			> div > header i
 				color #888
 
-.mk-notification[data-darkmode]
-	root(true)
-
-.mk-notification:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue
index 9f20c3fb22..e1a2967071 100644
--- a/src/client/app/mobile/views/components/notifications.vue
+++ b/src/client/app/mobile/views/components/notifications.vue
@@ -1,8 +1,7 @@
 <template>
 <div class="mk-notifications">
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
-	<!-- <transition-group name="mk-notifications" class="transition notifications"> -->
-	<div class="transition notifications">
+	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications">
 		<template v-for="(notification, i) in _notifications">
 			<mk-notification :notification="notification" :key="notification.id"/>
 			<p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date">
@@ -10,8 +9,7 @@
 				<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
 			</p>
 		</template>
-	</div>
-	<!-- </transition-group> -->
+	</component>
 
 	<button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
 		<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>
@@ -25,6 +23,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
+
 export default Vue.extend({
 	data() {
 		return {
@@ -32,10 +31,10 @@ export default Vue.extend({
 			fetchingMoreNotifications: false,
 			notifications: [],
 			moreNotifications: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
+
 	computed: {
 		_notifications(): any[] {
 			return (this.notifications as any).map(notification => {
@@ -47,9 +46,9 @@ export default Vue.extend({
 			});
 		}
 	},
+
 	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
+		this.connection = (this as any).os.stream.useSharedConnection('main');
 
 		this.connection.on('notification', this.onNotification);
 
@@ -68,10 +67,11 @@ export default Vue.extend({
 			this.$emit('fetched');
 		});
 	},
+
 	beforeDestroy() {
-		this.connection.off('notification', this.onNotification);
-		(this as any).os.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
+
 	methods: {
 		fetchMoreNotifications() {
 			this.fetchingMoreNotifications = true;
@@ -92,10 +92,11 @@ export default Vue.extend({
 				this.fetchingMoreNotifications = false;
 			});
 		},
+
 		onNotification(notification) {
 			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
 			this.connection.send({
-				type: 'read_notification',
+				type: 'readNotification',
 				id: notification.id
 			});
 
@@ -106,9 +107,9 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.mk-notifications
 	margin 0 auto
-	background isDark ? #282C37 :#fff
+	background var(--face)
 	border-radius 8px
 	box-shadow 0 0 2px rgba(#000, 0.1)
 	overflow hidden
@@ -128,7 +129,7 @@ root(isDark)
 	> .notifications
 
 		> .mk-notification:not(:last-child)
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			border-bottom solid 1px var(--faceDivider)
 
 		> .date
 			display block
@@ -136,9 +137,9 @@ root(isDark)
 			line-height 32px
 			text-align center
 			font-size 0.8em
-			color isDark ? #666b79 : #aaa
-			background isDark ? #242731 : #fdfdfd
-			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+			color var(--dateDividerFg)
+			background var(--dateDividerBg)
+			border-bottom solid 1px var(--faceDivider)
 
 			span
 				margin 0 16px
@@ -171,10 +172,4 @@ root(isDark)
 		> [data-fa]
 			margin-right 4px
 
-.mk-notifications[data-darkmode]
-	root(true)
-
-.mk-notifications:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/notify.vue b/src/client/app/mobile/views/components/notify.vue
index 6d4a481dbe..5f94b91ddd 100644
--- a/src/client/app/mobile/views/components/notify.vue
+++ b/src/client/app/mobile/views/components/notify.vue
@@ -1,6 +1,8 @@
 <template>
-<div class="mk-notify">
-	<mk-notification-preview :notification="notification"/>
+<div class="mk-notify" :class="pos">
+	<div>
+		<mk-notification-preview :notification="notification"/>
+	</div>
 </div>
 </template>
 
@@ -10,11 +12,16 @@ import * as anime from 'animejs';
 
 export default Vue.extend({
 	props: ['notification'],
+	computed: {
+		pos() {
+			return this.$store.state.device.mobileNotificationPosition;
+		}
+	},
 	mounted() {
 		this.$nextTick(() => {
 			anime({
 				targets: this.$el,
-				bottom: '0px',
+				[this.pos]: '0px',
 				duration: 500,
 				easing: 'easeOutQuad'
 			});
@@ -22,10 +29,10 @@ export default Vue.extend({
 			setTimeout(() => {
 				anime({
 					targets: this.$el,
-					bottom: '-64px',
+					[this.pos]: `-${this.$el.offsetHeight}px`,
 					duration: 500,
 					easing: 'easeOutQuad',
-					complete: () => this.$destroy()
+					complete: () => this.destroyDom()
 				});
 			}, 6000);
 		});
@@ -35,15 +42,32 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .mk-notify
+	$height = 78px
+
 	position fixed
-	z-index 1024
-	bottom -64px
+	z-index 10000
 	left 0
+	right 0
 	width 100%
-	height 64px
+	max-width 500px
+	height $height
+	margin 0 auto
+	padding 8px
 	pointer-events none
-	-webkit-backdrop-filter blur(2px)
-	backdrop-filter blur(2px)
-	background-color rgba(#000, 0.5)
+	font-size 80%
+
+	&.bottom
+		bottom -($height)
+
+	&.top
+		top -($height)
+
+	> div
+		height 100%
+		-webkit-backdrop-filter blur(2px)
+		backdrop-filter blur(2px)
+		background-color rgba(#000, 0.5)
+		border-radius 7px
+		overflow hidden
 
 </style>
diff --git a/src/client/app/mobile/views/components/post-form-dialog.vue b/src/client/app/mobile/views/components/post-form-dialog.vue
new file mode 100644
index 0000000000..15b36db945
--- /dev/null
+++ b/src/client/app/mobile/views/components/post-form-dialog.vue
@@ -0,0 +1,126 @@
+<template>
+<div class="ulveipglmagnxfgvitaxyszerjwiqmwl">
+	<div class="bg" ref="bg"></div>
+	<div class="main" ref="main">
+		<mk-post-form ref="form"
+			:reply="reply"
+			:renote="renote"
+			:initial-text="initialText"
+			:instant="instant"
+			@posted="onPosted"
+			@cancel="onCanceled"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as anime from 'animejs';
+
+export default Vue.extend({
+	props: {
+		reply: {
+			type: Object,
+			required: false
+		},
+		renote: {
+			type: Object,
+			required: false
+		},
+		initialText: {
+			type: String,
+			required: false
+		},
+		instant: {
+			type: Boolean,
+			required: false,
+			default: false
+		}
+	},
+
+	mounted() {
+		this.$nextTick(() => {
+			(this.$refs.bg as any).style.pointerEvents = 'auto';
+			anime({
+				targets: this.$refs.bg,
+				opacity: 1,
+				duration: 100,
+				easing: 'linear'
+			});
+
+			anime({
+				targets: this.$refs.main,
+				opacity: 1,
+				translateY: [-16, 0],
+				duration: 300,
+				easing: 'easeOutQuad'
+			});
+		});
+	},
+
+	methods: {
+		focus() {
+			this.$refs.form.focus();
+		},
+
+		close() {
+			(this.$refs.bg as any).style.pointerEvents = 'none';
+			anime({
+				targets: this.$refs.bg,
+				opacity: 0,
+				duration: 300,
+				easing: 'linear'
+			});
+
+			(this.$refs.main as any).style.pointerEvents = 'none';
+			anime({
+				targets: this.$refs.main,
+				opacity: 0,
+				translateY: 16,
+				duration: 300,
+				easing: 'easeOutQuad',
+				complete: () => this.destroyDom()
+			});
+		},
+
+		onPosted() {
+			this.$emit('posted');
+			this.close();
+		},
+
+		onCanceled() {
+			this.$emit('cancel');
+			this.close();
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.ulveipglmagnxfgvitaxyszerjwiqmwl
+	> .bg
+		display block
+		position fixed
+		z-index 10000
+		top 0
+		left 0
+		width 100%
+		height 100%
+		background rgba(#000, 0.7)
+		opacity 0
+		pointer-events none
+
+	> .main
+		display block
+		position fixed
+		z-index 10000
+		top 0
+		left 0
+		right 0
+		height 100%
+		overflow auto
+		margin 0 auto 0 auto
+		opacity 0
+		transform translateY(-16px)
+
+</style>
diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue
index a74df67c0a..3de920cf22 100644
--- a/src/client/app/mobile/views/components/post-form.vue
+++ b/src/client/app/mobile/views/components/post-form.vue
@@ -4,14 +4,14 @@
 		<header>
 			<button class="cancel" @click="cancel">%fa:times%</button>
 			<div>
-				<span class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</span>
+				<span class="text-count" :class="{ over: trimmedLength(text) > 1000 }">{{ 1000 - trimmedLength(text) }}</span>
 				<span class="geo" v-if="geo">%fa:map-marker-alt%</span>
 				<button class="submit" :disabled="!canPost" @click="post">{{ submitText }}</button>
 			</div>
 		</header>
 		<div class="form">
-			<mk-note-preview v-if="reply" :note="reply"/>
-			<mk-note-preview v-if="renote" :note="renote"/>
+			<mk-note-preview class="preview" v-if="reply" :note="reply"/>
+			<mk-note-preview class="preview" v-if="renote" :note="renote"/>
 			<div v-if="visibility == 'specified'" class="visibleUsers">
 				<span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span>
 				<a @click="addVisibleUser">+%i18n:@add-visible-user%</a>
@@ -42,7 +42,7 @@
 					<span v-if="visibility === 'private'">%fa:lock%</span>
 				</button>
 			</footer>
-			<input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/>
+			<input ref="file" class="file" type="file" multiple="multiple" @change="onChangeFile"/>
 		</div>
 	</div>
 	<div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags">
@@ -59,6 +59,9 @@ import MkVisibilityChooser from '../../../common/views/components/visibility-cho
 import getFace from '../../../common/scripts/get-face';
 import parse from '../../../../../mfm/parse';
 import { host } from '../../../config';
+import { erase, unique } from '../../../../../prelude/array';
+import { length } from 'stringz';
+import parseAcct from '../../../../../misc/acct/parse';
 
 export default Vue.extend({
 	components: {
@@ -94,7 +97,7 @@ export default Vue.extend({
 			files: [],
 			poll: false,
 			geo: null,
-			visibility: this.$store.state.device.visibility || 'public',
+			visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility,
 			visibleUsers: [],
 			useCw: false,
 			cw: null,
@@ -105,9 +108,9 @@ export default Vue.extend({
 	computed: {
 		draftId(): string {
 			return this.renote
-				? 'renote:' + this.renote.id
+				? `renote:${this.renote.id}`
 				: this.reply
-					? 'reply:' + this.reply.id
+					? `reply:${this.reply.id}`
 					: 'note';
 		},
 
@@ -170,12 +173,30 @@ export default Vue.extend({
 			});
 		}
 
+		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+		if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) {
+			this.visibility = this.reply.visibility;
+		}
+
+		// ダイレクトへのリプライはリプライ先ユーザーを初期設定
+		if (this.reply && this.reply.visibility === 'specified') {
+			(this as any).api('users/show', {	userId: this.reply.userId }).then(user => {
+				this.visibleUsers.push(user);
+			});
+		}
+
+		this.focus();
+
 		this.$nextTick(() => {
 			this.focus();
 		});
 	},
 
 	methods: {
+		trimmedLength(text: string) {
+			return length(text.trim());
+		},
+
 		addTag(tag: string) {
 			insertTextAtCursor(this.$refs.text, ` #${tag} `);
 		},
@@ -198,12 +219,12 @@ export default Vue.extend({
 
 		attachMedia(driveFile) {
 			this.files.push(driveFile);
-			this.$emit('change-attached-media', this.files);
+			this.$emit('change-attached-files', this.files);
 		},
 
 		detachMedia(file) {
 			this.files = this.files.filter(x => x.id != file.id);
-			this.$emit('change-attached-media', this.files);
+			this.$emit('change-attached-files', this.files);
 		},
 
 		onChangeFile() {
@@ -227,7 +248,7 @@ export default Vue.extend({
 			navigator.geolocation.getCurrentPosition(pos => {
 				this.geo = pos.coords;
 			}, err => {
-				alert('%i18n:@error%: ' + err.message);
+				alert(`%i18n:@error%: ${err.message}`);
 			}, {
 					enableHighAccuracy: true
 				});
@@ -250,24 +271,23 @@ export default Vue.extend({
 		addVisibleUser() {
 			(this as any).apis.input({
 				title: '%i18n:@username-prompt%'
-			}).then(username => {
-				(this as any).api('users/show', {
-					username
-				}).then(user => {
+			}).then(acct => {
+				if (acct.startsWith('@')) acct = acct.substr(1);
+				(this as any).api('users/show', parseAcct(acct)).then(user => {
 					this.visibleUsers.push(user);
 				});
 			});
 		},
 
 		removeVisibleUser(user) {
-			this.visibleUsers = this.visibleUsers.filter(u => u != user);
+			this.visibleUsers = erase(user, this.visibleUsers);
 		},
 
 		clear() {
 			this.text = '';
 			this.files = [];
 			this.poll = false;
-			this.$emit('change-attached-media');
+			this.$emit('change-attached-files');
 		},
 
 		post() {
@@ -275,7 +295,7 @@ export default Vue.extend({
 			const viaMobile = this.$store.state.settings.disableViaMobile !== true;
 			(this as any).api('notes/create', {
 				text: this.text == '' ? undefined : this.text,
-				mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+				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 : undefined,
 				poll: this.poll ? (this.$refs.poll as any).get() : undefined,
@@ -293,9 +313,6 @@ export default Vue.extend({
 				viaMobile: viaMobile
 			}).then(data => {
 				this.$emit('posted');
-				this.$nextTick(() => {
-					this.$destroy();
-				});
 			}).catch(err => {
 				this.posting = false;
 			});
@@ -303,13 +320,12 @@ export default Vue.extend({
 			if (this.text && this.text != '') {
 				const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag);
 				const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
-				localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], [])));
+				localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
 			}
 		},
 
 		cancel() {
 			this.$emit('cancel');
-			this.$destroy();
 		},
 
 		kao() {
@@ -320,9 +336,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.mk-post-form
 	max-width 500px
 	width calc(100% - 16px)
 	margin 8px auto
@@ -338,27 +352,27 @@ root(isDark)
 		margin 32px auto
 
 	> .form
-		background isDark ? #282C37 : #fff
+		background var(--face)
 		border-radius 8px
 		box-shadow 0 0 2px rgba(#000, 0.1)
 
 		> header
 			z-index 1000
 			height 50px
-			box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1)
+			box-shadow 0 1px 0 0 var(--mobilePostFormDivider)
 
 			> .cancel
 				padding 0
 				width 50px
 				line-height 50px
 				font-size 24px
-				color isDark ? #9baec8 : #555
+				color var(--text)
 
 			> div
 				position absolute
 				top 0
 				right 0
-				color #657786
+				color var(--text)
 
 				> .text-count
 					line-height 50px
@@ -372,8 +386,8 @@ root(isDark)
 					padding 0 16px
 					line-height 34px
 					vertical-align bottom
-					color $theme-color-foreground
-					background $theme-color
+					color var(--primaryForeground)
+					background var(--primary)
 					border-radius 4px
 
 					&:disabled
@@ -383,7 +397,7 @@ root(isDark)
 			max-width 500px
 			margin 0 auto
 
-			> .mk-note-preview
+			> .preview
 				padding 16px
 
 			> .visibleUsers
@@ -392,7 +406,7 @@ root(isDark)
 
 				> span
 					margin-right 16px
-					color isDark ? #fff : #666
+					color var(--text)
 
 			> input
 				z-index 1
@@ -404,11 +418,11 @@ root(isDark)
 				margin 0
 				width 100%
 				font-size 16px
-				color isDark ? #fff : #333
-				background isDark ? #191d23 : #fff
+				color var(--inputText)
+				background var(--mobilePostFormTextareaBg)
 				border none
 				border-radius 0
-				box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1)
+				box-shadow 0 1px 0 0 var(--mobilePostFormDivider)
 
 				&:disabled
 					opacity 0.5
@@ -464,7 +478,7 @@ root(isDark)
 					width 48px
 					height 48px
 					font-size 20px
-					color #657786
+					color var(--mobilePostFormButton)
 					background transparent
 					outline none
 					border none
@@ -477,10 +491,4 @@ root(isDark)
 		> *
 			margin-right 8px
 
-.mk-post-form[data-darkmode]
-	root(true)
-
-.mk-post-form:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue
index a4ce49786e..2238edf278 100644
--- a/src/client/app/mobile/views/components/sub-note-content.vue
+++ b/src/client/app/mobile/views/components/sub-note-content.vue
@@ -7,9 +7,9 @@
 		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
 		<a class="rp" v-if="note.renoteId">RP: ...</a>
 	</div>
-	<details v-if="note.media.length > 0">
-		<summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary>
-		<mk-media-list :media-list="note.media"/>
+	<details v-if="note.files.length > 0">
+		<summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary>
+		<mk-media-list :media-list="note.files"/>
 	</details>
 	<details v-if="note.poll">
 		<summary>%i18n:@poll%</summary>
@@ -37,7 +37,7 @@ export default Vue.extend({
 		> .rp
 			margin-left 4px
 			font-style oblique
-			color #a0bf46
+			color var(--renoteText)
 
 	mk-poll
 		font-size 80%
diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue
index a616586c56..9793d03a8c 100644
--- a/src/client/app/mobile/views/components/ui.header.vue
+++ b/src/client/app/mobile/views/components/ui.header.vue
@@ -1,9 +1,9 @@
 <template>
-<div class="header">
+<div class="header" ref="root">
+	<p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p>
 	<mk-special-message/>
 	<div class="main" ref="main">
 		<div class="backdrop"></div>
-		<p ref="welcomeback" v-if="$store.getters.isSignedIn">%i18n:@welcome-back%<b>{{ $store.state.i | userName }}</b>%i18n:@adjective%</p>
 		<div class="content" ref="mainContainer">
 			<button class="nav" @click="$parent.isDrawerOpening = true">%fa:bars%</button>
 			<template v-if="hasUnreadNotification || hasUnreadMessagingMessage || hasGameInvitation">%fa:circle%</template>
@@ -20,93 +20,51 @@
 <script lang="ts">
 import Vue from 'vue';
 import * as anime from 'animejs';
+import { env } from '../../../config';
 
 export default Vue.extend({
 	props: ['func'],
+
 	data() {
 		return {
 			hasGameInvitation: false,
 			connection: null,
-			connectionId: null
+			env: env
 		};
 	},
+
 	computed: {
 		hasUnreadNotification(): boolean {
 			return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification;
 		},
+
 		hasUnreadMessagingMessage(): boolean {
 			return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage;
 		}
 	},
+
 	mounted() {
-		this.$store.commit('setUiHeaderHeight', 48);
+		this.$store.commit('setUiHeaderHeight', this.$refs.root.offsetHeight);
 
 		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
+			this.connection = (this as any).os.stream.useSharedConnection('main');
 
-			this.connection.on('reversi_invited', this.onReversiInvited);
+			this.connection.on('reversiInvited', this.onReversiInvited);
 			this.connection.on('reversi_no_invites', this.onReversiNoInvites);
-
-			const ago = (new Date().getTime() - new Date(this.$store.state.i.lastUsedAt).getTime()) / 1000;
-			const isHisasiburi = ago >= 3600;
-			this.$store.state.i.lastUsedAt = new Date();
-
-			if (isHisasiburi) {
-				(this.$refs.welcomeback as any).style.display = 'block';
-				(this.$refs.main as any).style.overflow = 'hidden';
-
-				anime({
-					targets: this.$refs.welcomeback,
-					top: '0',
-					opacity: 1,
-					delay: 1000,
-					duration: 500,
-					easing: 'easeOutQuad'
-				});
-
-				anime({
-					targets: this.$refs.mainContainer,
-					opacity: 0,
-					delay: 1000,
-					duration: 500,
-					easing: 'easeOutQuad'
-				});
-
-				setTimeout(() => {
-					anime({
-						targets: this.$refs.welcomeback,
-						top: '-48px',
-						opacity: 0,
-						duration: 500,
-						complete: () => {
-							(this.$refs.welcomeback as any).style.display = 'none';
-							(this.$refs.main as any).style.overflow = 'initial';
-						},
-						easing: 'easeInQuad'
-					});
-
-					anime({
-						targets: this.$refs.mainContainer,
-						opacity: 1,
-						duration: 500,
-						easing: 'easeInQuad'
-					});
-				}, 2500);
-			}
 		}
 	},
+
 	beforeDestroy() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection.off('reversi_invited', this.onReversiInvited);
-			this.connection.off('reversi_no_invites', this.onReversiNoInvites);
-			(this as any).os.stream.dispose(this.connectionId);
+			this.connection.dispose();
 		}
 	},
+
 	methods: {
 		onReversiInvited() {
 			this.hasGameInvitation = true;
 		},
+
 		onReversiNoInvites() {
 			this.hasGameInvitation = false;
 		}
@@ -115,9 +73,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+.header
 	$height = 48px
 
 	position fixed
@@ -131,10 +87,19 @@ root(isDark)
 
 	> .indicator
 		height 3px
-		background $theme-color
+		background var(--primary)
+
+	> .warn
+		display block
+		margin 0
+		padding 4px
+		text-align center
+		font-size 12px
+		background #f00
+		color #fff
 
 	> .main
-		color rgba(#fff, 0.9)
+		color var(--mobileHeaderFg)
 
 		> .backdrop
 			position absolute
@@ -144,20 +109,7 @@ root(isDark)
 			height $height
 			-webkit-backdrop-filter blur(12px)
 			backdrop-filter blur(12px)
-			//background-color rgba(#1b2023, 0.75)
-			background-color isDark ? #313543 : #595f6f
-
-		> p
-			display none
-			position absolute
-			z-index 1002
-			top $height
-			width 100%
-			line-height $height
-			margin 0
-			text-align center
-			color #fff
-			opacity 0
+			background-color var(--mobileHeaderBg)
 
 		> .content
 			z-index 1001
@@ -176,9 +128,6 @@ root(isDark)
 				overflow hidden
 				text-overflow ellipsis
 
-				[data-fa], [data-icon]
-					margin-right 4px
-
 				> img
 					display inline-block
 					vertical-align bottom
@@ -207,7 +156,7 @@ root(isDark)
 				left 8px
 				pointer-events none
 				font-size 10px
-				color $theme-color
+				color var(--primary)
 
 			> button:last-child
 				display block
@@ -222,10 +171,4 @@ root(isDark)
 				line-height $height
 				border-left solid 1px rgba(#000, 0.1)
 
-.header[data-darkmode]
-	root(true)
-
-.header:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue
index 39ea513b76..c9c0c082b2 100644
--- a/src/client/app/mobile/views/components/ui.nav.vue
+++ b/src/client/app/mobile/views/components/ui.nav.vue
@@ -34,6 +34,12 @@
 					<li @click="dark"><p><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template><span>%i18n:@darkmode%</span></p></li>
 				</ul>
 			</div>
+			<div class="announcements" v-if="announcements && announcements.length > 0">
+				<article v-for="announcement in announcements">
+					<span v-html="announcement.title" class="title"></span>
+					<div v-html="announcement.text"></div>
+				</article>
+			</div>
 			<a :href="aboutUrl"><p class="about">%i18n:@about%</p></a>
 		</div>
 	</transition>
@@ -46,50 +52,60 @@ import { lang } from '../../../config';
 
 export default Vue.extend({
 	props: ['isOpen'],
+
 	data() {
 		return {
 			hasGameInvitation: false,
 			connection: null,
-			connectionId: null,
-			aboutUrl: `/docs/${lang}/about`
+			aboutUrl: `/docs/${lang}/about`,
+			announcements: []
 		};
 	},
+
 	computed: {
 		hasUnreadNotification(): boolean {
 			return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification;
 		},
+
 		hasUnreadMessagingMessage(): boolean {
 			return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage;
 		}
 	},
-	mounted() {
-		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
 
-			this.connection.on('reversi_invited', this.onReversiInvited);
+	mounted() {
+		(this as any).os.getMeta().then(meta => {
+			this.announcements = meta.broadcasts;
+		});
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection = (this as any).os.stream.useSharedConnection('main');
+
+			this.connection.on('reversiInvited', this.onReversiInvited);
 			this.connection.on('reversi_no_invites', this.onReversiNoInvites);
 		}
 	},
+
 	beforeDestroy() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection.off('reversi_invited', this.onReversiInvited);
-			this.connection.off('reversi_no_invites', this.onReversiNoInvites);
-			(this as any).os.stream.dispose(this.connectionId);
+			this.connection.dispose();
 		}
 	},
+
 	methods: {
 		search() {
 			const query = window.prompt('%i18n:@search%');
 			if (query == null || query == '') return;
-			this.$router.push('/search?q=' + encodeURIComponent(query));
+			this.$router.push(`/search?q=${encodeURIComponent(query)}`);
 		},
+
 		onReversiInvited() {
 			this.hasGameInvitation = true;
 		},
+
 		onReversiNoInvites() {
 			this.hasGameInvitation = false;
 		},
+
 		dark() {
 			this.$store.commit('device/set', {
 				key: 'darkmode',
@@ -101,10 +117,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	$color = isDark ? #c9d2e0 : #777
+.nav
+	$color = var(--text)
 
 	.backdrop
 		position fixed
@@ -113,7 +127,7 @@ root(isDark)
 		z-index 1025
 		width 100%
 		height 100%
-		background isDark ? rgba(#000, 0.7) : rgba(#000, 0.2)
+		background var(--mobileNavBackdrop)
 
 	.body
 		position fixed
@@ -124,7 +138,7 @@ root(isDark)
 		height 100%
 		overflow auto
 		-webkit-overflow-scrolling touch
-		background isDark ? #16191f : #fff
+		background var(--secondary)
 
 	.me
 		display block
@@ -178,11 +192,11 @@ root(isDark)
 				text-decoration none
 
 				&[data-active]
-					color $theme-color-foreground
-					background $theme-color
+					color var(--primaryForeground)
+					background var(--primary)
 
 					> [data-fa]:last-child
-						color $theme-color-foreground
+						color var(--primaryForeground)
 
 				> [data-fa]:first-child
 					margin-right 0.5em
@@ -192,7 +206,7 @@ root(isDark)
 				> [data-fa].circle
 					margin-left 6px
 					font-size 10px
-					color $theme-color
+					color var(--primary)
 
 				> [data-fa]:last-child
 					position absolute
@@ -204,6 +218,17 @@ root(isDark)
 					color $color
 					opacity 0.5
 
+	.announcements
+		> article
+			background var(--mobileAnnouncement)
+			color var(--mobileAnnouncementFg)
+			padding 16px
+			margin 8px 0
+			font-size 12px
+
+			> .title
+				font-weight bold
+
 	.about
 		margin 0 0 8px 0
 		padding 1em 0
@@ -234,10 +259,4 @@ root(isDark)
 	opacity: 0;
 }
 
-.nav[data-darkmode]
-	root(true)
-
-.nav:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue
index 7e2d39f259..b16c246b10 100644
--- a/src/client/app/mobile/views/components/ui.vue
+++ b/src/client/app/mobile/views/components/ui.vue
@@ -23,33 +23,43 @@ export default Vue.extend({
 		XHeader,
 		XNav
 	},
+
 	props: ['title'],
+
 	data() {
 		return {
 			isDrawerOpening: false,
-			connection: null,
-			connectionId: null
+			connection: null
 		};
 	},
+
+	watch: {
+		'$store.state.uiHeaderHeight'() {
+			this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px';
+		}
+	},
+
 	mounted() {
+		this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px';
+
 		if (this.$store.getters.isSignedIn) {
-			this.connection = (this as any).os.stream.getConnection();
-			this.connectionId = (this as any).os.stream.use();
+			this.connection = (this as any).os.stream.useSharedConnection('main');
 
 			this.connection.on('notification', this.onNotification);
 		}
 	},
+
 	beforeDestroy() {
 		if (this.$store.getters.isSignedIn) {
-			this.connection.off('notification', this.onNotification);
-			(this as any).os.stream.dispose(this.connectionId);
+			this.connection.dispose();
 		}
 	},
+
 	methods: {
 		onNotification(notification) {
 			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
 			this.connection.send({
-				type: 'read_notification',
+				type: 'readNotification',
 				id: notification.id
 			});
 
diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue
index 9b3f11f5c2..97200eb5b3 100644
--- a/src/client/app/mobile/views/components/user-list-timeline.vue
+++ b/src/client/app/mobile/views/components/user-list-timeline.vue
@@ -6,7 +6,6 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { UserListStream } from '../../../common/scripts/streaming/user-list';
 
 const fetchLimit = 10;
 
diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue
index 6be675c0a7..7cd23d6655 100644
--- a/src/client/app/mobile/views/components/user-timeline.vue
+++ b/src/client/app/mobile/views/components/user-timeline.vue
@@ -41,7 +41,7 @@ export default Vue.extend({
 			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
 				(this as any).api('users/notes', {
 					userId: this.user.id,
-					withMedia: this.withMedia,
+					withFiles: this.withMedia,
 					limit: fetchLimit + 1
 				}).then(notes => {
 					if (notes.length == fetchLimit + 1) {
@@ -62,7 +62,7 @@ export default Vue.extend({
 
 			const promise = (this as any).api('users/notes', {
 				userId: this.user.id,
-				withMedia: this.withMedia,
+				withFiles: this.withMedia,
 				limit: fetchLimit + 1,
 				untilId: (this.$refs.timeline as any).tail().id
 			});
diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue
index a57b821293..f06f5245b8 100644
--- a/src/client/app/mobile/views/components/users-list.vue
+++ b/src/client/app/mobile/views/components/users-list.vue
@@ -65,7 +65,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 .mk-users-list
 
@@ -87,8 +87,8 @@ export default Vue.extend({
 
 			&[data-active]
 				font-weight bold
-				color $theme-color
-				border-color $theme-color
+				color var(--primary)
+				border-color var(--primary)
 
 			> span
 				display inline-block
diff --git a/src/client/app/mobile/views/components/widget-container.vue b/src/client/app/mobile/views/components/widget-container.vue
index a713a10621..2a4025002b 100644
--- a/src/client/app/mobile/views/components/widget-container.vue
+++ b/src/client/app/mobile/views/components/widget-container.vue
@@ -25,8 +25,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
-	background isDark ? #21242f : #eee
+.mk-widget-container
+	background var(--face)
 	border-radius 8px
 	box-shadow 0 4px 16px rgba(#000, 0.1)
 	overflow hidden
@@ -35,17 +35,14 @@ root(isDark)
 		background transparent !important
 		box-shadow none !important
 
-	&.hideHeader
-		background isDark ? #21242f : #fff
-
 	> header
 		> .title
 			margin 0
 			padding 8px 10px
 			font-size 15px
 			font-weight normal
-			color isDark ? #b8c5cc : #465258
-			background isDark ? #282c37 : #fff
+			color var(--faceHeaderText)
+			background var(--faceHeader)
 			border-radius 8px 8px 0 0
 
 			> [data-fa]
@@ -65,10 +62,4 @@ root(isDark)
 			font-size 15px
 			color #465258
 
-.mk-widget-container[data-darkmode]
-	root(true)
-
-.mk-widget-container:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue
index c7cbe0f72e..bf02adca9d 100644
--- a/src/client/app/mobile/views/pages/drive.vue
+++ b/src/client/app/mobile/views/pages/drive.vue
@@ -1,9 +1,9 @@
 <template>
 <mk-ui>
 	<span slot="header">
-		<template v-if="folder">%fa:R folder-open%{{ folder.name }}</template>
-		<template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template>
-		<template v-if="!folder && !file">%fa:cloud%%i18n:@drive%</template>
+		<template v-if="folder"><span style="margin-right:4px;">%fa:R folder-open%</span>{{ folder.name }}</template>
+		<template v-if="file"><mk-file-type-icon data-icon :type="file.type" style="margin-right:4px;"/>{{ file.name }}</template>
+		<template v-if="!folder && !file"><span style="margin-right:4px;">%fa:cloud%</span>%i18n:@drive%</template>
 	</span>
 	<template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template>
 	<mk-drive
@@ -11,7 +11,7 @@
 		:init-folder="initFolder"
 		:init-file="initFile"
 		:is-naked="true"
-		:top="48"
+		:top="$store.state.uiHeaderHeight"
 		@begin-fetch="Progress.start()"
 		@fetched-mid="Progress.set(0.5)"
 		@fetched="Progress.done()"
@@ -44,7 +44,6 @@ export default Vue.extend({
 	},
 	mounted() {
 		document.title = `${(this as any).os.instanceName} Drive`;
-		document.documentElement.style.background = '#fff';
 	},
 	beforeDestroy() {
 		window.removeEventListener('popstate', this.onPopState);
@@ -80,7 +79,7 @@ export default Vue.extend({
 
 			if (!silent) {
 				// Rewrite URL
-				history.pushState(null, title, '/i/drive/folder/' + folder.id);
+				history.pushState(null, title, `/i/drive/folder/${folder.id}`);
 			}
 
 			document.title = title;
@@ -93,7 +92,7 @@ export default Vue.extend({
 
 			if (!silent) {
 				// Rewrite URL
-				history.pushState(null, title, '/i/drive/file/' + file.id);
+				history.pushState(null, title, `/i/drive/file/${file.id}`);
 			}
 
 			document.title = title;
diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue
index 6b9aec6a0c..a25f70147b 100644
--- a/src/client/app/mobile/views/pages/favorites.vue
+++ b/src/client/app/mobile/views/pages/favorites.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:star%%i18n:@title%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:star%</span>%i18n:@title%</span>
 
 	<main>
 		<template v-for="favorite in favorites">
@@ -71,7 +71,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 main
 	width 100%
diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue
index 421c150856..601f6670c1 100644
--- a/src/client/app/mobile/views/pages/followers.vue
+++ b/src/client/app/mobile/views/pages/followers.vue
@@ -49,7 +49,7 @@ export default Vue.extend({
 				this.user = user;
 				this.fetching = false;
 
-				document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName;
+				document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`;
 			});
 		},
 		onLoaded() {
diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue
index ff201ff2bd..0efac6110e 100644
--- a/src/client/app/mobile/views/pages/following.vue
+++ b/src/client/app/mobile/views/pages/following.vue
@@ -48,7 +48,7 @@ export default Vue.extend({
 				this.user = user;
 				this.fetching = false;
 
-				document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName;
+				document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`;
 			});
 		},
 		onLoaded() {
diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue
index d6849a1c11..7f8f919005 100644
--- a/src/client/app/mobile/views/pages/games/reversi.vue
+++ b/src/client/app/mobile/views/pages/games/reversi.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:gamepad%%i18n:@reversi%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:gamepad%</span>%i18n:@reversi%</span>
 	<mk-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/>
 </mk-ui>
 </template>
@@ -11,15 +11,14 @@ import Vue from 'vue';
 export default Vue.extend({
 	mounted() {
 		document.title = `${(this as any).os.instanceName} %i18n:@reversi%`;
-		document.documentElement.style.background = '#fff';
 	},
 	methods: {
 		nav(game, actualNav) {
 			if (actualNav) {
-				this.$router.push('/reversi/' + game.id);
+				this.$router.push(`/reversi/${game.id}`);
 			} else {
 				// TODO: https://github.com/vuejs/vue-router/issues/703
-				this.$router.push('/reversi/' + game.id);
+				this.$router.push(`/reversi/${game.id}`);
 			}
 		}
 	}
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
index 416b006cd8..1979747bf7 100644
--- a/src/client/app/mobile/views/pages/home.timeline.vue
+++ b/src/client/app/mobile/views/pages/home.timeline.vue
@@ -21,6 +21,9 @@ export default Vue.extend({
 		src: {
 			type: String,
 			required: true
+		},
+		tagTl: {
+			required: false
 		}
 	},
 
@@ -29,10 +32,17 @@ export default Vue.extend({
 			fetching: true,
 			moreFetching: false,
 			existMore: false,
+			streamManager: null,
 			connection: null,
-			connectionId: null,
 			unreadCount: 0,
-			date: null
+			date: null,
+			baseQuery: {
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			},
+			query: {},
+			endpoint: null
 		};
 	},
 
@@ -41,49 +51,67 @@ export default Vue.extend({
 			return this.$store.state.i.followingCount == 0;
 		},
 
-		stream(): any {
-			switch (this.src) {
-				case 'home': return (this as any).os.stream;
-				case 'local': return (this as any).os.streams.localTimelineStream;
-				case 'hybrid': return (this as any).os.streams.hybridTimelineStream;
-				case 'global': return (this as any).os.streams.globalTimelineStream;
-			}
-		},
-
-		endpoint(): string {
-			switch (this.src) {
-				case 'home': return 'notes/timeline';
-				case 'local': return 'notes/local-timeline';
-				case 'hybrid': return 'notes/hybrid-timeline';
-				case 'global': return 'notes/global-timeline';
-			}
-		},
-
 		canFetchMore(): boolean {
 			return !this.moreFetching && !this.fetching && this.existMore;
 		}
 	},
 
 	mounted() {
-		this.connection = this.stream.getConnection();
-		this.connectionId = this.stream.use();
+		const prepend = note => {
+			(this.$refs.timeline as any).prepend(note);
+		};
 
-		this.connection.on('note', this.onNote);
-		if (this.src == 'home') {
-			this.connection.on('follow', this.onChangeFollowing);
-			this.connection.on('unfollow', this.onChangeFollowing);
+		if (this.src == 'tag') {
+			this.endpoint = 'notes/search_by_tag';
+			this.query = {
+				query: this.tagTl.query
+			};
+			this.connection = (this as any).os.stream.connectToChannel('hashtag', { q: this.tagTl.query });
+			this.connection.on('note', prepend);
+		} else if (this.src == 'home') {
+			this.endpoint = 'notes/timeline';
+			const onChangeFollowing = () => {
+				this.fetch();
+			};
+			this.connection = (this as any).os.stream.useSharedConnection('homeTimeline');
+			this.connection.on('note', prepend);
+			this.connection.on('follow', onChangeFollowing);
+			this.connection.on('unfollow', onChangeFollowing);
+		} else if (this.src == 'local') {
+			this.endpoint = 'notes/local-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('localTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'hybrid') {
+			this.endpoint = 'notes/hybrid-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('hybridTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'global') {
+			this.endpoint = 'notes/global-timeline';
+			this.connection = (this as any).os.stream.useSharedConnection('globalTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'mentions') {
+			this.endpoint = 'notes/mentions';
+			this.connection = (this as any).os.stream.useSharedConnection('main');
+			this.connection.on('mention', prepend);
+		} else if (this.src == 'messages') {
+			this.endpoint = 'notes/mentions';
+			this.query = {
+				visibility: 'specified'
+			};
+			const onNote = note => {
+				if (note.visibility == 'specified') {
+					prepend(note);
+				}
+			};
+			this.connection = (this as any).os.stream.useSharedConnection('main');
+			this.connection.on('mention', onNote);
 		}
 
 		this.fetch();
 	},
 
 	beforeDestroy() {
-		this.connection.off('note', this.onNote);
-		if (this.src == 'home') {
-			this.connection.off('follow', this.onChangeFollowing);
-			this.connection.off('unfollow', this.onChangeFollowing);
-		}
-		this.stream.dispose(this.connectionId);
+		this.connection.dispose();
 	},
 
 	methods: {
@@ -91,13 +119,10 @@ export default Vue.extend({
 			this.fetching = true;
 
 			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				(this as any).api(this.endpoint, {
+				(this as any).api(this.endpoint, Object.assign({
 					limit: fetchLimit + 1,
-					untilDate: this.date ? this.date.getTime() : undefined,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).then(notes => {
+					untilDate: this.date ? this.date.getTime() : undefined
+				}, this.baseQuery, this.query)).then(notes => {
 					if (notes.length == fetchLimit + 1) {
 						notes.pop();
 						this.existMore = true;
@@ -114,13 +139,10 @@ export default Vue.extend({
 
 			this.moreFetching = true;
 
-			const promise = (this as any).api(this.endpoint, {
+			const promise = (this as any).api(this.endpoint, Object.assign({
 				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
+				untilId: (this.$refs.timeline as any).tail().id
+			}, this.baseQuery, this.query));
 
 			promise.then(notes => {
 				if (notes.length == fetchLimit + 1) {
@@ -135,15 +157,6 @@ export default Vue.extend({
 			return promise;
 		},
 
-		onNote(note) {
-			// Prepend a note
-			(this.$refs.timeline as any).prepend(note);
-		},
-
-		onChangeFollowing() {
-			this.fetch();
-		},
-
 		focus() {
 			(this.$refs.timeline as any).focus();
 		},
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index 706c9cd28b..edba8585bd 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -1,35 +1,46 @@
 <template>
 <mk-ui>
 	<span slot="header" @click="showNav = true">
-		<span>
+		<span :class="$style.title">
 			<span v-if="src == 'home'">%fa:home%%i18n:@home%</span>
 			<span v-if="src == 'local'">%fa:R comments%%i18n:@local%</span>
 			<span v-if="src == 'hybrid'">%fa:share-alt%%i18n:@hybrid%</span>
 			<span v-if="src == 'global'">%fa:globe%%i18n:@global%</span>
+			<span v-if="src == 'mentions'">%fa:at%%i18n:@mentions%</span>
+			<span v-if="src == 'messages'">%fa:envelope R%%i18n:@messages%</span>
 			<span v-if="src == 'list'">%fa:list%{{ list.title }}</span>
+			<span v-if="src == 'tag'">%fa:hashtag%{{ tagTl.title }}</span>
 		</span>
 		<span style="margin-left:8px">
 			<template v-if="!showNav">%fa:angle-down%</template>
 			<template v-else>%fa:angle-up%</template>
 		</span>
+		<i :class="$style.badge" v-if="$store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i>
 	</span>
 
 	<template slot="func">
 		<button @click="fn">%fa:pencil-alt%</button>
 	</template>
 
-	<main :data-darkmode="$store.state.device.darkmode">
+	<main>
 		<div class="nav" v-if="showNav">
 			<div class="bg" @click="showNav = false"></div>
+			<div class="pointer"></div>
 			<div class="body">
 				<div>
 					<span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span>
-					<span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span>
-					<span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span>
+					<span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span>
+					<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span>
 					<span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span>
+					<div class="hr"></div>
+					<span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></span>
+					<span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></span>
 					<template v-if="lists">
+						<div class="hr" v-if="lists.length > 0"></div>
 						<span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id">%fa:list% {{ l.title }}</span>
 					</template>
+					<div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div>
+					<span v-for="tl in $store.state.settings.tagTimelines" :data-active="src == 'tag' && tagTl == tl" @click="src = 'tag'; tagTl = tl" :key="tl.id">%fa:hashtag% {{ tl.title }}</span>
 				</div>
 			</div>
 		</div>
@@ -39,6 +50,9 @@
 			<x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/>
 			<x-tl v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
 			<x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/>
+			<x-tl v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
+			<x-tl v-if="src == 'messages'" ref="tl" key="messages" src="messages"/>
+			<x-tl v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
 			<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
 		</div>
 	</main>
@@ -60,7 +74,9 @@ export default Vue.extend({
 			src: 'home',
 			list: null,
 			lists: null,
-			showNav: false
+			tagTl: null,
+			showNav: false,
+			enableLocalTimeline: false
 		};
 	},
 
@@ -70,9 +86,16 @@ export default Vue.extend({
 			this.saveSrc();
 		},
 
-		list() {
+		list(x) {
 			this.showNav = false;
 			this.saveSrc();
+			if (x != null) this.tagTl = null;
+		},
+
+		tagTl(x) {
+			this.showNav = false;
+			this.saveSrc();
+			if (x != null) this.list = null;
 		},
 
 		showNav(v) {
@@ -85,10 +108,16 @@ export default Vue.extend({
 	},
 
 	created() {
+		(this as any).os.getMeta().then(meta => {
+			this.enableLocalTimeline = !meta.disableLocalTimeline;
+		});
+
 		if (this.$store.state.device.tl) {
 			this.src = this.$store.state.device.tl.src;
 			if (this.src == 'list') {
 				this.list = this.$store.state.device.tl.arg;
+			} else if (this.src == 'tag') {
+				this.tagTl = this.$store.state.device.tl.arg;
 			}
 		} else if (this.$store.state.i.followingCount == 0) {
 			this.src = 'hybrid';
@@ -113,7 +142,7 @@ export default Vue.extend({
 		saveSrc() {
 			this.$store.commit('device/setTl', {
 				src: this.src,
-				arg: this.list
+				arg: this.src == 'list' ? this.list : this.tagTl
 			});
 		},
 
@@ -125,10 +154,28 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
+main
 	> .nav
+		> .pointer
+			position fixed
+			z-index 10002
+			top 56px
+			left 0
+			right 0
+
+			$size = 16px
+
+			&:after
+				content ""
+				display block
+				position absolute
+				top -($size * 2)
+				left s('calc(50% - %s)', $size)
+				border-top solid $size transparent
+				border-left solid $size transparent
+				border-right solid $size transparent
+				border-bottom solid $size var(--popupBg)
+
 		> .bg
 			position fixed
 			z-index 10000
@@ -145,38 +192,37 @@ root(isDark)
 			left 0
 			right 0
 			width 300px
+			max-height calc(100% - 70px)
 			margin 0 auto
-			background isDark ? #272f3a : #fff
+			overflow auto
+			-webkit-overflow-scrolling touch
+			background var(--popupBg)
 			border-radius 8px
 			box-shadow 0 0 16px rgba(#000, 0.1)
 
-			$balloon-size = 16px
-
-			&:after
-				content ""
-				display block
-				position absolute
-				top -($balloon-size * 2) + 1.5px
-				left s('calc(50% - %s)', $balloon-size)
-				border-top solid $balloon-size transparent
-				border-left solid $balloon-size transparent
-				border-right solid $balloon-size transparent
-				border-bottom solid $balloon-size isDark ? #272f3a : #fff
-
 			> div
 				padding 8px 0
 
-				> *
+				> .hr
+					margin 8px 0
+					border-top solid 1px var(--faceDivider)
+
+				> *:not(.hr)
 					display block
 					padding 8px 16px
-					color isDark ? #cdd0d8 : #666
+					color var(--text)
 
 					&[data-active]
-						color $theme-color-foreground
-						background $theme-color
+						color var(--primaryForeground)
+						background var(--primary)
 
 					&:not([data-active]):hover
-						background isDark ? #353e4a : #eee
+						background var(--mobileHomeTlItemHover)
+
+					> .badge
+						margin-left 6px
+						font-size 10px
+						color var(--primary)
 
 	> .tl
 		max-width 680px
@@ -189,10 +235,17 @@ root(isDark)
 		@media (min-width 600px)
 			padding 32px
 
-main[data-darkmode]
-	root(true)
+</style>
 
-main:not([data-darkmode])
-	root(false)
+<style lang="stylus" module>
+.title
+	i
+		margin-right 4px
+
+.badge
+	margin-left 6px
+	font-size 10px
+	color var(--primary)
+	vertical-align middle
 
 </style>
diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue
index 401397d856..750ba26294 100644
--- a/src/client/app/mobile/views/pages/messaging-room.vue
+++ b/src/client/app/mobile/views/pages/messaging-room.vue
@@ -1,7 +1,7 @@
 <template>
 <mk-ui>
 	<span slot="header">
-		<template v-if="user">%fa:R comments%{{ user | userName }}</template>
+		<template v-if="user"><span style="margin-right:4px;">%fa:R comments%</span>{{ user | userName }}</template>
 		<template v-else><mk-ellipsis/></template>
 	</span>
 	<mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/>
diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue
index 3883505281..98ae79fe6c 100644
--- a/src/client/app/mobile/views/pages/messaging.vue
+++ b/src/client/app/mobile/views/pages/messaging.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:R comments%%i18n:@messaging%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:R comments%</span>%i18n:@messaging%</span>
 	<mk-messaging @navigate="navigate" :header-top="48"/>
 </mk-ui>
 </template>
diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue
index fee60b350e..d7307c79a8 100644
--- a/src/client/app/mobile/views/pages/note.vue
+++ b/src/client/app/mobile/views/pages/note.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:R sticky-note%%i18n:@title%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:R sticky-note%</span>%i18n:@title%</span>
 	<main v-if="!fetching">
 		<div>
 			<mk-note-detail :note="note"/>
diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue
index 4d3c8ee534..ce33332faf 100644
--- a/src/client/app/mobile/views/pages/notifications.vue
+++ b/src/client/app/mobile/views/pages/notifications.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:R bell%%i18n:@notifications%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:R bell%</span>%i18n:@notifications%</span>
 	<template slot="func"><button @click="fn">%fa:check%</button></template>
 
 	<main>
@@ -34,7 +34,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 main
 	width 100%
diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/mobile/views/pages/received-follow-requests.vue
index 77938c3d60..beaf6bba57 100644
--- a/src/client/app/mobile/views/pages/received-follow-requests.vue
+++ b/src/client/app/mobile/views/pages/received-follow-requests.vue
@@ -52,8 +52,6 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
 main
 	width 100%
 	max-width 680px
@@ -69,7 +67,7 @@ main
 	> div
 		display flex
 		padding 16px
-		border solid 1px isDark ? #1c2023 : #eee
+		border solid 1px var(--faceDivider)
 		border-radius 4px
 
 		> span
diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue
index 1a162b346c..c098b8c65e 100644
--- a/src/client/app/mobile/views/pages/selectdrive.vue
+++ b/src/client/app/mobile/views/pages/selectdrive.vue
@@ -5,7 +5,7 @@
 		<button class="upload" @click="upload">%fa:upload%</button>
 		<button v-if="multiple" class="ok" @click="ok">%fa:check%</button>
 	</header>
-	<mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/>
+	<mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="$store.state.uiHeaderHeight"/>
 </div>
 </template>
 
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index 7437eb8b47..94fa38cec9 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -1,8 +1,8 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:cog%%i18n:@settings%</span>
-	<main :data-darkmode="$store.state.device.darkmode">
-		<div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + name + '</b>')"></div>
+	<span slot="header"><span style="margin-right:4px;">%fa:cog%</span>%i18n:@settings%</span>
+	<main>
+		<div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div>
 
 		<div>
 			<x-profile/>
@@ -10,80 +10,127 @@
 			<ui-card>
 				<div slot="title">%fa:palette% %i18n:@design%</div>
 
-				<ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch>
-				<ui-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons">%i18n:@circle-icons%</ui-switch>
-				<ui-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi">%i18n:common.i-like-sushi%</ui-switch>
-				<ui-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch>
-				<ui-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch>
-				<ui-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones">%i18n:common.use-contrast-reversi-stones%</ui-switch>
+				<section>
+					<ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch>
+					<ui-switch v-model="circleIcons">%i18n:@circle-icons%</ui-switch>
+					<ui-switch v-model="reduceMotion">%i18n:common.reduce-motion% (%i18n:common.this-setting-is-this-device-only%)</ui-switch>
+					<ui-switch v-model="contrastedAcct">%i18n:@contrasted-acct%</ui-switch>
+					<ui-switch v-model="showFullAcct">%i18n:common.show-full-acct%</ui-switch>
+					<ui-switch v-model="iLikeSushi">%i18n:common.i-like-sushi%</ui-switch>
+					<ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch>
+					<ui-switch v-model="alwaysShowNsfw">%i18n:common.always-show-nsfw% (%i18n:common.this-setting-is-this-device-only%)</ui-switch>
+					<ui-switch v-model="games_reversi_showBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch>
+					<ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch>
+				</section>
 
-				<div>
-					<div>%i18n:@timeline%</div>
-					<ui-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget">%i18n:@show-reply-target%</ui-switch>
-					<ui-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes">%i18n:@show-my-renotes%</ui-switch>
-					<ui-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch>
-					<ui-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes">%i18n:@show-local-renotes%</ui-switch>
-				</div>
+				<section>
+					<header>%i18n:@theme%</header>
+					<div>
+						<mk-theme/>
+					</div>
+				</section>
 
-				<div>
-					<div>%i18n:@post-style%</div>
+				<section>
+					<header>%i18n:@timeline%</header>
+					<div>
+						<ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch>
+						<ui-switch v-model="showMyRenotes">%i18n:@show-my-renotes%</ui-switch>
+						<ui-switch v-model="showRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch>
+						<ui-switch v-model="showLocalRenotes">%i18n:@show-local-renotes%</ui-switch>
+					</div>
+				</section>
+
+				<section>
+					<header>%i18n:@post-style%</header>
 					<ui-radio v-model="postStyle" value="standard">%i18n:@post-style-standard%</ui-radio>
 					<ui-radio v-model="postStyle" value="smart">%i18n:@post-style-smart%</ui-radio>
-				</div>
+				</section>
+
+				<section>
+					<header>%i18n:@notification-position%</header>
+					<ui-radio v-model="mobileNotificationPosition" value="bottom">%i18n:@notification-position-bottom%</ui-radio>
+					<ui-radio v-model="mobileNotificationPosition" value="top">%i18n:@notification-position-top%</ui-radio>
+				</section>
 			</ui-card>
 
 			<ui-card>
 				<div slot="title">%fa:cog% %i18n:@behavior%</div>
-				<ui-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch>
-				<ui-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile">%i18n:@disable-via-mobile%</ui-switch>
-				<ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch>
-				<ui-switch v-model="$store.state.settings.loadRemoteMedia" @change="onChangeLoadRemoteMedia">%i18n:@load-remote-media%</ui-switch>
-				<ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch>
+
+				<section>
+					<ui-switch v-model="fetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch>
+					<ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch>
+					<ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch>
+					<ui-switch v-model="loadRemoteMedia">%i18n:@load-remote-media%</ui-switch>
+					<ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch>
+				</section>
+
+				<section>
+					<header>%i18n:@note-visibility%</header>
+					<ui-switch v-model="rememberNoteVisibility">%i18n:@remember-note-visibility%</ui-switch>
+					<section>
+						<header>%i18n:@default-note-visibility%</header>
+						<ui-select v-model="defaultNoteVisibility">
+							<option value="public">%i18n:common.note-visibility.public%</option>
+							<option value="home">%i18n:common.note-visibility.home%</option>
+							<option value="followers">%i18n:common.note-visibility.followers%</option>
+							<option value="specified">%i18n:common.note-visibility.specified%</option>
+							<option value="private">%i18n:common.note-visibility.private%</option>
+						</ui-select>
+					</section>
+				</section>
 			</ui-card>
 
 			<ui-card>
 				<div slot="title">%fa:volume-up% %i18n:@sound%</div>
 
-				<ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch>
+				<section>
+					<ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch>
+				</section>
 			</ui-card>
 
 			<ui-card>
 				<div slot="title">%fa:language% %i18n:@lang%</div>
 
-				<ui-select v-model="lang" placeholder="%i18n:@auto%">
-					<optgroup label="%i18n:@recommended%">
-						<option value="">%i18n:@auto%</option>
-					</optgroup>
+				<section class="fit-top">
+					<ui-select v-model="lang" placeholder="%i18n:@auto%">
+						<optgroup label="%i18n:@recommended%">
+							<option value="">%i18n:@auto%</option>
+						</optgroup>
 
-					<optgroup label="%i18n:@specify-language%">
-						<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
-					</optgroup>
-				</ui-select>
-				<span>%fa:info-circle% %i18n:@lang-tip%</span>
+						<optgroup label="%i18n:@specify-language%">
+							<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+						</optgroup>
+					</ui-select>
+					<span>%fa:info-circle% %i18n:@lang-tip%</span>
+				</section>
 			</ui-card>
 
 			<ui-card>
 				<div slot="title">%fa:B twitter% %i18n:@twitter%</div>
 
-				<p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
-				<p>
-					<a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a>
-					<span v-if="$store.state.i.twitter"> or </span>
-					<a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a>
-				</p>
+				<section>
+					<p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
+					<p>
+						<a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a>
+						<span v-if="$store.state.i.twitter"> or </span>
+						<a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a>
+					</p>
+				</section>
 			</ui-card>
 
 			<ui-card>
 				<div slot="title">%fa:sync-alt% %i18n:@update%</div>
 
-				<div>%i18n:@version% <i>{{ version }}</i></div>
-				<template v-if="latestVersion !== undefined">
-					<div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div>
-				</template>
-				<ui-button @click="checkForUpdate" :disabled="checkingForUpdate">
-					<template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template>
-					<template v-else>%i18n:@check-for-updates%</template>
-				</ui-button>
+				<section>
+					<div>%i18n:@version% <i>{{ version }}</i></div>
+					<template v-if="latestVersion !== undefined">
+						<div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div>
+					</template>
+					<ui-button @click="checkForUpdate" :disabled="checkingForUpdate">
+						<template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template>
+						<template v-else>%i18n:@check-for-updates%</template>
+					</ui-button>
+				</section>
 			</ui-card>
 		</div>
 
@@ -129,11 +176,26 @@ export default Vue.extend({
 			set(value) { this.$store.commit('device/set', { key: 'darkmode', value }); }
 		},
 
+		reduceMotion: {
+			get() { return this.$store.state.device.reduceMotion; },
+			set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); }
+		},
+
+		alwaysShowNsfw: {
+			get() { return this.$store.state.device.alwaysShowNsfw; },
+			set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); }
+		},
+
 		postStyle: {
 			get() { return this.$store.state.device.postStyle; },
 			set(value) { this.$store.commit('device/set', { key: 'postStyle', value }); }
 		},
 
+		mobileNotificationPosition: {
+			get() { return this.$store.state.device.mobileNotificationPosition; },
+			set(value) { this.$store.commit('device/set', { key: 'mobileNotificationPosition', value }); }
+		},
+
 		lightmode: {
 			get() { return this.$store.state.device.lightmode; },
 			set(value) { this.$store.commit('device/set', { key: 'lightmode', value }); }
@@ -153,6 +215,86 @@ export default Vue.extend({
 			get() { return this.$store.state.device.enableSounds; },
 			set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); }
 		},
+
+		fetchOnScroll: {
+			get() { return this.$store.state.settings.fetchOnScroll; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); }
+		},
+
+		rememberNoteVisibility: {
+			get() { return this.$store.state.settings.rememberNoteVisibility; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
+		},
+
+		disableViaMobile: {
+			get() { return this.$store.state.settings.disableViaMobile; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); }
+		},
+
+		loadRemoteMedia: {
+			get() { return this.$store.state.settings.loadRemoteMedia; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'loadRemoteMedia', value }); }
+		},
+
+		circleIcons: {
+			get() { return this.$store.state.settings.circleIcons; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); }
+		},
+
+		contrastedAcct: {
+			get() { return this.$store.state.settings.contrastedAcct; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); }
+		},
+
+		showFullAcct: {
+			get() { return this.$store.state.settings.showFullAcct; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); }
+		},
+
+		iLikeSushi: {
+			get() { return this.$store.state.settings.iLikeSushi; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); }
+		},
+
+		games_reversi_showBoardLabels: {
+			get() { return this.$store.state.settings.games.reversi.showBoardLabels; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); }
+		},
+
+		games_reversi_useContrastStones: {
+			get() { return this.$store.state.settings.games.reversi.useContrastStones; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); }
+		},
+
+		disableAnimatedMfm: {
+			get() { return this.$store.state.settings.disableAnimatedMfm; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); }
+		},
+
+		showReplyTarget: {
+			get() { return this.$store.state.settings.showReplyTarget; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); }
+		},
+
+		showMyRenotes: {
+			get() { return this.$store.state.settings.showMyRenotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); }
+		},
+
+		showRenotedMyNotes: {
+			get() { return this.$store.state.settings.showRenotedMyNotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); }
+		},
+
+		showLocalRenotes: {
+			get() { return this.$store.state.settings.showLocalRenotes; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); }
+		},
+
+		defaultNoteVisibility: {
+			get() { return this.$store.state.settings.defaultNoteVisibility; },
+			set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
+		},
 	},
 
 	mounted() {
@@ -164,90 +306,6 @@ export default Vue.extend({
 			(this as any).os.signout();
 		},
 
-		onChangeFetchOnScroll(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'fetchOnScroll',
-				value: v
-			});
-		},
-
-		onChangeDisableViaMobile(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'disableViaMobile',
-				value: v
-			});
-		},
-
-		onChangeLoadRemoteMedia(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'loadRemoteMedia',
-				value: v
-			});
-		},
-
-		onChangeCircleIcons(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'circleIcons',
-				value: v
-			});
-		},
-
-		onChangeILikeSushi(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'iLikeSushi',
-				value: v
-			});
-		},
-
-		onChangeReversiBoardLabels(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'games.reversi.showBoardLabels',
-				value: v
-			});
-		},
-
-		onChangeUseContrastReversiStones(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'games.reversi.useContrastStones',
-				value: v
-			});
-		},
-
-		onChangeDisableAnimatedMfm(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'disableAnimatedMfm',
-				value: v
-			});
-		},
-
-		onChangeShowReplyTarget(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showReplyTarget',
-				value: v
-			});
-		},
-
-		onChangeShowMyRenotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showMyRenotes',
-				value: v
-			});
-		},
-
-		onChangeShowRenotedMyNotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showRenotedMyNotes',
-				value: v
-			});
-		},
-
-		onChangeShowLocalRenotes(v) {
-			this.$store.dispatch('settings/set', {
-				key: 'showLocalRenotes',
-				value: v
-			});
-		},
-
 		checkForUpdate() {
 			this.checkingForUpdate = true;
 			checkForUpdate((this as any).os, true, true).then(newer => {
@@ -271,36 +329,31 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+main
 	margin 0 auto
-	max-width 500px
+	max-width 600px
 	width 100%
 
 	> .signin-as
 		margin 16px
 		padding 16px
 		text-align center
-		color isDark ? #49ab63 : #2c662d
-		background isDark ? #273c34 : #fcfff5
+		color var(--mobileSignedInAsFg)
+		background var(--mobileSignedInAsBg)
 		box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12)
 
 	> .signout
 		margin 16px
 		padding 16px
 		text-align center
-		color isDark ? #ff5f56 : #cc2727
-		background isDark ? #652222 : #fff6f5
+		color var(--mobileSignedInAsFg)
+		background var(--mobileSignedInAsBg)
 		box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12)
 
 	> footer
 		margin 16px
 		text-align center
-		color isDark ? #c9d2e0 : #888
-
-main[data-darkmode]
-	root(true)
-
-main:not([data-darkmode])
-	root(false)
+		color var(--text)
+		opacity 0.7
 
 </style>
diff --git a/src/client/app/mobile/views/pages/settings/settings.profile.vue b/src/client/app/mobile/views/pages/settings/settings.profile.vue
index 3b797cdde1..127f531902 100644
--- a/src/client/app/mobile/views/pages/settings/settings.profile.vue
+++ b/src/client/app/mobile/views/pages/settings/settings.profile.vue
@@ -2,47 +2,64 @@
 <ui-card>
 	<div slot="title">%fa:user% %i18n:@title%</div>
 
-	<ui-form :disabled="saving">
-		<ui-input v-model="name" :max="30">
-			<span>%i18n:@name%</span>
-		</ui-input>
+	<section class="fit-top">
+		<ui-form :disabled="saving">
+			<ui-input v-model="name" :max="30">
+				<span>%i18n:@name%</span>
+			</ui-input>
 
-		<ui-input v-model="username" readonly>
-			<span>%i18n:@account%</span>
-			<span slot="prefix">@</span>
-			<span slot="suffix">@{{ host }}</span>
-		</ui-input>
+			<ui-input v-model="username" readonly>
+				<span>%i18n:@account%</span>
+				<span slot="prefix">@</span>
+				<span slot="suffix">@{{ host }}</span>
+			</ui-input>
 
-		<ui-input v-model="location">
-			<span>%i18n:@location%</span>
-			<span slot="prefix">%fa:map-marker-alt%</span>
-		</ui-input>
+			<ui-input v-model="location">
+				<span>%i18n:@location%</span>
+				<span slot="prefix">%fa:map-marker-alt%</span>
+			</ui-input>
 
-		<ui-input v-model="birthday" type="date">
-			<span>%i18n:@birthday%</span>
-			<span slot="prefix">%fa:birthday-cake%</span>
-		</ui-input>
+			<ui-input v-model="birthday" type="date">
+				<span>%i18n:@birthday%</span>
+				<span slot="prefix">%fa:birthday-cake%</span>
+			</ui-input>
 
-		<ui-textarea v-model="description" :max="500">
-			<span>%i18n:@description%</span>
-		</ui-textarea>
+			<ui-textarea v-model="description" :max="500">
+				<span>%i18n:@description%</span>
+			</ui-textarea>
 
-		<ui-input type="file" @change="onAvatarChange">
-			<span>%i18n:@avatar%</span>
-			<span slot="icon">%fa:image%</span>
-			<span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span>
-		</ui-input>
+			<ui-input type="file" @change="onAvatarChange">
+				<span>%i18n:@avatar%</span>
+				<span slot="icon">%fa:image%</span>
+				<span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span>
+			</ui-input>
 
-		<ui-input type="file" @change="onBannerChange">
-			<span>%i18n:@banner%</span>
-			<span slot="icon">%fa:image%</span>
-			<span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span>
-		</ui-input>
+			<ui-input type="file" @change="onBannerChange">
+				<span>%i18n:@banner%</span>
+				<span slot="icon">%fa:image%</span>
+				<span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span>
+			</ui-input>
 
-		<ui-switch v-model="isCat">%i18n:@is-cat%</ui-switch>
+			<ui-button @click="save(true)">%i18n:@save%</ui-button>
+		</ui-form>
+	</section>
 
-		<ui-button @click="save">%i18n:@save%</ui-button>
-	</ui-form>
+	<section>
+		<header>%i18n:@advanced%</header>
+
+		<div>
+			<ui-switch v-model="isCat" @change="save(false)">%i18n:@is-cat%</ui-switch>
+			<ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch>
+		</div>
+	</section>
+
+	<section>
+		<header>%i18n:@privacy%</header>
+
+		<div>
+			<ui-switch v-model="isLocked" @change="save(false)">%i18n:@is-locked%</ui-switch>
+		</div>
+	</section>
 </ui-card>
 </template>
 
@@ -62,12 +79,20 @@ export default Vue.extend({
 			avatarId: null,
 			bannerId: null,
 			isCat: false,
+			isLocked: false,
 			saving: false,
 			avatarUploading: false,
 			bannerUploading: false
 		};
 	},
 
+	computed: {
+		alwaysMarkNsfw: {
+			get() { return this.$store.state.i.settings.alwaysMarkNsfw; },
+			set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); }
+		},
+	},
+
 	created() {
 		this.name = this.$store.state.i.name || '';
 		this.username = this.$store.state.i.username;
@@ -77,6 +102,7 @@ export default Vue.extend({
 		this.avatarId = this.$store.state.i.avatarId;
 		this.bannerId = this.$store.state.i.bannerId;
 		this.isCat = this.$store.state.i.isCat;
+		this.isLocked = this.$store.state.i.isLocked;
 	},
 
 	methods: {
@@ -124,7 +150,7 @@ export default Vue.extend({
 				});
 		},
 
-		save() {
+		save(notify) {
 			this.saving = true;
 
 			(this as any).api('i/update', {
@@ -134,7 +160,8 @@ export default Vue.extend({
 				birthday: this.birthday || null,
 				avatarId: this.avatarId,
 				bannerId: this.bannerId,
-				isCat: this.isCat
+				isCat: this.isCat,
+				isLocked: this.isLocked
 			}).then(i => {
 				this.saving = false;
 				this.$store.state.i.avatarId = i.avatarId;
@@ -142,7 +169,9 @@ export default Vue.extend({
 				this.$store.state.i.bannerId = i.bannerId;
 				this.$store.state.i.bannerUrl = i.bannerUrl;
 
-				alert('%i18n:@saved%');
+				if (notify) {
+					alert('%i18n:@saved%');
+				}
 			});
 		}
 	}
diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue
index a545e2b839..3f963501e0 100644
--- a/src/client/app/mobile/views/pages/tag.vue
+++ b/src/client/app/mobile/views/pages/tag.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:hashtag%{{ $route.params.tag }}</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:hashtag%</span>{{ $route.params.tag }}</span>
 
 	<main>
 		<p v-if="!fetching && empty">%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}</p>
diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue
index 1c6a829cd5..f8c8aafa61 100644
--- a/src/client/app/mobile/views/pages/user-list.vue
+++ b/src/client/app/mobile/views/pages/user-list.vue
@@ -53,7 +53,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 main
 	width 100%
diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue
index abd04c1496..fc80f5d1c6 100644
--- a/src/client/app/mobile/views/pages/user-lists.vue
+++ b/src/client/app/mobile/views/pages/user-lists.vue
@@ -43,7 +43,7 @@ export default Vue.extend({
 					title
 				});
 
-				this.$router.push('/i/lists/' + list.id);
+				this.$router.push(`/i/lists/${list.id}`);
 			});
 		}
 	}
@@ -51,7 +51,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
+
 
 main
 	width 100%
diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue
index 8918847a8f..a2a6bd7a83 100644
--- a/src/client/app/mobile/views/pages/user.vue
+++ b/src/client/app/mobile/views/pages/user.vue
@@ -1,7 +1,7 @@
 <template>
 <mk-ui>
 	<template slot="header" v-if="!fetching"><img :src="user.avatarUrl" alt="">{{ user | userName }}</template>
-	<main v-if="!fetching" :data-darkmode="$store.state.device.darkmode">
+	<main v-if="!fetching">
 		<div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div>
 		<div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div>
 		<header>
@@ -16,7 +16,7 @@
 				</div>
 				<div class="title">
 					<h1>{{ user | userName }}</h1>
-					<span class="username"><mk-acct :user="user"/></span>
+					<span class="username"><mk-acct :user="user" :detail="true" /></span>
 					<span class="followed" v-if="user.isFollowed">%i18n:@follows-you%</span>
 				</div>
 				<div class="description">
@@ -107,7 +107,7 @@ export default Vue.extend({
 				this.fetching = false;
 
 				Progress.done();
-				document.title = Vue.filter('userName')(this.user) + ' | ' + (this as any).os.instanceName;
+				document.title = `${Vue.filter('userName')(this.user)} | ${(this as any).os.instanceName}`;
 			});
 		}
 	}
@@ -115,10 +115,8 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-@import '~const.styl'
-
-root(isDark)
-	$bg = isDark ? #22252f : #f7f7f7
+main
+	$bg = var(--face)
 
 	> .is-suspended
 	> .is-remote
@@ -148,7 +146,7 @@ root(isDark)
 
 		> .banner
 			padding-bottom 33.3%
-			background-color isDark ? #5f7273 : #cacaca
+			background-color rgba(0, 0, 0, 0.1)
 			background-size cover
 			background-position center
 
@@ -198,26 +196,26 @@ root(isDark)
 					margin 0
 					line-height 22px
 					font-size 20px
-					color isDark ? #fff : #757c82
+					color var(--mobileUserPageName)
 
 				> .username
 					display inline-block
 					line-height 20px
 					font-size 16px
 					font-weight bold
-					color isDark ? #657786 : #969ea5
+					color var(--mobileUserPageAcct)
 
 				> .followed
 					margin-left 8px
 					padding 2px 4px
 					font-size 12px
-					color isDark ? #657786 : #fff
-					background isDark ? #f8f8f8 : #a7bec7
+					color var(--mobileUserPageFollowedFg)
+					background var(--mobileUserPageFollowedBg)
 					border-radius 4px
 
 			> .description
 				margin 8px 0
-				color isDark ? #fff : #757c82
+				color var(--mobileUserPageDescription)
 
 			> .info
 				margin 8px 0
@@ -225,14 +223,14 @@ root(isDark)
 				> p
 					display inline
 					margin 0 16px 0 0
-					color isDark ? #a9b9c1 : #90989c
+					color var(--text)
 
 					> i
 						margin-right 4px
 
 			> .status
 				> a
-					color isDark ? #657786 : #818a92
+					color var(--text)
 
 					&:not(:last-child)
 						margin-right 16px
@@ -240,7 +238,7 @@ root(isDark)
 					> b
 						margin-right 4px
 						font-size 16px
-						color isDark ? #fff : #787e86
+						color var(--mobileUserPageStatusHighlight)
 
 					> i
 						font-size 14px
@@ -249,7 +247,7 @@ root(isDark)
 		position -webkit-sticky
 		position sticky
 		top 47px
-		box-shadow 0 4px 4px isDark ? rgba(#000, 0.3) : rgba(#000, 0.07)
+		box-shadow 0 4px 4px var(--mobileUserPageHeaderShadow)
 		background-color $bg
 		z-index 2
 
@@ -266,7 +264,7 @@ root(isDark)
 				line-height 48px
 				font-size 12px
 				text-decoration none
-				color isDark ? #657786 : #9ca1a5
+				color var(--text)
 				border-bottom solid 2px transparent
 
 				@media (min-width 400px)
@@ -275,8 +273,8 @@ root(isDark)
 
 				&[data-active]
 					font-weight bold
-					color $theme-color
-					border-color $theme-color
+					color var(--primary)
+					border-color var(--primary)
 
 	> .body
 		max-width 680px
@@ -289,10 +287,4 @@ root(isDark)
 		@media (min-width 600px)
 			padding 32px
 
-main[data-darkmode]
-	root(true)
-
-main:not([data-darkmode])
-	root(false)
-
 </style>
diff --git a/src/client/app/mobile/views/pages/user/home.photos.vue b/src/client/app/mobile/views/pages/user/home.photos.vue
index 73ff1d5173..261a3f796c 100644
--- a/src/client/app/mobile/views/pages/user/home.photos.vue
+++ b/src/client/app/mobile/views/pages/user/home.photos.vue
@@ -4,7 +4,7 @@
 	<div class="stream" v-if="!fetching && images.length > 0">
 		<a v-for="image in images"
 			class="img"
-			:style="`background-image: url(${image.media.url})`"
+			:style="`background-image: url(${image.media.thumbnailUrl})`"
 			:href="image.note | notePage"
 		></a>
 	</div>
@@ -26,7 +26,7 @@ export default Vue.extend({
 	mounted() {
 		(this as any).api('users/notes', {
 			userId: this.user.id,
-			withMedia: true,
+			withFiles: true,
 			limit: 6
 		}).then(notes => {
 			notes.forEach(note => {
diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue
index 8b57276b17..2c7134ed43 100644
--- a/src/client/app/mobile/views/pages/user/home.vue
+++ b/src/client/app/mobile/views/pages/user/home.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="root home">
-	<mk-note-detail v-if="user.pinnedNote" :note="user.pinnedNote" :compact="true"/>
+	<mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/>
 	<section class="recent-notes">
 		<h2>%fa:R comments%%i18n:@recent-notes%</h2>
 		<div>
@@ -54,7 +54,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-root(isDark)
+.root.home
 	max-width 600px
 	margin 0 auto
 
@@ -65,7 +65,7 @@ root(isDark)
 			margin 0 0 16px 0
 
 	> section
-		background isDark ? #21242f : #eee
+		background var(--face)
 		border-radius 8px
 		box-shadow 0 4px 16px rgba(#000, 0.1)
 
@@ -80,8 +80,8 @@ root(isDark)
 			padding 8px 10px
 			font-size 15px
 			font-weight normal
-			color isDark ? #b8c5cc : #465258
-			background isDark ? #282c37 : #fff
+			color var(--text)
+			background var(--faceHeader)
 			border-radius 8px 8px 0 0
 
 			@media (min-width 500px)
@@ -98,12 +98,6 @@ root(isDark)
 		display block
 		margin 16px
 		text-align center
-		color isDark ? #cad2da : #929aa0
-
-.root.home[data-darkmode]
-	root(true)
-
-.root.home:not([data-darkmode])
-	root(false)
+		color var(--text)
 
 </style>
diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue
index 49227790ff..32f74bfe3a 100644
--- a/src/client/app/mobile/views/pages/welcome.vue
+++ b/src/client/app/mobile/views/pages/welcome.vue
@@ -1,7 +1,9 @@
 <template>
-<div class="welcome">
+<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes">
+	<div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div>
+
 	<div>
-		<img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name">
+		<img svg-inline src="../../../../assets/title.svg" :alt="name">
 		<p class="host">{{ host }}</p>
 		<div class="about">
 			<h2>{{ name }}</h2>
@@ -15,12 +17,53 @@
 			<mk-welcome-timeline/>
 		</div>
 		<div class="hashtags">
-			<router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link>
+			<mk-tag-cloud/>
+		</div>
+		<div class="photos">
+			<div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div>
 		</div>
 		<div class="stats" v-if="stats">
 			<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
 			<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
 		</div>
+		<div class="announcements" v-if="announcements && announcements.length > 0">
+			<article v-for="announcement in announcements">
+				<span class="title" v-html="announcement.title"></span>
+				<div v-html="announcement.text"></div>
+			</article>
+		</div>
+		<article class="about-misskey">
+			<h1>%i18n:common.intro.title%</h1>
+			<p v-html="'%i18n:common.intro.about%'"></p>
+			<section>
+				<h2>%i18n:common.intro.features%</h2>
+				<section>
+					<h3>%i18n:common.intro.rich-contents%</h3>
+					<div class="image"><img src="/assets/about/post.png" alt=""></div>
+					<p v-html="'%i18n:common.intro.rich-contents-desc%'"></p>
+				</section>
+				<section>
+					<h3>%i18n:common.intro.reaction%</h3>
+					<div class="image"><img src="/assets/about/reaction.png" alt=""></div>
+					<p v-html="'%i18n:common.intro.reaction-desc%'"></p>
+				</section>
+				<section>
+					<h3>%i18n:common.intro.ui%</h3>
+					<div class="image"><img src="/assets/about/ui.png" alt=""></div>
+					<p v-html="'%i18n:common.intro.ui-desc%'"></p>
+				</section>
+				<section>
+					<h3>%i18n:common.intro.drive%</h3>
+					<div class="image"><img src="/assets/about/drive.png" alt=""></div>
+					<p v-html="'%i18n:common.intro.drive-desc%'"></p>
+				</section>
+			</section>
+			<p v-html="'%i18n:common.intro.outro%'"></p>
+		</article>
+		<div class="info" v-if="meta">
+			<p>Version: <b>{{ meta.version }}</b></p>
+			<p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p>
+		</div>
 		<footer>
 			<small>{{ copyright }}</small>
 		</footer>
@@ -30,50 +73,87 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { apiUrl, copyright, host } from '../../../config';
+import { copyright, host } from '../../../config';
+import { concat } from '../../../../../prelude/array';
 
 export default Vue.extend({
 	data() {
 		return {
-			apiUrl,
+			meta: null,
 			copyright,
 			stats: null,
+			banner: null,
 			host,
 			name: 'Misskey',
 			description: '',
-			tags: []
+			photos: [],
+			announcements: []
 		};
 	},
 	created() {
 		(this as any).os.getMeta().then(meta => {
+			this.meta = meta;
 			this.name = meta.name;
 			this.description = meta.description;
+			this.announcements = meta.broadcasts;
+			this.banner = meta.bannerUrl;
 		});
 
 		(this as any).api('stats').then(stats => {
 			this.stats = stats;
 		});
 
-		(this as any).api('hashtags/trend').then(stats => {
-			this.tags = stats.map(x => x.tag);
+		const image = [
+			'image/jpeg',
+			'image/png',
+			'image/gif'
+		];
+
+		(this as any).api('notes/local-timeline', {
+			fileType: image,
+			excludeNsfw: true,
+			limit: 6
+		}).then((notes: any[]) => {
+			const files = concat(notes.map((n: any): any[] => n.files));
+			this.photos = files.filter(f => image.includes(f.type)).slice(0, 6);
 		});
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
-.welcome
+.wgwfgvvimdjvhjfwxropcwksnzftjqes
 	text-align center
-	//background #fff
 
-	> div
+	> .banner
+		position absolute
+		top 0
+		left 0
+		width 100%
+		height 300px
+		background-position center
+		background-size cover
+		opacity 0.7
+
+		&:after
+			content ""
+			display block
+			position absolute
+			bottom 0
+			left 0
+			width 100%
+			height 100px
+			background linear-gradient(transparent, var(--bg))
+
+	> div:not(.banner)
 		padding 32px
 		margin 0 auto
 		max-width 500px
 
-		> img
+		> svg
 			display block
-			max-width 200px
+			width 200px
+			height 50px
 			margin 0 auto
 
 		> .host
@@ -89,8 +169,8 @@ export default Vue.extend({
 		> .about
 			margin-top 16px
 			padding 16px
-			color #555
-			background #fff
+			color var(--text)
+			background var(--face)
 			border-radius 6px
 
 			> h2
@@ -138,27 +218,98 @@ export default Vue.extend({
 				-webkit-overflow-scrolling touch
 
 		> .hashtags
-			padding 16px 0
-			border solid 2px #ddd
-			border-radius 8px
+			padding 0 8px
+			height 200px
 
-			> *
-				margin 0 16px
+		> .photos
+			display grid
+			grid-template-rows 1fr 1fr 1fr
+			grid-template-columns 1fr 1fr
+			gap 8px
+			height 300px
+			margin-top 16px
+
+			> div
+				border-radius 4px
+				background-position center center
+				background-size cover
 
 		> .stats
 			margin 16px 0
 			padding 8px
 			font-size 14px
-			color #444
+			color var(--text)
 			background rgba(#000, 0.1)
 			border-radius 6px
 
 			> *
 				margin 0 8px
 
+		> .announcements
+			margin 16px 0
+
+			> article
+				background var(--mobileAnnouncement)
+				border-radius 6px
+				color var(--mobileAnnouncementFg)
+				padding 16px
+				margin 8px 0
+				font-size 12px
+
+				> .title
+					font-weight bold
+
+		> .about-misskey
+			margin 16px 0
+			padding 32px
+			font-size 14px
+			background var(--face)
+			border-radius 6px
+			overflow hidden
+			color var(--text)
+
+			> h1
+				margin 0
+
+				& + p
+					margin-top 8px
+
+			> p:last-child
+				margin-bottom 0
+
+			> section
+				> h2
+					border-bottom 1px solid var(--faceDivider)
+
+				> section
+					margin-bottom 16px
+					padding-bottom 16px
+					border-bottom 1px solid var(--faceDivider)
+
+					> h3
+						margin-bottom 8px
+
+					> p
+						margin-bottom 0
+
+					> .image
+						> img
+							display block
+							width 100%
+							height 120px
+							object-fit cover
+
+		> .info
+			padding 16px 0
+			border solid 2px rgba(0, 0, 0, 0.1)
+			border-radius 8px
+
+			> *
+				margin 0 16px
+
 		> footer
 			text-align center
-			color #444
+			color var(--text)
 
 			> small
 				display block
diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue
index a83103632e..c649529c0e 100644
--- a/src/client/app/mobile/views/pages/widgets.vue
+++ b/src/client/app/mobile/views/pages/widgets.vue
@@ -1,6 +1,6 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:home%%i18n:@dashboard%</span>
+	<span slot="header"><span style="margin-right:4px;">%fa:home%</span>%i18n:@dashboard%</span>
 	<template slot="func">
 		<button @click="customizing = !customizing">%fa:cog%</button>
 	</template>
diff --git a/src/client/app/safe.js b/src/client/app/safe.js
index 3d73fa1a9c..026fc66c6e 100644
--- a/src/client/app/safe.js
+++ b/src/client/app/safe.js
@@ -12,16 +12,6 @@ if (!('fetch' in window)) {
 		'To run Misskey, please update your browser to latest version or try other browsers.');
 }
 
-// Detect Edge
-if (navigator.userAgent.toLowerCase().indexOf('edge') != -1) {
-	alert(
-		'現在、お使いのブラウザ(Microsoft Edge)ではMisskeyは正しく動作しません。' +
-		'サポートしているブラウザ: Google Chrome, Mozilla Firefox, Apple Safari など' +
-		'\n\n' +
-		'Currently, Misskey cannot run correctly on your browser (Microsoft Edge). ' +
-		'Supported browsers: Google Chrome, Mozilla Firefox, Apple Safari, etc');
-}
-
 // Check whether cookie enabled
 if (!navigator.cookieEnabled) {
 	alert(
diff --git a/src/client/app/store.ts b/src/client/app/store.ts
index 469563495f..545261225a 100644
--- a/src/client/app/store.ts
+++ b/src/client/app/store.ts
@@ -4,18 +4,23 @@ import * as nestedProperty from 'nested-property';
 
 import MiOS from './mios';
 import { hostname } from './config';
+import { erase } from '../../prelude/array';
 
 const defaultSettings = {
 	home: null,
 	mobileHome: [],
 	deck: null,
+	tagTimelines: [],
 	fetchOnScroll: true,
 	showMaps: true,
 	showPostFormOnTopOfTl: false,
 	suggestRecentHashtags: true,
 	showClockOnHeader: true,
+	useShadow: true,
+	roundedCorners: false,
 	circleIcons: true,
-	gradientWindowHeader: false,
+	contrastedAcct: true,
+	showFullAcct: false,
 	showReplyTarget: true,
 	showMyRenotes: true,
 	showRenotedMyNotes: true,
@@ -24,6 +29,8 @@ const defaultSettings = {
 	disableViaMobile: false,
 	memo: null,
 	iLikeSushi: false,
+	rememberNoteVisibility: false,
+	defaultNoteVisibility: 'public',
 	games: {
 		reversi: {
 			showBoardLabels: false,
@@ -33,9 +40,13 @@ const defaultSettings = {
 };
 
 const defaultDeviceSettings = {
+	reduceMotion: false,
 	apiViaStream: true,
 	autoPopout: false,
 	darkmode: false,
+	darkTheme: 'dark',
+	lightTheme: 'light',
+	themes: [],
 	enableSounds: true,
 	soundVolume: 0.5,
 	lang: null,
@@ -43,7 +54,9 @@ const defaultDeviceSettings = {
 	debug: false,
 	lightmode: false,
 	loadRawImages: false,
-	postStyle: 'standard'
+	alwaysShowNsfw: false,
+	postStyle: 'standard',
+	mobileNotificationPosition: 'bottom'
 };
 
 export default (os: MiOS) => new Vuex.Store({
@@ -194,7 +207,7 @@ export default (os: MiOS) => new Vuex.Store({
 
 				removeDeckColumn(state, id) {
 					state.deck.columns = state.deck.columns.filter(c => c.id != id);
-					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
 					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
 				},
 
@@ -265,7 +278,7 @@ export default (os: MiOS) => new Vuex.Store({
 
 				stackLeftDeckColumn(state, id) {
 					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
-					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
 					const left = state.deck.layout[i - 1];
 					if (left) state.deck.layout[i - 1].push(id);
 					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
@@ -273,7 +286,7 @@ export default (os: MiOS) => new Vuex.Store({
 
 				popRightDeckColumn(state, id) {
 					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
-					state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
 					state.deck.layout.splice(i + 1, 0, [id]);
 					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
 				},
diff --git a/src/client/app/sw.js b/src/client/app/sw.js
index ac7ea20acf..d381bfb7a5 100644
--- a/src/client/app/sw.js
+++ b/src/client/app/sw.js
@@ -3,6 +3,7 @@
  */
 
 import composeNotification from './common/scripts/compose-notification';
+import { erase } from '../../prelude/array';
 
 // キャッシュするリソース
 const cachee = [
@@ -24,8 +25,7 @@ self.addEventListener('activate', ev => {
 	// Clean up old caches
 	ev.waitUntil(
 		caches.keys().then(keys => Promise.all(
-			keys
-				.filter(key => key != _VERSION_)
+			erase(_VERSION_, keys)
 				.map(key => caches.delete(key))
 		))
 	);
diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts
new file mode 100644
index 0000000000..9c5be74fa1
--- /dev/null
+++ b/src/client/app/theme.ts
@@ -0,0 +1,104 @@
+import * as tinycolor from 'tinycolor2';
+
+export type Theme = {
+	id: string;
+	name: string;
+	author: string;
+	desc?: string;
+	base?: 'dark' | 'light';
+	vars: { [key: string]: string };
+	props: { [key: string]: string };
+};
+
+export const lightTheme: Theme = require('../theme/light.json5');
+export const darkTheme: Theme = require('../theme/dark.json5');
+export const pinkTheme: Theme = require('../theme/pink.json5');
+export const blackTheme: Theme = require('../theme/black.json5');
+export const halloweenTheme: Theme = require('../theme/halloween.json5');
+
+export const builtinThemes = [
+	lightTheme,
+	darkTheme,
+	pinkTheme,
+	blackTheme,
+	halloweenTheme
+];
+
+export function applyTheme(theme: Theme, persisted = true) {
+	// Deep copy
+	const _theme = JSON.parse(JSON.stringify(theme));
+
+	if (_theme.base) {
+		const base = [lightTheme, darkTheme].find(x => x.id == _theme.base);
+		_theme.vars = Object.assign({}, base.vars, _theme.vars);
+		_theme.props = Object.assign({}, base.props, _theme.props);
+	}
+
+	const props = compile(_theme);
+
+	Object.entries(props).forEach(([k, v]) => {
+		document.documentElement.style.setProperty(`--${k}`, v.toString());
+	});
+
+	if (persisted) {
+		localStorage.setItem('theme', JSON.stringify(props));
+	}
+}
+
+function compile(theme: Theme): { [key: string]: string } {
+	function getColor(code: string): tinycolor.Instance {
+		// ref
+		if (code[0] == '@') {
+			return getColor(theme.props[code.substr(1)]);
+		}
+		if (code[0] == '$') {
+			return getColor(theme.vars[code.substr(1)]);
+		}
+
+		// func
+		if (code[0] == ':') {
+			const parts = code.split('<');
+			const func = parts.shift().substr(1);
+			const arg = parseFloat(parts.shift());
+			const color = getColor(parts.join('<'));
+
+			switch (func) {
+				case 'darken': return color.darken(arg);
+				case 'lighten': return color.lighten(arg);
+				case 'alpha': return color.setAlpha(arg);
+			}
+		}
+
+		return tinycolor(code);
+	}
+
+	const props = {};
+
+	Object.entries(theme.props).forEach(([k, v]) => {
+		const c = getColor(v);
+		props[k] = genValue(c);
+	});
+
+	const primary = getColor(props['primary']);
+
+	for (let i = 1; i < 10; i++) {
+		const color = primary.clone().setAlpha(i / 10);
+		props['primaryAlpha0' + i] = genValue(color);
+	}
+
+	for (let i = 1; i < 100; i++) {
+		const color = primary.clone().lighten(i);
+		props['primaryLighten' + i] = genValue(color);
+	}
+
+	for (let i = 1; i < 100; i++) {
+		const color = primary.clone().darken(i);
+		props['primaryDarken' + i] = genValue(color);
+	}
+
+	return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+	return c.toRgbString();
+}
diff --git a/src/client/app/tsconfig.json b/src/client/app/tsconfig.json
index e31b52dab1..4a05469673 100644
--- a/src/client/app/tsconfig.json
+++ b/src/client/app/tsconfig.json
@@ -14,7 +14,8 @@
     "removeComments": false,
     "noLib": false,
     "strict": true,
-    "strictNullChecks": false
+    "strictNullChecks": false,
+    "experimentalDecorators": true
   },
   "compileOnSave": false,
   "include": [
diff --git a/src/client/assets/code-highlight.css b/src/client/assets/code-highlight.css
deleted file mode 100644
index f0807dc9c3..0000000000
--- a/src/client/assets/code-highlight.css
+++ /dev/null
@@ -1,93 +0,0 @@
-.hljs {
-	font-family: Consolas, 'Courier New', Courier, Monaco, monospace;
-}
-
-.hljs,
-.hljs-subst {
-	color: #444;
-}
-
-.hljs-comment {
-	color: #888888;
-}
-
-.hljs-keyword {
-	color: #2973b7;
-}
-
-.hljs-number {
-	color: #ae81ff;
-}
-
-.hljs-string {
-	color: #e96900;
-}
-
-.hljs-regexp {
-	color: #e9003f;
-}
-
-.hljs-attribute,
-.hljs-selector-tag,
-.hljs-meta-keyword,
-.hljs-doctag,
-.hljs-name {
-	font-weight: bold;
-}
-
-.hljs-type,
-.hljs-selector-id,
-.hljs-selector-class,
-.hljs-quote,
-.hljs-template-tag,
-.hljs-deletion {
-	color: #880000;
-}
-
-.hljs-title,
-.hljs-section {
-	color: #880000;
-	font-weight: bold;
-}
-
-.hljs-symbol,
-.hljs-variable,
-.hljs-template-variable,
-.hljs-link,
-.hljs-selector-attr,
-.hljs-selector-pseudo {
-	color: #BC6060;
-}
-
-/* Language color: hue: 90; */
-
-.hljs-literal {
-	color: #78A960;
-}
-
-.hljs-built_in,
-.hljs-bullet,
-.hljs-code,
-.hljs-addition {
-	color: #397300;
-}
-
-/* Meta color: hue: 200 */
-
-.hljs-meta {
-	color: #1f7199;
-}
-
-.hljs-meta-string {
-	color: #4d99bf;
-}
-
-/* Misc effects */
-
-.hljs-emphasis {
-	font-style: italic;
-}
-
-.hljs-strong {
-	font-weight: bold;
-}
diff --git a/src/client/assets/pointer.png b/src/client/assets/pointer.png
index 0d03f75d2b..c9aaada5a3 100644
Binary files a/src/client/assets/pointer.png and b/src/client/assets/pointer.png differ
diff --git a/src/client/assets/reactions/angry.png b/src/client/assets/reactions/angry.png
deleted file mode 100644
index 7e32dd6809..0000000000
Binary files a/src/client/assets/reactions/angry.png and /dev/null differ
diff --git a/src/client/assets/reactions/confused.png b/src/client/assets/reactions/confused.png
deleted file mode 100644
index c791854183..0000000000
Binary files a/src/client/assets/reactions/confused.png and /dev/null differ
diff --git a/src/client/assets/reactions/congrats.png b/src/client/assets/reactions/congrats.png
deleted file mode 100644
index fdea27fcb9..0000000000
Binary files a/src/client/assets/reactions/congrats.png and /dev/null differ
diff --git a/src/client/assets/reactions/hmm.png b/src/client/assets/reactions/hmm.png
deleted file mode 100644
index 725fe3898d..0000000000
Binary files a/src/client/assets/reactions/hmm.png and /dev/null differ
diff --git a/src/client/assets/reactions/laugh.png b/src/client/assets/reactions/laugh.png
deleted file mode 100644
index 3b3c10a27a..0000000000
Binary files a/src/client/assets/reactions/laugh.png and /dev/null differ
diff --git a/src/client/assets/reactions/like.png b/src/client/assets/reactions/like.png
deleted file mode 100644
index 526b391f96..0000000000
Binary files a/src/client/assets/reactions/like.png and /dev/null differ
diff --git a/src/client/assets/reactions/love.png b/src/client/assets/reactions/love.png
deleted file mode 100644
index 9fe82cd070..0000000000
Binary files a/src/client/assets/reactions/love.png and /dev/null differ
diff --git a/src/client/assets/reactions/pudding.png b/src/client/assets/reactions/pudding.png
deleted file mode 100644
index e4d10a229d..0000000000
Binary files a/src/client/assets/reactions/pudding.png and /dev/null differ
diff --git a/src/client/assets/reactions/rip.png b/src/client/assets/reactions/rip.png
deleted file mode 100644
index 4800fdb91b..0000000000
Binary files a/src/client/assets/reactions/rip.png and /dev/null differ
diff --git a/src/client/assets/reactions/surprise.png b/src/client/assets/reactions/surprise.png
deleted file mode 100644
index aa55592ded..0000000000
Binary files a/src/client/assets/reactions/surprise.png and /dev/null differ
diff --git a/src/client/assets/reactions/sushi.png b/src/client/assets/reactions/sushi.png
deleted file mode 100644
index c30d44eb15..0000000000
Binary files a/src/client/assets/reactions/sushi.png and /dev/null differ
diff --git a/src/client/assets/title.light.svg b/src/client/assets/title.light.svg
deleted file mode 100644
index 95ad11c399..0000000000
--- a/src/client/assets/title.light.svg
+++ /dev/null
@@ -1,140 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="614.71039"
-   height="205.08009"
-   viewBox="0 0 162.64213 54.260776"
-   version="1.1"
-   id="svg8"
-   inkscape:version="0.92.1 r15371"
-   sodipodi:docname="misskey.svg"
-   inkscape:export-filename="C:\Users\Takumiya_Cho\Desktop\misskey.png"
-   inkscape:export-xdpi="96"
-   inkscape:export-ydpi="96">
-  <defs
-     id="defs2">
-    <inkscape:path-effect
-       effect="simplify"
-       id="path-effect5115"
-       is_visible="true"
-       steps="1"
-       threshold="0.000408163"
-       smooth_angles="360"
-       helper_size="0"
-       simplify_individual_paths="false"
-       simplify_just_coalesce="false"
-       simplifyindividualpaths="false"
-       simplifyJustCoalesce="false" />
-    <inkscape:path-effect
-       effect="simplify"
-       id="path-effect5104"
-       is_visible="true"
-       steps="1"
-       threshold="0.000408163"
-       smooth_angles="360"
-       helper_size="0"
-       simplify_individual_paths="false"
-       simplify_just_coalesce="false"
-       simplifyindividualpaths="false"
-       simplifyJustCoalesce="false" />
-  </defs>
-  <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="0.9899495"
-     inkscape:cx="370.82839"
-     inkscape:cy="79.043895"
-     inkscape:document-units="mm"
-     inkscape:current-layer="layer1"
-     showgrid="false"
-     units="px"
-     inkscape:snap-bbox="true"
-     inkscape:bbox-nodes="true"
-     inkscape:snap-bbox-edge-midpoints="false"
-     inkscape:snap-smooth-nodes="true"
-     inkscape:snap-center="true"
-     inkscape:snap-page="true"
-     inkscape:window-width="1920"
-     inkscape:window-height="1017"
-     inkscape:window-x="-8"
-     inkscape:window-y="1072"
-     inkscape:window-maximized="1"
-     inkscape:object-paths="true"
-     inkscape:bbox-paths="true"
-     fit-margin-top="50"
-     fit-margin-left="50"
-     fit-margin-bottom="20"
-     fit-margin-right="50" />
-  <metadata
-     id="metadata5">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title></dc:title>
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <g
-     inkscape:label="レイヤー 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(-11.097531,-173.29664)">
-    <g
-       transform="matrix(0.28612302,0,0,0.28612302,17.176981,141.74334)"
-       id="text4489-6"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       aria-label="Mi">
-      <path
-         sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz"
-         inkscape:connector-curvature="0"
-         id="path5210"
-         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#212d3a;fill-opacity:1;stroke-width:0.92471898px"
-         d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" />
-      <path
-         inkscape:connector-curvature="0"
-         id="path5212"
-         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#212d3a;fill-opacity:1;stroke-width:0.92471898px"
-         d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" />
-    </g>
-    <path
-       inkscape:connector-curvature="0"
-       id="path5199"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 72.022691,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791824,1.29083 2.581666,1.69422 2.581666,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685756,0.0807 1.169817,0.24203 4.477578,0.60508 0.443724,0 0.968125,-0.0403 0.201693,0 0.201693,-0.24203 0.04034,-0.20169 -0.242032,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895911,-0.48406 -1.12948,-0.32271 -1.895912,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685756,0.84711 0.685756,1.93625 0,1.25049 -0.927787,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path5201"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 89.577027,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791823,1.29083 2.581667,1.69422 2.581667,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685755,0.0807 1.169818,0.24203 4.477579,0.60508 0.443724,0 0.968125,-0.0403 0.201692,0 0.201692,-0.24203 0.04034,-0.20169 -0.242031,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895912,-0.48406 -1.129479,-0.32271 -1.895911,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685755,0.84711 0.685755,1.93625 0,1.25049 -0.927786,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path5203"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 115.65209,203.87137 q 0.12101,0.0807 2.86404,2.78336 1.25049,1.21016 1.25049,2.94471 0,1.61354 -1.16982,2.86404 -1.16982,1.21016 -2.90437,1.21016 -1.65388,0 -2.86404,-1.16982 l -4.03385,-3.91284 q -0.16136,-0.12102 -0.32271,-0.12102 -0.32271,0 -0.32271,1.21016 0,1.69422 -1.21016,2.90438 -1.21015,1.16981 -2.90437,1.16981 -1.69422,0 -2.90438,-1.16981 -1.169807,-1.21016 -1.169807,-2.90438 v -18.79776 q 0,-1.69422 1.169807,-2.86404 1.21016,-1.21015 2.90438,-1.21015 1.69422,0 2.90437,1.21015 1.21016,1.16982 1.21016,2.86404 v 6.29281 q 0,0.40339 0.28237,0.5244 0.24203,0.12102 0.5244,-0.0807 0.16135,-0.0807 4.84063,-3.18675 1.0488,-0.64542 2.25895,-0.64542 2.21862,0 3.42878,1.81524 0.64542,1.0488 0.64542,2.25896 0,2.21862 -1.81524,3.42877 l -2.54133,1.61354 v 0.0403 l -0.0807,0.0403 q -0.56474,0.36305 -0.0403,0.88745 z" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path5205"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 131.25181,213.92955 q -4.19521,0 -7.18026,-2.94472 -2.94472,-2.98505 -2.94472,-7.18026 0,-4.15487 2.94472,-7.09958 2.98505,-2.98505 7.18026,-2.98505 4.15487,0 6.97857,2.78335 0.92778,0.92779 0.92778,2.25896 0,1.33118 -0.92778,2.25896 l -4.67928,4.63893 q -1.00846,1.00847 -2.01692,1.00847 -1.45219,0 -2.25896,-0.80677 -0.80677,-0.80677 -0.80677,-2.13795 0,-1.29083 0.92778,-2.21862 l 0.80678,-0.84711 q 0.16135,-0.12101 0.0807,-0.24203 -0.12101,-0.0807 -0.32271,-0.0403 -0.80677,0.20169 -1.37151,0.80677 -1.12948,1.08914 -1.12948,2.622 0,1.5732 1.08915,2.70268 1.12947,1.08914 2.70268,1.08914 1.53286,0 2.622,-1.12947 0.92779,-0.92779 2.25896,-0.92779 1.33117,0 2.25896,0.92779 0.92779,0.92778 0.92779,2.25895 0,1.33118 -0.92779,2.25896 -2.98505,2.94472 -7.13992,2.94472 z" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path5207"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 160.51049,198.1433 v 5.60705 q 0,0.56474 -0.0807,1.21016 v 7.38195 q 0,4.51792 -2.74302,7.2206 -2.70268,2.70269 -7.30128,2.70269 -2.66234,0 -4.80028,-1.00847 -2.13795,-0.96812 -2.13795,-3.3481 0,-0.80677 0.36305,-1.53286 0.96812,-2.17828 3.3481,-2.17828 0.56474,0 1.5732,0.32271 1.00847,0.3227 1.65388,0.3227 1.69422,0 2.21862,-0.72609 0.20169,-0.28237 0.0807,-0.44372 -0.16136,-0.24204 -0.56474,-0.16136 -0.68576,0.12102 -1.49253,0.12102 -4.07419,0 -6.97856,-2.90438 -2.90438,-2.90437 -2.90438,-6.97857 v -5.60705 q 0,-1.69422 1.16982,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.90438,1.21016 1.21015,1.16982 1.21015,2.86404 v 5.60705 q 0,0.68576 0.48407,1.21016 0.5244,0.48406 1.21015,0.48406 0.7261,0 1.21016,-0.48406 0.48406,-0.5244 0.48406,-1.21016 v -5.60705 q 0,-1.69422 1.21016,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.86404,1.21016 1.21016,1.16982 1.21016,2.86404 z" />
-  </g>
-</svg>
diff --git a/src/client/assets/title.dark.svg b/src/client/assets/title.svg
similarity index 92%
rename from src/client/assets/title.dark.svg
rename to src/client/assets/title.svg
index 10139024ad..0e4e0b8b3b 100644
--- a/src/client/assets/title.dark.svg
+++ b/src/client/assets/title.svg
@@ -97,44 +97,44 @@
     <g
        transform="matrix(0.28612302,0,0,0.28612302,17.176981,141.74334)"
        id="text4489-6"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        aria-label="Mi">
       <path
          sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz"
          inkscape:connector-curvature="0"
          id="path5210"
-         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#fff;fill-opacity:1;stroke-width:0.92471898px"
+         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px"
          d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" />
       <path
          inkscape:connector-curvature="0"
          id="path5212"
-         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#fff;fill-opacity:1;stroke-width:0.92471898px"
+         style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px"
          d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" />
     </g>
     <path
        inkscape:connector-curvature="0"
        id="path5199"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        d="m 72.022691,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791824,1.29083 2.581666,1.69422 2.581666,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685756,0.0807 1.169817,0.24203 4.477578,0.60508 0.443724,0 0.968125,-0.0403 0.201693,0 0.201693,-0.24203 0.04034,-0.20169 -0.242032,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895911,-0.48406 -1.12948,-0.32271 -1.895912,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685756,0.84711 0.685756,1.93625 0,1.25049 -0.927787,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" />
     <path
        inkscape:connector-curvature="0"
        id="path5201"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        d="m 89.577027,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791823,1.29083 2.581667,1.69422 2.581667,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685755,0.0807 1.169818,0.24203 4.477579,0.60508 0.443724,0 0.968125,-0.0403 0.201692,0 0.201692,-0.24203 0.04034,-0.20169 -0.242031,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895912,-0.48406 -1.129479,-0.32271 -1.895911,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685755,0.84711 0.685755,1.93625 0,1.25049 -0.927786,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" />
     <path
        inkscape:connector-curvature="0"
        id="path5203"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        d="m 115.65209,203.87137 q 0.12101,0.0807 2.86404,2.78336 1.25049,1.21016 1.25049,2.94471 0,1.61354 -1.16982,2.86404 -1.16982,1.21016 -2.90437,1.21016 -1.65388,0 -2.86404,-1.16982 l -4.03385,-3.91284 q -0.16136,-0.12102 -0.32271,-0.12102 -0.32271,0 -0.32271,1.21016 0,1.69422 -1.21016,2.90438 -1.21015,1.16981 -2.90437,1.16981 -1.69422,0 -2.90438,-1.16981 -1.169807,-1.21016 -1.169807,-2.90438 v -18.79776 q 0,-1.69422 1.169807,-2.86404 1.21016,-1.21015 2.90438,-1.21015 1.69422,0 2.90437,1.21015 1.21016,1.16982 1.21016,2.86404 v 6.29281 q 0,0.40339 0.28237,0.5244 0.24203,0.12102 0.5244,-0.0807 0.16135,-0.0807 4.84063,-3.18675 1.0488,-0.64542 2.25895,-0.64542 2.21862,0 3.42878,1.81524 0.64542,1.0488 0.64542,2.25896 0,2.21862 -1.81524,3.42877 l -2.54133,1.61354 v 0.0403 l -0.0807,0.0403 q -0.56474,0.36305 -0.0403,0.88745 z" />
     <path
        inkscape:connector-curvature="0"
        id="path5205"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        d="m 131.25181,213.92955 q -4.19521,0 -7.18026,-2.94472 -2.94472,-2.98505 -2.94472,-7.18026 0,-4.15487 2.94472,-7.09958 2.98505,-2.98505 7.18026,-2.98505 4.15487,0 6.97857,2.78335 0.92778,0.92779 0.92778,2.25896 0,1.33118 -0.92778,2.25896 l -4.67928,4.63893 q -1.00846,1.00847 -2.01692,1.00847 -1.45219,0 -2.25896,-0.80677 -0.80677,-0.80677 -0.80677,-2.13795 0,-1.29083 0.92778,-2.21862 l 0.80678,-0.84711 q 0.16135,-0.12101 0.0807,-0.24203 -0.12101,-0.0807 -0.32271,-0.0403 -0.80677,0.20169 -1.37151,0.80677 -1.12948,1.08914 -1.12948,2.622 0,1.5732 1.08915,2.70268 1.12947,1.08914 2.70268,1.08914 1.53286,0 2.622,-1.12947 0.92779,-0.92779 2.25896,-0.92779 1.33117,0 2.25896,0.92779 0.92779,0.92778 0.92779,2.25895 0,1.33118 -0.92779,2.25896 -2.98505,2.94472 -7.13992,2.94472 z" />
     <path
        inkscape:connector-curvature="0"
        id="path5207"
-       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
        d="m 160.51049,198.1433 v 5.60705 q 0,0.56474 -0.0807,1.21016 v 7.38195 q 0,4.51792 -2.74302,7.2206 -2.70268,2.70269 -7.30128,2.70269 -2.66234,0 -4.80028,-1.00847 -2.13795,-0.96812 -2.13795,-3.3481 0,-0.80677 0.36305,-1.53286 0.96812,-2.17828 3.3481,-2.17828 0.56474,0 1.5732,0.32271 1.00847,0.3227 1.65388,0.3227 1.69422,0 2.21862,-0.72609 0.20169,-0.28237 0.0807,-0.44372 -0.16136,-0.24204 -0.56474,-0.16136 -0.68576,0.12102 -1.49253,0.12102 -4.07419,0 -6.97856,-2.90438 -2.90438,-2.90437 -2.90438,-6.97857 v -5.60705 q 0,-1.69422 1.16982,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.90438,1.21016 1.21015,1.16982 1.21015,2.86404 v 5.60705 q 0,0.68576 0.48407,1.21016 0.5244,0.48406 1.21015,0.48406 0.7261,0 1.21016,-0.48406 0.48406,-0.5244 0.48406,-1.21016 v -5.60705 q 0,-1.69422 1.21016,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.86404,1.21016 1.21016,1.16982 1.21016,2.86404 z" />
   </g>
 </svg>
diff --git a/src/client/const.styl b/src/client/const.styl
deleted file mode 100644
index b6560701d9..0000000000
--- a/src/client/const.styl
+++ /dev/null
@@ -1,4 +0,0 @@
-json('../const.json')
-
-$theme-color = themeColor
-$theme-color-foreground = themeColorForeground
diff --git a/src/client/element.scss b/src/client/element.scss
deleted file mode 100644
index 917198e024..0000000000
--- a/src/client/element.scss
+++ /dev/null
@@ -1,12 +0,0 @@
-/* Element variable definitons */
-/* SEE: http://element.eleme.io/#/en-US/component/custom-theme */
-
-@import '../const.json';
-
-/* theme color */
-$--color-primary: $themeColor;
-
-/* icon font path, required */
-$--font-path: '~element-ui/lib/theme-chalk/fonts';
-
-@import "~element-ui/packages/theme-chalk/src/index";
diff --git a/src/client/style.styl b/src/client/style.styl
index 6d1e53e5a6..8ebba2f15e 100644
--- a/src/client/style.styl
+++ b/src/client/style.styl
@@ -1,10 +1,8 @@
 @charset 'utf-8'
 
-@import "./const"
-
 /*
 	::selection
-		background $theme-color
+		background var(--primary)
 		color #fff
 */
 
@@ -24,10 +22,8 @@ html, body
 
 a
 	text-decoration none
-	color $theme-color
+	color var(--primary)
 	cursor pointer
-	tap-highlight-color rgba($theme-color, 0.7) !important
-	-webkit-tap-highlight-color rgba($theme-color, 0.7) !important
 
 	&:hover
 		text-decoration underline
@@ -35,3 +31,9 @@ a
 	*
 		cursor pointer
 
+@css {
+	a {
+		tap-highlight-color: var(--primaryAlpha07) !important;
+		-webkit-tap-highlight-color: var(--primaryAlpha07) !important;
+	}
+}
diff --git a/src/client/theme/black.json5 b/src/client/theme/black.json5
new file mode 100644
index 0000000000..91a812f88a
--- /dev/null
+++ b/src/client/theme/black.json5
@@ -0,0 +1,20 @@
+{
+	id: 'bb5a8287-a072-4b0a-8ae5-ea2a0d33f4f2',
+
+	name: 'Future',
+	author: 'syuilo',
+
+	base: 'dark',
+
+	vars: {
+		primary: 'rgb(94, 158, 185)',
+		secondary: 'rgb(22, 24, 30)',
+		text: 'rgb(214, 218, 224)',
+	},
+
+	props: {
+		renoteGradient: '#0a2d3c',
+		renoteText: '$primary',
+		quoteBorder: '$primary',
+	},
+}
diff --git a/src/client/theme/dark.json5 b/src/client/theme/dark.json5
new file mode 100644
index 0000000000..4fa38a3ae0
--- /dev/null
+++ b/src/client/theme/dark.json5
@@ -0,0 +1,209 @@
+{
+	id: 'dark',
+
+	name: 'Dark',
+	author: 'syuilo',
+	desc: 'Default dark theme',
+	kind: 'dark',
+
+	vars: {
+		primary: '#fb4e4e',
+		secondary: '#282C37',
+		text: '#d6dae0',
+	},
+
+	props: {
+		primary: '$primary',
+		primaryForeground: '#fff',
+		secondary: '$secondary',
+		bg: ':darken<8<$secondary',
+		text: '$text',
+
+		scrollbarTrack: ':darken<5<$secondary',
+		scrollbarHandle: ':lighten<5<$secondary',
+		scrollbarHandleHover: ':lighten<10<$secondary',
+
+		face: '$secondary',
+		faceText: '#fff',
+		faceHeader: ':lighten<5<$secondary',
+		faceHeaderText: '#e3e5e8',
+		faceDivider: 'rgba(0, 0, 0, 0.3)',
+		faceTextButton: '$text',
+		faceTextButtonHover: ':lighten<10<$text',
+		faceTextButtonActive: ':darken<10<$text',
+		faceClearButtonHover: 'rgba(0, 0, 0, 0.1)',
+		faceClearButtonActive: 'rgba(0, 0, 0, 0.2)',
+		popupBg: ':lighten<5<$secondary',
+		popupFg: '#d6dce2',
+
+		subNoteBg: 'rgba(0, 0, 0, 0.18)',
+		subNoteText: ':alpha<0.7<$text',
+		renoteGradient: '#314027',
+		renoteText: '#9dbb00',
+		quoteBorder: '#4e945e',
+		noteText: '#fff',
+		noteHeaderName: '#fff',
+		noteHeaderBadgeFg: '#758188',
+		noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.25)',
+		noteHeaderAdminFg: '#f15f71',
+		noteHeaderAdminBg: '#5d282e',
+		noteHeaderAcct: ':alpha<0.65<$text',
+		noteHeaderInfo: ':alpha<0.5<$text',
+
+		noteActions: ':alpha<0.45<$text',
+		noteActionsHover: ':alpha<0.6<$text',
+		noteActionsReplyHover: '#0af',
+		noteActionsRenoteHover: '#8d0',
+		noteActionsReactionHover: '#fa0',
+		noteActionsHighlighted: ':alpha<0.7<$text',
+
+		noteAttachedFile: 'rgba(255, 255, 255, 0.1)',
+
+		modalBackdrop: 'rgba(0, 0, 0, 0.5)',
+
+		dateDividerBg: ':darken<2<$secondary',
+		dateDividerFg: ':alpha<0.7<$text',
+
+		switchTrack: 'rgba(255, 255, 255, 0.15)',
+		radioBorder: 'rgba(255, 255, 255, 0.6)',
+		inputBorder: 'rgba(255, 255, 255, 0.7)',
+		inputLabel: 'rgba(255, 255, 255, 0.7)',
+		inputText: '#fff',
+
+		buttonBg: 'rgba(255, 255, 255, 0.05)',
+		buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
+		buttonActiveBg: 'rgba(255, 255, 255, 0.15)',
+
+		autocompleteItemHoverBg: 'rgba(255, 255, 255, 0.1)',
+		autocompleteItemText: 'rgba(255, 255, 255, 0.8)',
+		autocompleteItemTextSub: 'rgba(255, 255, 255, 0.3)',
+
+		cwButtonBg: '#687390',
+		cwButtonFg: '#393f4f',
+		cwButtonHoverBg: '#707b97',
+
+		reactionPickerButtonHoverBg: 'rgba(255, 255, 255, 0.18)',
+
+		reactionViewerBorder: 'rgba(255, 255, 255, 0.1)',
+
+		pollEditorInputBg: 'rgba(0, 0, 0, 0.25)',
+
+		pollChoiceText: '#fff',
+		pollChoiceBorder: 'rgba(255, 255, 255, 0.1)',
+
+		urlPreviewBorder: 'rgba(0, 0, 0, 0.4)',
+		urlPreviewBorderHover: 'rgba(255, 255, 255, 0.2)',
+		urlPreviewTitle: '$text',
+		urlPreviewText: ':alpha<0.7<$text',
+		urlPreviewInfo: ':alpha<0.8<$text',
+
+		calendarWeek: '#43d5dc',
+		calendarSaturdayOrSunday: '#ff6679',
+		calendarDay: '$text',
+
+		materBg: 'rgba(0, 0, 0, 0.3)',
+
+		chartCaption: ':alpha<0.6<$text',
+
+		announcementsBg: '#253a50',
+		announcementsTitle: '#539eff',
+		announcementsText: '#fff',
+
+		donationBg: '#5d5242',
+		donationFg: '#e4dbce',
+
+		googleSearchBg: 'rgba(0, 0, 0, 0.2)',
+		googleSearchFg: '#dee4e8',
+		googleSearchBorder: 'rgba(255, 255, 255, 0.2)',
+		googleSearchHoverBorder: 'rgba(255, 255, 255, 0.3)',
+		googleSearchHoverButton: 'rgba(255, 255, 255, 0.1)',
+
+		mfmTitleBg: 'rgba(0, 0, 0, 0.2)',
+		mfmQuote: ':alpha<0.7<$text',
+		mfmQuoteLine: ':alpha<0.6<$text',
+
+		suspendedInfoBg: '#611d1d',
+		suspendedInfoFg: '#ffb4b4',
+		remoteInfoBg: '#42321c',
+		remoteInfoFg: '#ffbd3e',
+
+		messagingRoomBg: '@bg',
+		messagingRoomInfo: '#fff',
+		messagingRoomDateDividerLine: 'rgba(255, 255, 255, 0.1)',
+		messagingRoomDateDividerText: 'rgba(255, 255, 255, 0.3)',
+		messagingRoomMessageInfo: 'rgba(255, 255, 255, 0.4)',
+		messagingRoomMessageBg: '$secondary',
+		messagingRoomMessageFg: '#fff',
+
+		formButtonBorder: 'rgba(255, 255, 255, 0.1)',
+		formButtonHoverBg: ':alpha<0.2<$primary',
+		formButtonHoverBorder: ':alpha<0.5<$primary',
+		formButtonActiveBg: ':alpha<0.12<$primary',
+
+		desktopHeaderBg: ':lighten<5<$secondary',
+		desktopHeaderFg: '$text',
+		desktopHeaderHoverFg: '#fff',
+		desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.1)',
+		desktopHeaderSearchHoverBg: 'rgba(255, 255, 255, 0.04)',
+		desktopHeaderSearchFg: '#fff',
+		desktopNotificationBg: ':alpha<0.9<$secondary',
+		desktopNotificationFg: ':alpha<0.7<$text',
+		desktopNotificationShadow: 'rgba(0, 0, 0, 0.4)',
+		desktopPostFormBg: '@face',
+		desktopPostFormTextareaBg: 'rgba(0, 0, 0, 0.25)',
+		desktopPostFormTextareaFg: '#fff',
+		desktopPostFormTransparentButtonFg: '$primary',
+		desktopPostFormTransparentButtonActiveGradientStart: ':darken<8<$secondary',
+		desktopPostFormTransparentButtonActiveGradientEnd: ':darken<3<$secondary',
+		desktopRenoteFormFooter: ':lighten<5<$secondary',
+		desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.15)',
+		desktopTimelineSrc: '@faceTextButton',
+		desktopTimelineSrcHover: '@faceTextButtonHover',
+		desktopWindowTitle: '@faceHeaderText',
+		desktopWindowShadow: 'rgba(0, 0, 0, 0.5)',
+		desktopDriveBg: '@bg',
+		desktopDriveFolderBg: ':alpha<0.2<$primary',
+		desktopDriveFolderHoverBg: ':alpha<0.3<$primary',
+		desktopDriveFolderActiveBg: ':alpha<0.3<:darken<10<$primary',
+		desktopDriveFolderFg: '#fff',
+		desktopSettingsNavItem: ':alpha<0.8<$text',
+		desktopSettingsNavItemHover: ':lighten<10<$text',
+
+		deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.25)',
+
+		mobileHeaderBg: ':lighten<5<$secondary',
+		mobileHeaderFg: '$text',
+		mobileNavBackdrop: 'rgba(0, 0, 0, 0.7)',
+		mobilePostFormDivider: 'rgba(0, 0, 0, 0.2)',
+		mobilePostFormTextareaBg: 'rgba(0, 0, 0, 0.3)',
+		mobilePostFormButton: '$text',
+		mobileDriveNavBg: ':alpha<0.75<$secondary',
+		mobileHomeTlItemHover: 'rgba(255, 255, 255, 0.1)',
+		mobileUserPageName: '#fff',
+		mobileUserPageAcct: '$text',
+		mobileUserPageDescription: '$text',
+		mobileUserPageFollowedBg: 'rgba(0, 0, 0, 0.3)',
+		mobileUserPageFollowedFg: '$text',
+		mobileUserPageStatusHighlight: '#fff',
+		mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.3)',
+		mobileAnnouncement: 'rgba(30, 129, 216, 0.2)',
+		mobileAnnouncementFg: '#fff',
+		mobileSignedInAsBg: '#273c34',
+		mobileSignedInAsFg: '#49ab63',
+		mobileSignoutBg: '#652222',
+		mobileSignoutFg: '#ff5f56',
+
+		reversiBannerGradientStart: '#45730e',
+		reversiBannerGradientEnd: '#464300',
+		reversiDescBg: 'rgba(255, 255, 255, 0.1)',
+		reversiListItemShadow: 'rgba(0, 0, 0, 0.7)',
+		reversiMapSelectBorder: 'rgba(255, 255, 255, 0.1)',
+		reversiMapSelectHoverBorder: 'rgba(255, 255, 255, 0.2)',
+		reversiRoomFormShadow: 'rgba(0, 0, 0, 0.7)',
+		reversiRoomFooterBg: ':alpha<0.9<$secondary',
+		reversiGameHeaderLine: ':alpha<0.5<$secondary',
+		reversiGameEmptyCell: ':lighten<2<$secondary',
+		reversiGameEmptyCellMyTurn: ':lighten<5<$secondary',
+		reversiGameEmptyCellCanPut: ':lighten<4<$secondary',
+	},
+}
diff --git a/src/client/theme/halloween.json5 b/src/client/theme/halloween.json5
new file mode 100644
index 0000000000..608105903a
--- /dev/null
+++ b/src/client/theme/halloween.json5
@@ -0,0 +1,21 @@
+{
+	id: '42e4f09b-67d5-498c-af7d-29faa54745b0',
+
+	name: 'Halloween',
+	author: 'syuilo',
+	desc: 'Hello, Happy Halloween!',
+
+	base: 'dark',
+
+	vars: {
+		primary: '#d67036',
+		secondary: '#1f1d30',
+		text: '#b1bee3',
+	},
+
+	props: {
+		renoteGradient: '#5d2d1a',
+		renoteText: '#ff6c00',
+		quoteBorder: '#c3631c',
+	},
+}
diff --git a/src/client/theme/light.json5 b/src/client/theme/light.json5
new file mode 100644
index 0000000000..9f17a63dda
--- /dev/null
+++ b/src/client/theme/light.json5
@@ -0,0 +1,209 @@
+{
+	id: 'light',
+
+	name: 'Light',
+	author: 'syuilo',
+	desc: 'Default light theme',
+	kind: 'light',
+
+	vars: {
+		primary: '#fb4e4e',
+		secondary: '#fff',
+		text: '#666',
+	},
+
+	props: {
+		primary: '$primary',
+		primaryForeground: '#fff',
+		secondary: '$secondary',
+		bg: ':darken<8<$secondary',
+		text: '$text',
+
+		scrollbarTrack: '#fff',
+		scrollbarHandle: '#00000033',
+		scrollbarHandleHover: '#00000066',
+
+		face: '$secondary',
+		faceText: '$text',
+		faceHeader: ':lighten<5<$secondary',
+		faceHeaderText: '$text',
+		faceDivider: 'rgba(0, 0, 0, 0.082)',
+		faceTextButton: ':alpha<0.7<$text',
+		faceTextButtonHover: ':alpha<0.7<:darken<7<$text',
+		faceTextButtonActive: ':alpha<0.7<:darken<10<$text',
+		faceClearButtonHover: 'rgba(0, 0, 0, 0.025)',
+		faceClearButtonActive: 'rgba(0, 0, 0, 0.05)',
+		popupBg: ':lighten<5<$secondary',
+		popupFg: '#586069',
+
+		subNoteBg: 'rgba(0, 0, 0, 0.01)',
+		subNoteText: ':alpha<0.7<$text',
+		renoteGradient: '#edfde2',
+		renoteText: '#9dbb00',
+		quoteBorder: '#c0dac6',
+		noteText: '$text',
+		noteHeaderName: ':darken<2<$text',
+		noteHeaderBadgeFg: '#aaa',
+		noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.05)',
+		noteHeaderAdminFg: '#f15f71',
+		noteHeaderAdminBg: '#ffdfdf',
+		noteHeaderAcct: ':alpha<0.7<@noteHeaderName',
+		noteHeaderInfo: ':alpha<0.7<@noteHeaderName',
+
+		noteActions: ':alpha<0.3<$text',
+		noteActionsHover: ':alpha<0.9<$text',
+		noteActionsReplyHover: '#0af',
+		noteActionsRenoteHover: '#8d0',
+		noteActionsReactionHover: '#fa0',
+		noteActionsHighlighted: '#888',
+
+		noteAttachedFile: 'rgba(0, 0, 0, 0.05)',
+
+		modalBackdrop: 'rgba(0, 0, 0, 0.1)',
+
+		dateDividerBg: ':darken<2<$secondary',
+		dateDividerFg: ':alpha<0.7<$text',
+
+		switchTrack: 'rgba(0, 0, 0, 0.25)',
+		radioBorder: 'rgba(0, 0, 0, 0.4)',
+		inputBorder: 'rgba(0, 0, 0, 0.42)',
+		inputLabel: 'rgba(0, 0, 0, 0.54)',
+		inputText: '#000',
+
+		buttonBg: 'rgba(0, 0, 0, 0.05)',
+		buttonHoverBg: 'rgba(0, 0, 0, 0.1)',
+		buttonActiveBg: 'rgba(0, 0, 0, 0.15)',
+
+		autocompleteItemHoverBg: 'rgba(0, 0, 0, 0.1)',
+		autocompleteItemText: 'rgba(0, 0, 0, 0.8)',
+		autocompleteItemTextSub: 'rgba(0, 0, 0, 0.3)',
+
+		cwButtonBg: '#b1b9c1',
+		cwButtonFg: '#fff',
+		cwButtonHoverBg: '#bbc4ce',
+
+		reactionPickerButtonHoverBg: '#eee',
+
+		reactionViewerBorder: 'rgba(0, 0, 0, 0.1)',
+
+		pollEditorInputBg: '#fff',
+
+		pollChoiceText: '#000',
+		pollChoiceBorder: 'rgba(0, 0, 0, 0.1)',
+
+		urlPreviewBorder: 'rgba(0, 0, 0, 0.1)',
+		urlPreviewBorderHover: 'rgba(0, 0, 0, 0.2)',
+		urlPreviewTitle: '$text',
+		urlPreviewText: ':alpha<0.7<$text',
+		urlPreviewInfo: ':alpha<0.8<$text',
+
+		calendarWeek: '#19a2a9',
+		calendarSaturdayOrSunday: '#ef95a0',
+		calendarDay: '$text',
+
+		materBg: 'rgba(0, 0, 0, 0.1)',
+
+		chartCaption: ':alpha<0.6<$text',
+
+		announcementsBg: '#f3f9ff',
+		announcementsTitle: '#4078c0',
+		announcementsText: '#57616f',
+
+		donationBg: '#fbead4',
+		donationFg: '#777d71',
+
+		googleSearchBg: '#fff',
+		googleSearchFg: '#55595c',
+		googleSearchBorder: 'rgba(0, 0, 0, 0.2)',
+		googleSearchHoverBorder: 'rgba(0, 0, 0, 0.3)',
+		googleSearchHoverButton: 'rgba(0, 0, 0, 0.05)',
+
+		mfmTitleBg: 'rgba(0, 0, 0, 0.07)',
+		mfmQuote: ':alpha<0.6<$text',
+		mfmQuoteLine: ':alpha<0.5<$text',
+
+		suspendedInfoBg: '#ffdbdb',
+		suspendedInfoFg: '#570808',
+		remoteInfoBg: '#fff0db',
+		remoteInfoFg: '#573c08',
+
+		messagingRoomBg: '#fff',
+		messagingRoomInfo: '#000',
+		messagingRoomDateDividerLine: 'rgba(0, 0, 0, 0.1)',
+		messagingRoomDateDividerText: 'rgba(0, 0, 0, 0.3)',
+		messagingRoomMessageInfo: 'rgba(0, 0, 0, 0.4)',
+		messagingRoomMessageBg: '#eee',
+		messagingRoomMessageFg: '#333',
+
+		formButtonBorder: 'rgba(0, 0, 0, 0.1)',
+		formButtonHoverBg: ':alpha<0.12<$primary',
+		formButtonHoverBorder: ':alpha<0.3<$primary',
+		formButtonActiveBg: ':alpha<0.12<$primary',
+
+		desktopHeaderBg: ':lighten<5<$secondary',
+		desktopHeaderFg: '$text',
+		desktopHeaderHoverFg: ':darken<7<$text',
+		desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.05)',
+		desktopHeaderSearchHoverBg: 'rgba(0, 0, 0, 0.08)',
+		desktopHeaderSearchFg: '#000',
+		desktopNotificationBg: ':alpha<0.9<$secondary',
+		desktopNotificationFg: ':alpha<0.7<$text',
+		desktopNotificationShadow: 'rgba(0, 0, 0, 0.2)',
+		desktopPostFormBg: ':lighten<33<$primary',
+		desktopPostFormTextareaBg: '#fff',
+		desktopPostFormTextareaFg: '#333',
+		desktopPostFormTransparentButtonFg: ':alpha<0.5<$primary',
+		desktopPostFormTransparentButtonActiveGradientStart: ':lighten<30<$primary',
+		desktopPostFormTransparentButtonActiveGradientEnd: ':lighten<33<$primary',
+		desktopRenoteFormFooter: ':lighten<33<$primary',
+		desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.08)',
+		desktopTimelineSrc: '$text',
+		desktopTimelineSrcHover: ':darken<7<$text',
+		desktopWindowTitle: '$text',
+		desktopWindowShadow: 'rgba(0, 0, 0, 0.2)',
+		desktopDriveBg: '#fff',
+		desktopDriveFolderBg: ':lighten<31<$primary',
+		desktopDriveFolderHoverBg: ':lighten<27<$primary',
+		desktopDriveFolderActiveBg: ':lighten<25<$primary',
+		desktopDriveFolderFg: ':darken<10<$primary',
+		desktopSettingsNavItem: ':alpha<0.8<$text',
+		desktopSettingsNavItemHover: ':darken<10<$text',
+
+		deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.1)',
+
+		mobileHeaderBg: ':lighten<5<$secondary',
+		mobileHeaderFg: '$text',
+		mobileNavBackdrop: 'rgba(0, 0, 0, 0.2)',
+		mobilePostFormDivider: 'rgba(0, 0, 0, 0.1)',
+		mobilePostFormTextareaBg: '#fff',
+		mobilePostFormButton: '$text',
+		mobileDriveNavBg: ':alpha<0.75<$secondary',
+		mobileHomeTlItemHover: 'rgba(0, 0, 0, 0.05)',
+		mobileUserPageName: '#757c82',
+		mobileUserPageAcct: '#969ea5',
+		mobileUserPageDescription: '#757c82',
+		mobileUserPageFollowedBg: '#a7bec7',
+		mobileUserPageFollowedFg: '#fff',
+		mobileUserPageStatusHighlight: '#787e86',
+		mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.07)',
+		mobileAnnouncement: 'rgba(155, 196, 232, 0.2)',
+		mobileAnnouncementFg: '#3f4967',
+		mobileSignedInAsBg: '#fcfff5',
+		mobileSignedInAsFg: '#2c662d',
+		mobileSignoutBg: '#fff6f5',
+		mobileSignoutFg: '#cc2727',
+
+		reversiBannerGradientStart: '#8bca3e',
+		reversiBannerGradientEnd: '#d6cf31',
+		reversiDescBg: 'rgba(0, 0, 0, 0.1)',
+		reversiListItemShadow: 'rgba(0, 0, 0, 0.15)',
+		reversiMapSelectBorder: 'rgba(0, 0, 0, 0.1)',
+		reversiMapSelectHoverBorder: 'rgba(0, 0, 0, 0.2)',
+		reversiRoomFormShadow: 'rgba(0, 0, 0, 0.1)',
+		reversiRoomFooterBg: ':alpha<0.9<$secondary',
+		reversiGameHeaderLine: '#c4cdd4',
+		reversiGameEmptyCell: 'rgba(0, 0, 0, 0.06)',
+		reversiGameEmptyCellMyTurn: 'rgba(0, 0, 0, 0.12)',
+		reversiGameEmptyCellCanPut: 'rgba(0, 0, 0, 0.9)',
+	},
+}
diff --git a/src/client/theme/pink.json5 b/src/client/theme/pink.json5
new file mode 100644
index 0000000000..71e963dc91
--- /dev/null
+++ b/src/client/theme/pink.json5
@@ -0,0 +1,20 @@
+{
+	id: 'e9c8c01d-9c15-48d0-9b5c-3d00843b5b36',
+
+	name: 'Lavender',
+	author: 'sokuyuku & syuilo',
+
+	base: 'light',
+
+	vars: {
+		primary: 'rgb(206, 147, 191)',
+		secondary: 'rgb(253, 242, 243)',
+		text: 'rgb(161, 139, 146)',
+	},
+
+	props: {
+		renoteGradient: '#f7e4ec',
+		renoteText: '$primary',
+		quoteBorder: '$primary',
+	},
+}
diff --git a/src/config/load.ts b/src/config/load.ts
index 8929cf8d3e..3a1bac3201 100644
--- a/src/config/load.ts
+++ b/src/config/load.ts
@@ -7,6 +7,7 @@ import { URL } from 'url';
 import * as yaml from 'js-yaml';
 import { Source, Mixin } from './types';
 import isUrl = require('is-url');
+const pkg = require('../../package.json');
 
 /**
  * Path of configuration directory
@@ -43,6 +44,7 @@ export default function load() {
 	mixin.stats_url = `${mixin.scheme}://${mixin.host}/stats`;
 	mixin.status_url = `${mixin.scheme}://${mixin.host}/status`;
 	mixin.drive_url = `${mixin.scheme}://${mixin.host}/files`;
+	mixin.user_agent = `Misskey/${pkg.version} (${config.url})`;
 
 	if (config.localDriveCapacityMb == null) config.localDriveCapacityMb = 256;
 	if (config.remoteDriveCapacityMb == null) config.remoteDriveCapacityMb = 8;
diff --git a/src/config/types.ts b/src/config/types.ts
index a1dc9a5bd4..003185accd 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -114,6 +114,7 @@ export type Mixin = {
 	status_url: string;
 	dev_url: string;
 	drive_url: string;
+	user_agent: string;
 };
 
 export type Config = Source & Mixin;
diff --git a/src/const.json b/src/const.json
index b93226b2d2..af9a22bce8 100644
--- a/src/const.json
+++ b/src/const.json
@@ -1,5 +1,5 @@
 {
 	"copyright": "Copyright (c) 2014-2018 syuilo",
-	"themeColor": "#f6584f",
+	"themeColor": "#fb4e4e",
 	"themeColorForeground": "#fff"
 }
diff --git a/src/daemons/notes-stats.ts b/src/daemons/notes-stats.ts
index 3d2c4820a6..bddb54cfa5 100644
--- a/src/daemons/notes-stats.ts
+++ b/src/daemons/notes-stats.ts
@@ -16,7 +16,7 @@ export default function() {
 	});
 
 	ev.on('requestNotesStatsLog', id => {
-		ev.emit('notesStatsLog:' + id, log.toArray());
+		ev.emit(`notesStatsLog:${id}`, log.toArray());
 	});
 
 	process.on('exit', code => {
diff --git a/src/daemons/server-stats.ts b/src/daemons/server-stats.ts
index 4a653f81f4..9bb43fe84e 100644
--- a/src/daemons/server-stats.ts
+++ b/src/daemons/server-stats.ts
@@ -16,7 +16,7 @@ export default function() {
 	const log = new Deque<any>();
 
 	ev.on('requestServerStatsLog', x => {
-		ev.emit('serverStatsLog:' + x.id, log.toArray().slice(0, x.length || 50));
+		ev.emit(`serverStatsLog:${x.id}`, log.toArray().slice(0, x.length || 50));
 	});
 
 	async function tick() {
diff --git a/src/db/elasticsearch.ts b/src/db/elasticsearch.ts
index 4acff40793..ee5769d1d4 100644
--- a/src/db/elasticsearch.ts
+++ b/src/db/elasticsearch.ts
@@ -4,6 +4,12 @@ import config from '../config';
 const index = {
 	settings: {
 		analysis: {
+			normalizer: {
+				lowercase_normalizer: {
+					type: 'custom',
+					filter: ['lowercase']
+				}
+			},
 			analyzer: {
 				bigram: {
 					tokenizer: 'bigram_tokenizer'
@@ -24,7 +30,8 @@ const index = {
 				text: {
 					type: 'text',
 					index: true,
-					analyzer: 'bigram'
+					analyzer: 'bigram',
+					normalizer: 'lowercase_normalizer'
 				}
 			}
 		}
diff --git a/src/docs/api/entities/note.yaml b/src/docs/api/entities/note.yaml
index cae9a53f82..6654be2b02 100644
--- a/src/docs/api/entities/note.yaml
+++ b/src/docs/api/entities/note.yaml
@@ -33,19 +33,19 @@ props:
       ja-JP: "投稿の本文"
       en-US: "The text of this note"
 
-  mediaIds:
+  fileIds:
     type: "id(DriveFile)[]"
     optional: true
     desc:
-      ja-JP: "添付されているメディアのID (なければレスポンスでは空配列)"
-      en-US: "The IDs of the attached media (empty array for response if no media is attached)"
+      ja-JP: "添付されているファイルのID (なければレスポンスでは空配列)"
+      en-US: "The IDs of the attached files (empty array for response if no files is attached)"
 
-  media:
+  files:
     type: "entity(DriveFile)[]"
     optional: true
     desc:
-      ja-JP: "添付されているメディア"
-      en-US: "The attached media"
+      ja-JP: "添付されているファイル"
+      en-US: "The attached files"
 
   userId:
     type: "id(User)"
diff --git a/src/docs/api/entities/user.yaml b/src/docs/api/entities/user.yaml
index c90b55ee88..e3755d8585 100644
--- a/src/docs/api/entities/user.yaml
+++ b/src/docs/api/entities/user.yaml
@@ -101,15 +101,15 @@ props:
       ja-JP: "投稿の数"
       en-US: "The number of the notes of this user"
 
-  pinnedNote:
-    type: "entity(Note)"
+  pinnedNotes:
+    type: "entity(Note)[]"
     optional: true
     desc:
       ja-JP: "ピン留めされた投稿"
       en-US: "The pinned note of this user"
 
-  pinnedNoteId:
-    type: "id(Note)"
+  pinnedNoteIds:
+    type: "id(Note)[]"
     optional: true
     desc:
       ja-JP: "ピン留めされた投稿のID"
diff --git a/src/docs/base.pug b/src/docs/base.pug
index 26f19ddf09..41eb80a64e 100644
--- a/src/docs/base.pug
+++ b/src/docs/base.pug
@@ -9,7 +9,7 @@ html(lang= lang)
 		link(rel="stylesheet" href="/docs/assets/style.css")
 		link(rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/default.min.css")
 		script(src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js")
-		link(rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" integrity="sha384-lKuwvrZot6UHsBSfcMvOkWwlCMgc0TaWr+30HWe3a4ltaBwTZhyTEggF5tJv8tbt" crossorigin="anonymous")
+		link(rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous")
 		block meta
 
 	body
diff --git a/src/docs/keyboard-shortcut.ja-JP.md b/src/docs/keyboard-shortcut.ja-JP.md
new file mode 100644
index 0000000000..264387242c
--- /dev/null
+++ b/src/docs/keyboard-shortcut.ja-JP.md
@@ -0,0 +1,97 @@
+# キーボードショートカット
+
+## グローバル
+これらのショートカットは基本的にどこでも使えます。
+<table>
+	<thead>
+		<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">P</kbd>, <kbd class="key">N</kbd></td><td>新規投稿</td><td><b>P</b>ost, <b>N</b>ew, <b>N</b>ote</td></tr>
+		<tr><td><kbd class="key">T</kbd></td><td>タイムラインの最も新しい投稿にフォーカス</td><td><b>T</b>imeline, <b>T</b>op</td></tr>
+		<tr><td><kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">N</kbd></kbd></td><td>通知を表示/隠す</td><td><b>N</b>otifications</td></tr>
+		<tr><td><kbd class="key">A</kbd>, <kbd class="key">M</kbd></td><td>アカウントメニューを表示/隠す</td><td><b>A</b>ccount, <b>M</b>y, <b>M</b>e, <b>M</b>enu</td></tr>
+		<tr><td><kbd class="key">D</kbd></td><td>ダークモード切り替え</td><td><b>D</b>ark</td></tr>
+		<tr><td><kbd class="key">Z</kbd></td><td>上部のバーを隠す</td><td><b>Z</b>en</td></tr>
+		<tr><td><kbd class="key">H</kbd>, <kbd class="key">?</kbd></td><td>ヘルプを表示</td><td><b>H</b>elp</td></tr>
+	</tbody>
+</table>
+
+## 投稿にフォーカスされた状態
+<table>
+	<thead>
+		<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">↑</kbd>, <kbd class="key">K</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>上の投稿にフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">↓</kbd>, <kbd class="key">J</kbd>, <kbd class="key">Tab</kbd></td><td>下の投稿にフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">←</kbd>, <kbd class="key">R</kbd></td><td>返信フォームを開く</td><td><b>R</b>eply</td></tr>
+		<tr><td><kbd class="key">→</kbd>, <kbd class="key">Q</kbd></td><td>Renoteフォームを開く</td><td><b>Q</b>uote</td></tr>
+		<tr><td><kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">→</kbd></kbd>, <kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">Q</kbd></kbd></td><td>即刻Renoteする(フォームを開かずに)</td><td>-</td></tr>
+		<tr><td><kbd class="key">E</kbd>, <kbd class="key">A</kbd>, <kbd class="key">+</kbd></td><td>リアクションフォームを開く</td><td><b>E</b>mote, re<b>A</b>ction</td></tr>
+		<tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションをする(対応については後述)</td><td>-</td></tr>
+		<tr><td><kbd class="key">M</kbd>, <kbd class="key">O</kbd></td><td>投稿に対するメニューを開く</td><td><b>M</b>ore, <b>O</b>ther</td></tr>
+		<tr><td><kbd class="key">S</kbd></td><td>CWで隠された部分を表示 or 隠す</td><td><b>S</b>how, <b>S</b>ee</td></tr>
+		<tr><td><kbd class="key">Esc</kbd></td><td>フォーカスを外す</td><td>-</td></tr>
+	</tbody>
+</table>
+
+## Renoteフォーム
+<table>
+	<thead>
+		<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">Enter</kbd></td><td>Renoteする</td><td>-</td></tr>
+		<tr><td><kbd class="key">Q</kbd></td><td>フォームを展開する</td><td><b>Q</b>uote</td></tr>
+		<tr><td><kbd class="key">Esc</kbd></td><td>フォームを閉じる</td><td>-</td></tr>
+	</tbody>
+</table>
+
+## リアクションフォーム
+デフォルトで「👍」にフォーカスが当たっている状態です。
+<table>
+	<thead>
+		<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">↑</kbd>, <kbd class="key">K</kbd></td><td>上のリアクションにフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">↓</kbd>, <kbd class="key">J</kbd></td><td>下のリアクションにフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">←</kbd>, <kbd class="key">H</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>左のリアクションにフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">→</kbd>, <kbd class="key">L</kbd>, <kbd class="key">Tab</kbd></td><td>右のリアクションにフォーカスを移動</td><td>-</td></tr>
+		<tr><td><kbd class="key">Enter</kbd>, <kbd class="key">Space</kbd>, <kbd class="key">+</kbd></td><td>リアクション確定</td><td>-</td></tr>
+		<tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションで確定(対応については後述)</td><td>-</td></tr>
+		<tr><td><kbd class="key">Esc</kbd></td><td>リアクションするのをやめる</td><td>-</td></tr>
+	</tbody>
+</table>
+
+## リアクションと数字キーの対応
+<table>
+	<thead>
+		<tr><th>数字キー</th><th>リアクション</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">1</kbd></td><td>👍</td></tr>
+		<tr><td><kbd class="key">2</kbd></td><td>❤️</td></tr>
+		<tr><td><kbd class="key">3</kbd></td><td>😆</td></tr>
+		<tr><td><kbd class="key">4</kbd></td><td>🤔</td></tr>
+		<tr><td><kbd class="key">5</kbd></td><td>😮</td></tr>
+		<tr><td><kbd class="key">6</kbd></td><td>🎉</td></tr>
+		<tr><td><kbd class="key">7</kbd></td><td>💢</td></tr>
+		<tr><td><kbd class="key">8</kbd></td><td>😥</td></tr>
+		<tr><td><kbd class="key">9</kbd></td><td>😇</td></tr>
+		<tr><td><kbd class="key">0</kbd></td><td>🍮 or 🍣</td></tr>
+	</tbody>
+</table>
+
+# 例
+<table>
+	<thead>
+		<tr><th>ショートカット</th><th>動作</th></tr>
+	</thead>
+	<tbody>
+		<tr><td><kbd class="key">t</kbd><kbd class="key">+</kbd><kbd class="key">+</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr>
+		<tr><td><kbd class="key">t</kbd><kbd class="key">1</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr>
+		<tr><td><kbd class="key">t</kbd><kbd class="key">0</kbd></td><td>タイムラインの最新の投稿に🍮する</td></tr>
+	</tbody>
+</table>
diff --git a/src/docs/stream.ja-JP.md b/src/docs/stream.ja-JP.md
index c720299932..a8b0eb0cdc 100644
--- a/src/docs/stream.ja-JP.md
+++ b/src/docs/stream.ja-JP.md
@@ -55,7 +55,7 @@ APIへリクエストすると、レスポンスがストリームから次の
 
 ```json
 {
-	type: 'api-res:xxxxxxxxxxxxxxxx',
+	type: 'api:xxxxxxxxxxxxxxxx',
 	body: {
 		...
 	}
@@ -95,7 +95,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま
 
 ```json
 {
-	type: 'note-updated',
+	type: 'noteUpdated',
 	body: {
 		note: {
 			...
@@ -108,7 +108,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま
 
 ---
 
-このように、投稿の情報が更新されると、`note-updated`イベントが流れてくるようになります。`note-updated`イベントが発生するのは、以下の場合です:
+このように、投稿の情報が更新されると、`noteUpdated`イベントが流れてくるようになります。`noteUpdated`イベントが発生するのは、以下の場合です:
 
 - 投稿にリアクションが付いた
 - 投稿に添付されたアンケートに投票がされた
@@ -153,7 +153,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま
 
 `body`プロパティの中に、投稿情報が含まれています。
 
-### `read_all_notifications`
+### `readAllNotifications`
 
 自分宛ての通知がすべて既読になったことを表すイベントです。このイベントを利用して、「通知があることを示すアイコン」のようなものをオフにしたりする等のケースが想定されます。
 
diff --git a/src/docs/style.styl b/src/docs/style.styl
index b01fe493ac..70d77b5499 100644
--- a/src/docs/style.styl
+++ b/src/docs/style.styl
@@ -128,3 +128,24 @@ pre
 	> code
 		display block
 		padding 16px
+
+kbd.group
+	display inline-block
+	padding 4px
+	background #fbfbfb
+	border 1px solid #d6d6d6
+	border-radius 4px
+	box-shadow 0 1px 1px rgba(0, 0, 0, 0.1)
+
+kbd.key
+	display inline-block
+	padding 6px 8px
+	background #fff
+	border solid 1px #cecece
+	border-radius 4px
+	box-shadow 0 1px 1px rgba(0, 0, 0, 0.1)
+
+td
+	> kbd.group,
+	> kbd.key
+		margin 4px
diff --git a/src/games/reversi/core.ts b/src/games/reversi/core.ts
index 92b7c3799c..e724917fbf 100644
--- a/src/games/reversi/core.ts
+++ b/src/games/reversi/core.ts
@@ -1,3 +1,5 @@
+import { count, concat } from "../../prelude/array";
+
 // MISSKEY REVERSI ENGINE
 
 /**
@@ -88,8 +90,8 @@ export default class Reversi {
 		//#endregion
 
 		// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
-		if (this.canPutSomewhere(BLACK).length == 0) {
-			if (this.canPutSomewhere(WHITE).length == 0) {
+		if (!this.canPutSomewhere(BLACK)) {
+			if (!this.canPutSomewhere(WHITE)) {
 				this.turn = null;
 			} else {
 				this.turn = WHITE;
@@ -101,14 +103,14 @@ export default class Reversi {
 	 * 黒石の数
 	 */
 	public get blackCount() {
-		return this.board.filter(x => x === BLACK).length;
+		return count(BLACK, this.board);
 	}
 
 	/**
 	 * 白石の数
 	 */
 	public get whiteCount() {
-		return this.board.filter(x => x === WHITE).length;
+		return count(WHITE, this.board);
 	}
 
 	/**
@@ -170,9 +172,9 @@ export default class Reversi {
 
 	private calcTurn() {
 		// ターン計算
-		if (this.canPutSomewhere(!this.prevColor).length > 0) {
+		if (this.canPutSomewhere(!this.prevColor)) {
 			this.turn = !this.prevColor;
-		} else if (this.canPutSomewhere(this.prevColor).length > 0) {
+		} else if (this.canPutSomewhere(this.prevColor)) {
 			this.turn = this.prevColor;
 		} else {
 			this.turn = null;
@@ -204,14 +206,15 @@ export default class Reversi {
 	/**
 	 * 打つことができる場所を取得します
 	 */
-	public canPutSomewhere(color: Color): number[] {
-		const result: number[] = [];
+	public puttablePlaces(color: Color): number[] {
+		return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
+	}
 
-		this.board.forEach((x, i) => {
-			if (this.canPut(color, i)) result.push(i);
-		});
-
-		return result;
+	/**
+	 * 打つことができる場所があるかどうかを取得します
+	 */
+	public canPutSomewhere(color: Color): boolean {
+		return this.puttablePlaces(color).length > 0;
 	}
 
 	/**
@@ -235,87 +238,55 @@ export default class Reversi {
 	/**
 	 * 指定のマスに石を置いた時の、反転させられる石を取得します
 	 * @param color 自分の色
-	 * @param pos 位置
+	 * @param initPos 位置
 	 */
-	public effects(color: Color, pos: number): number[] {
+	public effects(color: Color, initPos: number): number[] {
 		const enemyColor = !color;
 
-		// ひっくり返せる石(の位置)リスト
-		let stones: number[] = [];
+		const diffVectors: [number, number][] = [
+			[  0,  -1], // 上
+			[ +1,  -1], // 右上
+			[ +1,   0], // 右
+			[ +1,  +1], // 右下
+			[  0,  +1], // 下
+			[ -1,  +1], // 左下
+			[ -1,   0], // 左
+			[ -1,  -1]  // 左上
+		];
 
-		const initPos = pos;
-
-		// 走査
-		const iterate = (fn: (i: number) => number[]) => {
-			let i = 1;
-			const found = [];
+		const effectsInLine = ([dx, dy]: [number, number]): number[] => {
+			const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
 
+			const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
+			let [x, y] = this.transformPosToXy(initPos);
 			while (true) {
-				let [x, y] = fn(i);
+				[x, y] = nextPos(x, y);
 
 				// 座標が指し示す位置がボード外に出たとき
 				if (this.opts.loopedBoard) {
-					if (x <  0             ) x = this.mapWidth  - ((-x) % this.mapWidth);
-					if (y <  0             ) y = this.mapHeight - ((-y) % this.mapHeight);
-					if (x >= this.mapWidth ) x = x % this.mapWidth;
-					if (y >= this.mapHeight) y = y % this.mapHeight;
+					x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth;
+					y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight;
 
-					// for debug
-					//if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) {
-					//	console.log(x, y);
-					//}
-
-					// 一周して自分に帰ってきたら
 					if (this.transformXyToPos(x, y) == initPos) {
-						// ↓のコメントアウトを外すと、「現時点で自分の石が隣接していないが、
-						// そこに置いたとするとループして最終的に挟んだことになる」というケースを有効化します。(Test4のマップで違いが分かります)
-						// このケースを有効にした方が良いのか無効にした方が良いのか判断がつかなかったためとりあえず無効としておきます
-						// (あと無効な方がゲームとしておもしろそうだった)
-						stones = stones.concat(found);
-						break;
+						// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
+						return found;
 					}
 				} else {
-					if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) break;
+					if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) {
+						return []; // 挟めないことが確定 (盤面外に到達)
+					}
 				}
 
 				const pos = this.transformXyToPos(x, y);
-
-				//#region 「配置不能」マスに当たった場合走査終了
-				const pixel = this.mapDataGet(pos);
-				if (pixel == 'null') break;
-				//#endregion
-
-				// 石取得
+				if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
 				const stone = this.board[pos];
-
-				// 石が置かれていないマスなら走査終了
-				if (stone === null) break;
-
-				// 相手の石なら「ひっくり返せるかもリスト」に入れておく
-				if (stone === enemyColor) found.push(pos);
-
-				// 自分の石なら「ひっくり返せるかもリスト」を「ひっくり返せるリスト」に入れ、走査終了
-				if (stone === color) {
-					stones = stones.concat(found);
-					break;
-				}
-
-				i++;
+				if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
+				if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
+				if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
 			}
 		};
 
-		const [x, y] = this.transformPosToXy(pos);
-
-		iterate(i => [x    , y - i]); // 上
-		iterate(i => [x + i, y - i]); // 右上
-		iterate(i => [x + i, y    ]); // 右
-		iterate(i => [x + i, y + i]); // 右下
-		iterate(i => [x    , y + i]); // 下
-		iterate(i => [x - i, y + i]); // 左下
-		iterate(i => [x - i, y    ]); // 左
-		iterate(i => [x - i, y - i]); // 左上
-
-		return stones;
+		return concat(diffVectors.map(effectsInLine));
 	}
 
 	/**
diff --git a/src/index.ts b/src/index.ts
index 470699eab9..ed23ff7e72 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -20,7 +20,6 @@ import Logger from './misc/logger';
 import ProgressBar from './misc/cli/progressbar';
 import EnvironmentInfo from './misc/environmentInfo';
 import MachineInfo from './misc/machineInfo';
-import DependencyInfo from './misc/dependencyInfo';
 import serverStats from './daemons/server-stats';
 import notesStats from './daemons/notes-stats';
 import loadConfig from './config/load';
@@ -116,7 +115,6 @@ async function init(): Promise<Config> {
 	new Logger('Deps').info(`Node.js ${process.version}`);
 	MachineInfo.show();
 	EnvironmentInfo.show();
-	new DependencyInfo().showAll();
 
 	const configLogger = new Logger('Config');
 	let config;
diff --git a/src/mfm/html-to-mfm.ts b/src/mfm/html-to-mfm.ts
index daa228ec51..aa887c5560 100644
--- a/src/mfm/html-to-mfm.ts
+++ b/src/mfm/html-to-mfm.ts
@@ -1,4 +1,5 @@
 const parse5 = require('parse5');
+import { URL } from 'url';
 
 export default function(html: string): string {
 	if (html == null) return null;
@@ -33,26 +34,27 @@ export default function(html: string): string {
 
 			case 'a':
 				const txt = getText(node);
+				const rel = node.attrs.find((x: any) => x.name == 'rel');
+				const href = node.attrs.find((x: any) => x.name == 'href');
 
+				// ハッシュタグ / hrefがない / txtがURL
+				if ((rel && rel.value.match('tag') !== null) || !href || href.value == txt) {
+					text += txt;
 				// メンション
-				if (txt.startsWith('@')) {
+				} else if (txt.startsWith('@')) {
 					const part = txt.split('@');
 
 					if (part.length == 2) {
 						//#region ホスト名部分が省略されているので復元する
-						const href = new URL(node.attrs.find((x: any) => x.name == 'href').value);
-						const acct = txt + '@' + href.hostname;
+						const acct = `${txt}@${(new URL(href.value)).hostname}`;
 						text += acct;
-						break;
 						//#endregion
 					} else if (part.length == 3) {
 						text += txt;
-						break;
 					}
-				}
-
-				if (node.childNodes) {
-					node.childNodes.forEach((n: any) => analyze(n));
+				// その他
+				} else {
+					text += `[${txt}](${href.value})`;
 				}
 				break;
 
diff --git a/src/mfm/html.ts b/src/mfm/html.ts
index c798ee410a..df9959dc4b 100644
--- a/src/mfm/html.ts
+++ b/src/mfm/html.ts
@@ -4,10 +4,7 @@ const { JSDOM } = jsdom;
 import config from '../config';
 import { INote } from '../models/note';
 import { TextElement } from './parse';
-
-function intersperse<T>(sep: T, xs: T[]): T[] {
-	return [].concat(...xs.map(x => [sep, x])).slice(1);
-}
+import { intersperse } from '../prelude/array';
 
 const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = {
 	bold({ document }, { bold }) {
@@ -44,8 +41,8 @@ const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers:
 
 	hashtag({ document }, { hashtag }) {
 		const a = document.createElement('a');
-		a.href = config.url + '/tags/' + hashtag;
-		a.textContent = '#' + hashtag;
+		a.href = `${config.url}/tags/${hashtag}`;
+		a.textContent = `#${hashtag}`;
 		a.setAttribute('rel', 'tag');
 		document.body.appendChild(a);
 	},
@@ -85,8 +82,12 @@ const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers:
 
 	text({ document }, { content }) {
 		const nodes = (content as string).split('\n').map(x => document.createTextNode(x));
-		for (const x of intersperse(document.createElement('br'), nodes)) {
-			document.body.appendChild(x);
+		for (const x of intersperse('br', nodes)) {
+			if (x === 'br') {
+				document.body.appendChild(document.createElement('br'));
+			} else {
+				document.body.appendChild(x);
+			}
 		}
 	},
 
diff --git a/src/mfm/parse/core/syntax-highlighter.ts b/src/mfm/parse/core/syntax-highlighter.ts
index 2b13608d2b..83aac89f1b 100644
--- a/src/mfm/parse/core/syntax-highlighter.ts
+++ b/src/mfm/parse/core/syntax-highlighter.ts
@@ -1,3 +1,5 @@
+import { capitalize, toUpperCase } from "../../../prelude/string";
+
 function escape(text: string) {
 	return text
 		.replace(/>/g, '&gt;')
@@ -89,8 +91,8 @@ const _keywords = [
 ];
 
 const keywords = _keywords
-	.concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1)))
-	.concat(_keywords.map(k => k.toUpperCase()))
+	.concat(_keywords.map(capitalize))
+	.concat(_keywords.map(toUpperCase))
 	.sort((a, b) => b.length - a.length);
 
 const symbols = [
diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts
index f4b6a78fa8..339026228a 100644
--- a/src/mfm/parse/elements/hashtag.ts
+++ b/src/mfm/parse/elements/hashtag.ts
@@ -9,9 +9,9 @@ export type TextElementHashtag = {
 };
 
 export default function(text: string, i: number) {
-	if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null;
+	if (!(/^\s#[^\s\.,]+/.test(text) || (i == 0 && /^#[^\s\.,]+/.test(text)))) return null;
 	const isHead = text.startsWith('#');
-	const hashtag = text.match(/^\s?#[^\s]+/)[0];
+	const hashtag = text.match(/^\s?#[^\s\.,]+/)[0];
 	const res: any[] = !isHead ? [{
 		type: 'text',
 		content: text[0]
diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts
index ea99240d5f..994ce98ca8 100644
--- a/src/mfm/parse/elements/quote.ts
+++ b/src/mfm/parse/elements/quote.ts
@@ -8,13 +8,20 @@ export type TextElementQuote = {
 	quote: string
 };
 
-export default function(text: string) {
-	const match = text.match(/^"([\s\S]+?)\n"/);
+export default function(text: string, index: number) {
+	const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) ||
+		(index == 0 ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null);
+
 	if (!match) return null;
-	const quote = match[0];
+
+	const quote = match[1]
+		.split('\n')
+		.map(line => line.replace(/^>+/g, '').trim())
+		.join('\n');
+
 	return {
 		type: 'quote',
-		content: quote,
-		quote: match[1].trim(),
+		content: match[0],
+		quote: quote,
 	} as TextElementQuote;
 }
diff --git a/src/misc/dependencyInfo.ts b/src/misc/dependencyInfo.ts
deleted file mode 100644
index 09d2828222..0000000000
--- a/src/misc/dependencyInfo.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import Logger from './logger';
-import { execSync } from 'child_process';
-
-export default class {
-	private logger: Logger;
-
-	constructor() {
-		this.logger = new Logger('Deps');
-	}
-
-	public showAll(): void {
-		this.show('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version:? v(.*)\r?\n/));
-		this.show('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/));
-	}
-
-	public show(serviceName: string, command: string, transform: (x: string) => RegExpMatchArray): void {
-		try {
-			// ステータス0以外のときにexecSyncはstderrをコンソール上に出力してしまうので
-			// プロセスからのstderrをすべて無視するように stdio オプションをセット
-			const x = execSync(command, { stdio: ['pipe', 'pipe', 'ignore'] });
-			const ver = transform(x.toString());
-			if (ver != null) {
-				this.logger.succ(`${serviceName} ${ver[1]} found`);
-			} else {
-				this.logger.warn(`${serviceName} not found`);
-				this.logger.warn(`Regexp used for version check of ${serviceName} is probably messed up`);
-			}
-		} catch (e) {
-			this.logger.warn(`${serviceName} not found`);
-		}
-	}
-}
diff --git a/src/misc/fa.ts b/src/misc/fa.ts
index 8be06362c3..5405255ac7 100644
--- a/src/misc/fa.ts
+++ b/src/misc/fa.ts
@@ -2,12 +2,12 @@
  * Replace fontawesome symbols
  */
 
-import * as fontawesome from '@fortawesome/fontawesome';
-import regular from '@fortawesome/fontawesome-free-regular';
-import solid from '@fortawesome/fontawesome-free-solid';
-import brands from '@fortawesome/fontawesome-free-brands';
+import * as fontawesome from '@fortawesome/fontawesome-svg-core';
+import { far } from '@fortawesome/free-regular-svg-icons';
+import { fas } from '@fortawesome/free-solid-svg-icons';
+import { fab } from '@fortawesome/free-brands-svg-icons';
 
-fontawesome.library.add(regular, solid, brands);
+fontawesome.library.add(far, fas, fab);
 
 export const pattern = /%fa:(.+?)%/g;
 
@@ -26,7 +26,7 @@ export const replacement = (match: string, key: string) => {
 				arg == 'B' ? 'fab' :
 				'';
 		} else if (arg.startsWith('.')) {
-			classes.push('fa-' + arg.substr(1));
+			classes.push(`fa-${arg.substr(1)}`);
 		} else if (arg.startsWith('-')) {
 			transform = arg.substr(1).split('|').join(' ');
 		} else {
diff --git a/src/misc/get-note-summary.ts b/src/misc/get-note-summary.ts
index ec7c74cf9f..3c6f2dd3d6 100644
--- a/src/misc/get-note-summary.ts
+++ b/src/misc/get-note-summary.ts
@@ -16,9 +16,9 @@ const summarize = (note: any): string => {
 	// 本文
 	summary += note.text ? note.text : '';
 
-	// メディアが添付されているとき
-	if (note.media.length != 0) {
-		summary += ` (${note.media.length}つのメディア)`;
+	// ファイルが添付されているとき
+	if (note.files.length != 0) {
+		summary += ` (${note.files.length}つのファイル)`;
 	}
 
 	// 投票が添付されているとき
diff --git a/src/misc/is-quote.ts b/src/misc/is-quote.ts
index 420f03a489..a99b8f6434 100644
--- a/src/misc/is-quote.ts
+++ b/src/misc/is-quote.ts
@@ -1,5 +1,5 @@
 import { INote } from '../models/note';
 
 export default function(note: INote): boolean {
-	return note.renoteId != null && (note.text != null || note.poll != null || (note.mediaIds != null && note.mediaIds.length > 0));
+	return note.renoteId != null && (note.text != null || note.poll != null || (note.fileIds != null && note.fileIds.length > 0));
 }
diff --git a/src/misc/should-mute-this-note.ts b/src/misc/should-mute-this-note.ts
new file mode 100644
index 0000000000..663e60af6d
--- /dev/null
+++ b/src/misc/should-mute-this-note.ts
@@ -0,0 +1,21 @@
+import * as mongo from 'mongodb';
+
+function toString(id: any) {
+	return mongo.ObjectID.prototype.isPrototypeOf(id) ? (id as mongo.ObjectID).toHexString() : id;
+}
+
+export default function(note: any, mutedUserIds: string[]): boolean {
+	if (mutedUserIds.includes(toString(note.userId))) {
+		return true;
+	}
+
+	if (note.reply != null && mutedUserIds.includes(toString(note.reply.userId))) {
+		return true;
+	}
+
+	if (note.renote != null && mutedUserIds.includes(toString(note.renote.userId))) {
+		return true;
+	}
+
+	return false;
+}
diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts
index dbbc1f1cd5..0d0886ad0b 100644
--- a/src/models/drive-file.ts
+++ b/src/models/drive-file.ts
@@ -92,7 +92,7 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv
 
 	// このDriveFileを添付しているNoteをすべて削除
 	await Promise.all((
-		await Note.find({ mediaIds: d._id })
+		await Note.find({ fileIds: d._id })
 	).map(x => deleteNote(x)));
 
 	// このDriveFileを添付しているMessagingMessageをすべて削除
@@ -127,6 +127,15 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv
 	});
 }
 
+export const packMany = async (
+	files: any[],
+	options?: {
+		detail: boolean
+	}
+) => {
+	return (await Promise.all(files.map(f => pack(f, options)))).filter(x => x != null);
+};
+
 /**
  * Pack a drive file for API response
  */
@@ -155,7 +164,11 @@ export const pack = (
 		_file = deepcopy(file);
 	}
 
-	if (!_file) return reject('invalid file arg.');
+	// (データベースの欠損などで)ファイルがデータベース上に見つからなかったとき
+	if (_file == null) {
+		console.warn(`in packaging driveFile: driveFile not found on database: ${_file}`);
+		return resolve(null);
+	}
 
 	// rendered target
 	let _target: any = {};
@@ -193,5 +206,10 @@ export const pack = (
 		*/
 	}
 
+	delete _target.withoutChunks;
+	delete _target.storage;
+	delete _target.storageProps;
+	delete _target.isRemote;
+
 	resolve(_target);
 });
diff --git a/src/models/favorite.ts b/src/models/favorite.ts
index b2d2fc93e8..2c10674bcb 100644
--- a/src/models/favorite.ts
+++ b/src/models/favorite.ts
@@ -41,6 +41,13 @@ export async function deleteFavorite(favorite: string | mongo.ObjectID | IFavori
 	});
 }
 
+export const packMany = async (
+	favorites: any[],
+	me: any
+) => {
+	return (await Promise.all(favorites.map(f => pack(f, me)))).filter(x => x != null);
+};
+
 /**
  * Pack a favorite for API response
  */
@@ -70,5 +77,11 @@ export const pack = (
 	// Populate note
 	_favorite.note = await packNote(_favorite.noteId, me);
 
+	// (データベースの不具合などで)投稿が見つからなかったら
+	if (_favorite.note == null) {
+		console.warn(`in packaging favorite: note not found on database: ${_favorite.noteId}`);
+		return resolve(null);
+	}
+
 	resolve(_favorite);
 });
diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts
index f46abd506d..d778164de0 100644
--- a/src/models/messaging-message.ts
+++ b/src/models/messaging-message.ts
@@ -4,6 +4,7 @@ import { pack as packUser } from './user';
 import { pack as packFile } from './drive-file';
 import db from '../db/mongodb';
 import MessagingHistory, { deleteMessagingHistory } from './messaging-history';
+import { length } from 'stringz';
 
 const MessagingMessage = db.get<IMessagingMessage>('messagingMessages');
 export default MessagingMessage;
@@ -19,7 +20,7 @@ export interface IMessagingMessage {
 }
 
 export function isValidText(text: string): boolean {
-	return text.length <= 1000 && text.trim() != '';
+	return length(text.trim()) <= 1000 && text.trim() != '';
 }
 
 /**
diff --git a/src/models/meta.ts b/src/models/meta.ts
index aef0163dfe..3c0347485c 100644
--- a/src/models/meta.ts
+++ b/src/models/meta.ts
@@ -4,12 +4,15 @@ const Meta = db.get<IMeta>('meta');
 export default Meta;
 
 export type IMeta = {
-	broadcasts: any[];
-	stats: {
+	broadcasts?: any[];
+	stats?: {
 		notesCount: number;
 		originalNotesCount: number;
 		usersCount: number;
 		originalUsersCount: number;
 	};
-	disableRegistration: boolean;
+	disableRegistration?: boolean;
+	disableLocalTimeline?: boolean;
+	hidedTags?: string[];
+	bannerUrl?: string;
 };
diff --git a/src/models/note-unread.ts b/src/models/note-unread.ts
new file mode 100644
index 0000000000..62408d23b6
--- /dev/null
+++ b/src/models/note-unread.ts
@@ -0,0 +1,17 @@
+import * as mongo from 'mongodb';
+import db from '../db/mongodb';
+
+const NoteUnread = db.get<INoteUnread>('noteUnreads');
+NoteUnread.createIndex(['userId', 'noteId'], { unique: true });
+export default NoteUnread;
+
+export interface INoteUnread {
+	_id: mongo.ObjectID;
+	noteId: mongo.ObjectID;
+	userId: mongo.ObjectID;
+	isSpecified: boolean;
+
+	_note: {
+		userId: mongo.ObjectID;
+	};
+}
diff --git a/src/models/note.ts b/src/models/note.ts
index 9d2e23d901..6c16ab054b 100644
--- a/src/models/note.ts
+++ b/src/models/note.ts
@@ -2,11 +2,12 @@ import * as mongo from 'mongodb';
 const deepcopy = require('deepcopy');
 import rap from '@prezzemolo/rap';
 import db from '../db/mongodb';
+import { length } from 'stringz';
 import { IUser, pack as packUser } from './user';
 import { pack as packApp } from './app';
 import PollVote, { deletePollVote } from './poll-vote';
 import Reaction, { deleteNoteReaction } from './note-reaction';
-import { pack as packFile } from './drive-file';
+import { packMany as packFileMany, IDriveFile } from './drive-file';
 import NoteWatching, { deleteNoteWatching } from './note-watching';
 import NoteReaction from './note-reaction';
 import Favorite, { deleteFavorite } from './favorite';
@@ -16,25 +17,29 @@ import Following from './following';
 const Note = db.get<INote>('notes');
 Note.createIndex('uri', { sparse: true, unique: true });
 Note.createIndex('userId');
+Note.createIndex('mentions');
+Note.createIndex('visibleUserIds');
 Note.createIndex('tagsLower');
+Note.createIndex('_files._id');
+Note.createIndex('_files.contentType');
 Note.createIndex({
 	createdAt: -1
 });
 export default Note;
 
 export function isValidText(text: string): boolean {
-	return text.length <= 1000 && text.trim() != '';
+	return length(text.trim()) <= 1000 && text.trim() != '';
 }
 
 export function isValidCw(text: string): boolean {
-	return text.length <= 100;
+	return length(text.trim()) <= 100;
 }
 
 export type INote = {
 	_id: mongo.ObjectID;
 	createdAt: Date;
 	deletedAt: Date;
-	mediaIds: mongo.ObjectID[];
+	fileIds: mongo.ObjectID[];
 	replyId: mongo.ObjectID;
 	renoteId: mongo.ObjectID;
 	poll: {
@@ -92,6 +97,7 @@ export type INote = {
 		inbox?: string;
 	};
 	_replyIds?: mongo.ObjectID[];
+	_files?: IDriveFile[];
 };
 
 /**
@@ -160,6 +166,76 @@ export async function deleteNote(note: string | mongo.ObjectID | INote) {
 	console.log(`Note: deleted ${n._id}`);
 }
 
+export const hideNote = async (packedNote: any, meId: mongo.ObjectID) => {
+	let hide = false;
+
+	// visibility が private かつ投稿者のIDが自分のIDではなかったら非表示
+	if (packedNote.visibility == 'private' && (meId == null || !meId.equals(packedNote.userId))) {
+		hide = true;
+	}
+
+	// visibility が specified かつ自分が指定されていなかったら非表示
+	if (packedNote.visibility == 'specified') {
+		if (meId == null) {
+			hide = true;
+		} else if (meId.equals(packedNote.userId)) {
+			hide = false;
+		} else {
+			// 指定されているかどうか
+			const specified = packedNote.visibleUserIds.some((id: any) => meId.equals(id));
+
+			if (specified) {
+				hide = false;
+			} else {
+				hide = true;
+			}
+		}
+	}
+
+	// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
+	if (packedNote.visibility == 'followers') {
+		if (meId == null) {
+			hide = true;
+		} else if (meId.equals(packedNote.userId)) {
+			hide = false;
+		} else {
+			// フォロワーかどうか
+			const following = await Following.findOne({
+				followeeId: packedNote.userId,
+				followerId: meId
+			});
+
+			if (following == null) {
+				hide = true;
+			} else {
+				hide = false;
+			}
+		}
+	}
+
+	if (hide) {
+		packedNote.fileIds = [];
+		packedNote.files = [];
+		packedNote.text = null;
+		packedNote.poll = null;
+		packedNote.cw = null;
+		packedNote.tags = [];
+		packedNote.geo = null;
+		packedNote.isHidden = true;
+	}
+};
+
+export const packMany = async (
+	notes: (string | mongo.ObjectID | INote)[],
+	me?: string | mongo.ObjectID | IUser,
+	options?: {
+		detail?: boolean;
+		skipHide?: boolean;
+	}
+) => {
+	return (await Promise.all(notes.map(n => pack(n, me, options)))).filter(x => x != null);
+};
+
 /**
  * Pack a note for API response
  *
@@ -172,11 +248,13 @@ export const pack = async (
 	note: string | mongo.ObjectID | INote,
 	me?: string | mongo.ObjectID | IUser,
 	options?: {
-		detail: boolean
+		detail?: boolean;
+		skipHide?: boolean;
 	}
 ) => {
 	const opts = Object.assign({
-		detail: true
+		detail: true,
+		skipHide: false
 	}, options);
 
 	// Me
@@ -203,52 +281,10 @@ export const pack = async (
 		_note = deepcopy(note);
 	}
 
-	if (!_note) throw `invalid note arg ${note}`;
-
-	let hide = false;
-
-	// visibility が private かつ投稿者のIDが自分のIDではなかったら非表示
-	if (_note.visibility == 'private' && (meId == null || !meId.equals(_note.userId))) {
-		hide = true;
-	}
-
-	// visibility が specified かつ自分が指定されていなかったら非表示
-	if (_note.visibility == 'specified') {
-		if (meId == null) {
-			hide = true;
-		} else if (meId.equals(_note.userId)) {
-			hide = false;
-		} else {
-			// 指定されているかどうか
-			const specified = _note.visibleUserIds.some((id: mongo.ObjectID) => id.equals(meId));
-
-			if (specified) {
-				hide = false;
-			} else {
-				hide = true;
-			}
-		}
-	}
-
-	// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
-	if (_note.visibility == 'followers') {
-		if (meId == null) {
-			hide = true;
-		} else if (meId.equals(_note.userId)) {
-			hide = false;
-		} else {
-			// フォロワーかどうか
-			const following = await Following.findOne({
-				followeeId: _note.userId,
-				followerId: meId
-			});
-
-			if (following == null) {
-				hide = true;
-			} else {
-				hide = false;
-			}
-		}
+	// 投稿がデータベース上に見つからなかったとき
+	if (_note == null) {
+		console.warn(`note not found on database: ${note}`);
+		return null;
 	}
 
 	const id = _note._id;
@@ -257,10 +293,13 @@ export const pack = async (
 	_note.id = _note._id;
 	delete _note._id;
 
+	delete _note.prev;
+	delete _note.next;
+	delete _note.tagsLower;
 	delete _note._user;
 	delete _note._reply;
-	delete _note.repost;
-	delete _note.mentions;
+	delete _note._renote;
+	delete _note._files;
 	if (_note.geo) delete _note.geo.type;
 
 	// Populate user
@@ -271,10 +310,12 @@ export const pack = async (
 		_note.app = packApp(_note.appId);
 	}
 
-	// Populate media
-	_note.media = hide ? [] : Promise.all(_note.mediaIds.map((fileId: mongo.ObjectID) =>
-		packFile(fileId)
-	));
+	// Populate files
+	_note.files = packFileMany(_note.fileIds || []);
+
+	// 後方互換性のため
+	_note.mediaIds = _note.fileIds;
+	_note.media = _note.files;
 
 	// When requested a detailed note data
 	if (opts.detail) {
@@ -298,7 +339,7 @@ export const pack = async (
 		}
 
 		// Poll
-		if (meId && _note.poll && !hide) {
+		if (meId && _note.poll) {
 			_note.poll = (async poll => {
 				const vote = await PollVote
 					.findOne({
@@ -339,19 +380,18 @@ export const pack = async (
 	// resolve promises in _note object
 	_note = await rap(_note);
 
+	// (データベースの欠損などで)ユーザーがデータベース上に見つからなかったとき
+	if (_note.user == null) {
+		console.warn(`in packaging note: note user not found on database: note(${_note.id})`);
+		return null;
+	}
+
 	if (_note.user.isCat && _note.text) {
 		_note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ');
 	}
 
-	if (hide) {
-		_note.mediaIds = [];
-		_note.text = null;
-		_note.poll = null;
-		_note.cw = null;
-		_note.tags = [];
-		_note.tagsLower = [];
-		_note.geo = null;
-		_note.isHidden = true;
+	if (!opts.skipHide) {
+		await hideNote(_note, meId);
 	}
 
 	return _note;
diff --git a/src/models/notification.ts b/src/models/notification.ts
index 835c89cd56..57be4bef10 100644
--- a/src/models/notification.ts
+++ b/src/models/notification.ts
@@ -77,6 +77,12 @@ export async function deleteNotification(notification: string | mongo.ObjectID |
 	});
 }
 
+export const packMany = async (
+	notifications: any[]
+) => {
+	return (await Promise.all(notifications.map(n => pack(n)))).filter(x => x != null);
+};
+
 /**
  * Pack a notification for API response
  */
@@ -123,6 +129,12 @@ export const pack = (notification: any) => new Promise<any>(async (resolve, reje
 		case 'poll_vote':
 			// Populate note
 			_notification.note = await packNote(_notification.noteId, me);
+
+			// (データベースの不具合などで)投稿が見つからなかったら
+			if (_notification.note == null) {
+				console.warn(`in packaging notification: note not found on database: ${_notification.noteId}`);
+				return resolve(null);
+			}
 			break;
 		default:
 			console.error(`Unknown type: ${_notification.type}`);
diff --git a/src/models/stats.ts b/src/models/stats.ts
index 326bfacc80..492784555e 100644
--- a/src/models/stats.ts
+++ b/src/models/stats.ts
@@ -2,7 +2,7 @@ import * as mongo from 'mongodb';
 import db from '../db/mongodb';
 
 const Stats = db.get<IStats>('stats');
-Stats.dropIndex({ date: -1 }); // 後方互換性のため
+
 Stats.createIndex({ span: -1, date: -1 }, { unique: true });
 export default Stats;
 
@@ -199,4 +199,30 @@ export interface IStats {
 			decSize: number;
 		};
 	};
+
+	/**
+	 * ネットワークに関する統計
+	 */
+	network: {
+		/**
+		 * サーバーへのリクエスト数
+		 */
+		requests: number;
+
+		/**
+		 * 応答時間の合計
+		 * TIP: (totalTime / requests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる
+		 */
+		totalTime: number;
+
+		/**
+		 * 合計受信データ量
+		 */
+		incomingBytes: number;
+
+		/**
+		 * 合計送信データ量
+		 */
+		outgoingBytes: number;
+	};
 }
diff --git a/src/models/user.ts b/src/models/user.ts
index 31d09bc8f8..e0ce561421 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -3,7 +3,7 @@ const deepcopy = require('deepcopy');
 const sequential = require('promise-sequential');
 import rap from '@prezzemolo/rap';
 import db from '../db/mongodb';
-import Note, { pack as packNote, deleteNote } from './note';
+import Note, { packMany as packNoteMany, deleteNote } from './note';
 import Following, { deleteFollowing } from './following';
 import Mute, { deleteMute } from './mute';
 import { getFriendIds } from '../server/api/common/get-friends';
@@ -53,7 +53,7 @@ type IUserBase = {
 	wallpaperUrl?: string;
 	data: any;
 	description: string;
-	pinnedNoteId: mongo.ObjectID;
+	pinnedNoteIds: mongo.ObjectID[];
 
 	/**
 	 * 凍結されているか否か
@@ -102,7 +102,10 @@ export interface ILocalUser extends IUserBase {
 	twoFactorEnabled: boolean;
 	twoFactorTempSecret?: string;
 	clientSettings: any;
-	settings: any;
+	settings: {
+		autoWatch: boolean;
+		alwaysMarkNsfw?: boolean;
+	};
 	hasUnreadNotification: boolean;
 	hasUnreadMessagingMessage: boolean;
 }
@@ -110,6 +113,7 @@ export interface ILocalUser extends IUserBase {
 export interface IRemoteUser extends IUserBase {
 	inbox: string;
 	sharedInbox?: string;
+	featured?: string;
 	endpoints: string[];
 	uri: string;
 	url?: string;
@@ -323,7 +327,8 @@ export const pack = (
 	me?: string | mongo.ObjectID | IUser,
 	options?: {
 		detail?: boolean,
-		includeSecrets?: boolean
+		includeSecrets?: boolean,
+		includeHasUnreadNotes?: boolean
 	}
 ) => new Promise<any>(async (resolve, reject) => {
 
@@ -356,9 +361,11 @@ export const pack = (
 		_user = deepcopy(user);
 	}
 
-	// TODO: ここでエラーにするのではなくダミーのユーザーデータを返す
-	// SEE: https://github.com/syuilo/misskey/issues/1432
-	if (!_user) return reject('invalid user arg.');
+	// (データベースの欠損などで)ユーザーがデータベース上に見つからなかったとき
+	if (_user == null) {
+		console.warn(`user not found on database: ${user}`);
+		return resolve(null);
+	}
 
 	// Me
 	const meId: mongo.ObjectID = me
@@ -432,10 +439,10 @@ export const pack = (
 				followerId: _user.id,
 				followeeId: meId
 			}),
-			_user.isLocked ? FollowRequest.findOne({
+			FollowRequest.findOne({
 				followerId: meId,
 				followeeId: _user.id
-			}) : Promise.resolve(null),
+			}),
 			FollowRequest.findOne({
 				followerId: _user.id,
 				followeeId: meId
@@ -461,9 +468,9 @@ export const pack = (
 	}
 
 	if (opts.detail) {
-		if (_user.pinnedNoteId) {
-			// Populate pinned note
-			_user.pinnedNote = packNote(_user.pinnedNoteId, meId, {
+		if (_user.pinnedNoteIds) {
+			// Populate pinned notes
+			_user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, meId, {
 				detail: true
 			});
 		}
@@ -485,6 +492,11 @@ export const pack = (
 		}
 	}
 
+	if (!opts.includeHasUnreadNotes) {
+		delete _user.hasUnreadSpecifiedNotes;
+		delete _user.hasUnreadMentions;
+	}
+
 	// resolve promises in _user object
 	_user = await rap(_user);
 
diff --git a/src/notify.ts b/src/notify.ts
index ea7423655e..522f4c52dd 100644
--- a/src/notify.ts
+++ b/src/notify.ts
@@ -2,7 +2,7 @@ import * as mongo from 'mongodb';
 import Notification from './models/notification';
 import Mute from './models/mute';
 import { pack } from './models/notification';
-import { publishUserStream } from './stream';
+import { publishMainStream } from './stream';
 import User from './models/user';
 import pushSw from './push-sw';
 
@@ -30,7 +30,7 @@ export default (
 	const packed = await pack(notification);
 
 	// Publish notification event
-	publishUserStream(notifiee, 'notification', packed);
+	publishMainStream(notifiee, 'notification', packed);
 
 	// Update flag
 	User.update({ _id: notifiee }, {
@@ -39,7 +39,7 @@ export default (
 		}
 	});
 
-	// 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
+	// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
 	setTimeout(async () => {
 		const fresh = await Notification.findOne({ _id: notification._id }, { isRead: true });
 		if (!fresh.isRead) {
@@ -54,9 +54,9 @@ export default (
 			}
 			//#endregion
 
-			publishUserStream(notifiee, 'unread_notification', packed);
+			publishMainStream(notifiee, 'unreadNotification', packed);
 
 			pushSw(notifiee, 'notification', packed);
 		}
-	}, 3000);
+	}, 2000);
 });
diff --git a/src/prelude/README.md b/src/prelude/README.md
new file mode 100644
index 0000000000..bb728cfb1b
--- /dev/null
+++ b/src/prelude/README.md
@@ -0,0 +1,3 @@
+# Prelude
+このディレクトリのコードはJavaScriptの表現能力を補うためのコードです。
+Misskey固有の処理とは独立したコードの集まりですが、Misskeyのコードを読みやすくすることを目的としています。
diff --git a/src/prelude/array.ts b/src/prelude/array.ts
new file mode 100644
index 0000000000..54f7081712
--- /dev/null
+++ b/src/prelude/array.ts
@@ -0,0 +1,27 @@
+export function countIf<T>(f: (x: T) => boolean, xs: T[]): number {
+	return xs.filter(f).length;
+}
+
+export function count<T>(x: T, xs: T[]): number {
+	return countIf(y => x === y, xs);
+}
+
+export function concat<T>(xss: T[][]): T[] {
+	return ([] as T[]).concat(...xss);
+}
+
+export function intersperse<T>(sep: T, xs: T[]): T[] {
+	return concat(xs.map(x => [sep, x])).slice(1);
+}
+
+export function erase<T>(x: T, xs: T[]): T[] {
+	return xs.filter(y => x !== y);
+}
+
+export function unique<T>(xs: T[]): T[] {
+	return [...new Set(xs)];
+}
+
+export function sum(xs: number[]): number {
+	return xs.reduce((a, b) => a + b, 0);
+}
diff --git a/src/prelude/math.ts b/src/prelude/math.ts
new file mode 100644
index 0000000000..07b94bec30
--- /dev/null
+++ b/src/prelude/math.ts
@@ -0,0 +1,3 @@
+export function gcd(a: number, b: number): number {
+	return b === 0 ? a : gcd(b, a % b);
+}
diff --git a/src/prelude/string.ts b/src/prelude/string.ts
new file mode 100644
index 0000000000..cae776bc3d
--- /dev/null
+++ b/src/prelude/string.ts
@@ -0,0 +1,11 @@
+export function capitalize(s: string): string {
+	return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1));
+}
+
+export function toUpperCase(s: string): string {
+	return s.toUpperCase();
+}
+
+export function toLowerCase(s: string): string {
+	return s.toLowerCase();
+}
diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts
index e14a162105..621219fec6 100644
--- a/src/queue/processors/http/deliver.ts
+++ b/src/queue/processors/http/deliver.ts
@@ -7,19 +7,18 @@ export default async (job: bq.Job, done: any): Promise<void> => {
 		await request(job.data.user, job.data.to, job.data.content);
 		done();
 	} catch (res) {
-		if (res == null || !res.hasOwnProperty('statusCode')) {
-			console.warn(`deliver failed (unknown): ${res}`);
-			return done();
-		}
-
-		if (res.statusCode == null) return done();
-		if (res.statusCode >= 400 && res.statusCode < 500) {
-			// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
-			// 何回再送しても成功することはないということなのでエラーにはしないでおく
-			done();
+		if (res != null && res.hasOwnProperty('statusCode')) {
+			if (res.statusCode >= 400 && res.statusCode < 500) {
+				// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
+				// 何回再送しても成功することはないということなのでエラーにはしないでおく
+				done();
+			} else {
+				console.warn(`deliver failed: ${res.statusCode} ${res.statusMessage} to=${job.data.to}`);
+				done(res.statusMessage);
+			}
 		} else {
-			console.warn(`deliver failed: ${res.statusMessage}`);
-			done(res.statusMessage);
+			console.warn(`deliver failed: ${res} to=${job.data.to}`);
+			done();
 		}
 	}
 };
diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts
index c9c2fa72cb..8e6b3769de 100644
--- a/src/queue/processors/http/process-inbox.ts
+++ b/src/queue/processors/http/process-inbox.ts
@@ -5,7 +5,9 @@ const httpSignature = require('http-signature');
 import parseAcct from '../../../misc/acct/parse';
 import User, { IRemoteUser } from '../../../models/user';
 import perform from '../../../remote/activitypub/perform';
-import { resolvePerson } from '../../../remote/activitypub/models/person';
+import { resolvePerson, updatePerson } from '../../../remote/activitypub/models/person';
+import { toUnicode } from 'punycode';
+import { URL } from 'url';
 
 const log = debug('misskey:queue:inbox');
 
@@ -32,22 +34,51 @@ export default async (job: bq.Job, done: any): Promise<void> => {
 			return;
 		}
 
-		user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser;
-
-		// アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
-		if (user === null) {
-			user = await resolvePerson(activity.actor) as IRemoteUser;
+		// アクティビティ内のホストの検証
+		try {
+			ValidateActivity(activity, host);
+		} catch (e) {
+			console.warn(e.message);
+			done();
+			return;
 		}
+
+		user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser;
 	} else {
+		// アクティビティ内のホストの検証
+		const host = toUnicode(new URL(signature.keyId).hostname.toLowerCase());
+		try {
+			ValidateActivity(activity, host);
+		} catch (e) {
+			console.warn(e.message);
+			done();
+			return;
+		}
+
 		user = await User.findOne({
 			host: { $ne: null },
 			'publicKey.id': signature.keyId
 		}) as IRemoteUser;
+	}
 
-		// アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
-		if (user === null) {
-			user = await resolvePerson(activity.actor) as IRemoteUser;
+	// Update activityの場合は、ここで署名検証/更新処理まで実施して終了
+	if (activity.type === 'Update') {
+		if (activity.object && activity.object.type === 'Person') {
+			if (user == null) {
+				console.warn('Update activity received, but user not registed.');
+			} else if (!httpSignature.verifySignature(signature, user.publicKey.publicKeyPem)) {
+				console.warn('Update activity received, but signature verification failed.');
+			} else {
+				updatePerson(activity.actor, null, activity.object);
+			}
 		}
+		done();
+		return;
+	}
+
+	// アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
+	if (user === null) {
+		user = await resolvePerson(activity.actor) as IRemoteUser;
 	}
 
 	if (user === null) {
@@ -69,3 +100,40 @@ export default async (job: bq.Job, done: any): Promise<void> => {
 		done(e);
 	}
 };
+
+/**
+ * Validate host in activity
+ * @param activity Activity
+ * @param host Expect host
+ */
+function ValidateActivity(activity: any, host: string) {
+	// id (if exists)
+	if (typeof activity.id === 'string') {
+		const uriHost = toUnicode(new URL(activity.id).hostname.toLowerCase());
+		if (host !== uriHost) {
+			const diag = activity.signature ? '. Has LD-Signature. Forwarded?' : '';
+			throw new Error(`activity.id(${activity.id}) has different host(${host})${diag}`);
+		}
+	}
+
+	// actor (if exists)
+	if (typeof activity.actor === 'string') {
+		const uriHost = toUnicode(new URL(activity.actor).hostname.toLowerCase());
+		if (host !== uriHost) throw new Error('activity.actor has different host');
+	}
+
+	// For Create activity
+	if (activity.type === 'Create' && activity.object) {
+		// object.id (if exists)
+		if (typeof activity.object.id === 'string') {
+			const uriHost = toUnicode(new URL(activity.object.id).hostname.toLowerCase());
+			if (host !== uriHost) throw new Error('activity.object.id has different host');
+		}
+
+		// object.attributedTo (if exists)
+		if (typeof activity.object.attributedTo === 'string') {
+			const uriHost = toUnicode(new URL(activity.object.attributedTo).hostname.toLowerCase());
+			if (host !== uriHost) throw new Error('activity.object.attributedTo has different host');
+		}
+	}
+}
diff --git a/src/remote/activitypub/kernel/add/index.ts b/src/remote/activitypub/kernel/add/index.ts
new file mode 100644
index 0000000000..eb2dba5b21
--- /dev/null
+++ b/src/remote/activitypub/kernel/add/index.ts
@@ -0,0 +1,22 @@
+import { IRemoteUser } from '../../../../models/user';
+import { IAdd } from '../../type';
+import { resolveNote } from '../../models/note';
+import { addPinned } from '../../../../services/i/pin';
+
+export default async (actor: IRemoteUser, activity: IAdd): Promise<void> => {
+	if ('actor' in activity && actor.uri !== activity.actor) {
+		throw new Error('invalid actor');
+	}
+
+	if (activity.target == null) {
+		throw new Error('target is null');
+	}
+
+	if (activity.target === actor.featured) {
+		const note = await resolveNote(activity.object);
+		await addPinned(actor, note._id);
+		return;
+	}
+
+	throw new Error(`unknown target: ${activity.target}`);
+};
diff --git a/src/remote/activitypub/kernel/index.ts b/src/remote/activitypub/kernel/index.ts
index 752a9bd2e2..52b0efc730 100644
--- a/src/remote/activitypub/kernel/index.ts
+++ b/src/remote/activitypub/kernel/index.ts
@@ -8,6 +8,8 @@ import like from './like';
 import announce from './announce';
 import accept from './accept';
 import reject from './reject';
+import add from './add';
+import remove from './remove';
 
 const self = async (actor: IRemoteUser, activity: Object): Promise<void> => {
 	switch (activity.type) {
@@ -31,6 +33,14 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => {
 		await reject(actor, activity);
 		break;
 
+	case 'Add':
+		await add(actor, activity).catch(err => console.log(err));
+		break;
+
+	case 'Remove':
+		await remove(actor, activity).catch(err => console.log(err));
+		break;
+
 	case 'Announce':
 		await announce(actor, activity);
 		break;
diff --git a/src/remote/activitypub/kernel/remove/index.ts b/src/remote/activitypub/kernel/remove/index.ts
new file mode 100644
index 0000000000..91b207c80d
--- /dev/null
+++ b/src/remote/activitypub/kernel/remove/index.ts
@@ -0,0 +1,22 @@
+import { IRemoteUser } from '../../../../models/user';
+import { IRemove } from '../../type';
+import { resolveNote } from '../../models/note';
+import { removePinned } from '../../../../services/i/pin';
+
+export default async (actor: IRemoteUser, activity: IRemove): Promise<void> => {
+	if ('actor' in activity && actor.uri !== activity.actor) {
+		throw new Error('invalid actor');
+	}
+
+	if (activity.target == null) {
+		throw new Error('target is null');
+	}
+
+	if (activity.target === actor.featured) {
+		const note = await resolveNote(activity.object);
+		await removePinned(actor, note._id);
+		return;
+	}
+
+	throw new Error(`unknown target: ${activity.target}`);
+};
diff --git a/src/remote/activitypub/misc/get-note-html.ts b/src/remote/activitypub/misc/get-note-html.ts
index 8df440930b..0a607bd48c 100644
--- a/src/remote/activitypub/misc/get-note-html.ts
+++ b/src/remote/activitypub/misc/get-note-html.ts
@@ -1,23 +1,10 @@
 import { INote } from '../../../models/note';
 import toHtml from '../../../mfm/html';
 import parse from '../../../mfm/parse';
-import config from '../../../config';
 
 export default function(note: INote) {
-	if (note.text == null) return null;
-
 	let html = toHtml(parse(note.text), note.mentionedRemoteUsers);
-
-	if (note.poll != null) {
-		const url = `${config.url}/notes/${note._id}`;
-		// TODO: i18n
-		html += `<p><a href="${url}">【Misskeyで投票を見る】</a></p>`;
-	}
-
-	if (note.renoteId != null) {
-		const url = `${config.url}/notes/${note.renoteId}`;
-		html += `<p>RE: <a href="${url}">${url}</a></p>`;
-	}
+	if (html == null) html = '';
 
 	return html;
 }
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index 1dfeebfdf7..d49cf53079 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -56,7 +56,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	log(`Creating the Note: ${note.id}`);
 
 	// 投稿者をフェッチ
-	const actor = await resolvePerson(note.attributedTo) as IRemoteUser;
+	const actor = await resolvePerson(note.attributedTo, null, resolver) as IRemoteUser;
 
 	// 投稿者が凍結されていたらスキップ
 	if (actor.isSuspended) {
@@ -73,16 +73,16 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 			visibility = 'followers';
 		} else {
 			visibility = 'specified';
-			visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri)));
+			visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver)));
 		}
 	}
 	//#endergion
 
-	// 添付メディア
+	// 添付ファイル
 	// TODO: attachmentは必ずしもImageではない
 	// TODO: attachmentは必ずしも配列ではない
 	// Noteがsensitiveなら添付もsensitiveにする
-	const media = note.attachment
+	const files = note.attachment
 		.map(attach => attach.sensitive = note.sensitive)
 		? await Promise.all(note.attachment.map(x => resolveImage(actor, x)))
 		: [];
@@ -91,7 +91,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null;
 
 	// テキストのパース
-	const text = htmlToMFM(note.content);
+	const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content);
 
 	// ユーザーの情報が古かったらついでに更新しておく
 	if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
@@ -100,7 +100,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 
 	return await post(actor, {
 		createdAt: new Date(note.published),
-		media,
+		files: files,
 		reply,
 		renote: undefined,
 		cw: note.summary,
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index 3bd4e16763..ee95e43ad3 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -3,15 +3,16 @@ import { toUnicode } from 'punycode';
 import * as debug from 'debug';
 
 import config from '../../../config';
-import User, { validateUsername, isValidName, IUser, IRemoteUser } from '../../../models/user';
+import User, { validateUsername, isValidName, IUser, IRemoteUser, isRemoteUser } from '../../../models/user';
 import Resolver from '../resolver';
 import { resolveImage } from './image';
-import { isCollectionOrOrderedCollection, IPerson } from '../type';
+import { isCollectionOrOrderedCollection, isCollection, IPerson } from '../type';
 import { IDriveFile } from '../../../models/drive-file';
 import Meta from '../../../models/meta';
 import htmlToMFM from '../../../mfm/html-to-mfm';
 import { updateUserStats } from '../../../services/update-chart';
 import { URL } from 'url';
+import { resolveNote } from './note';
 
 const log = debug('misskey:activitypub');
 
@@ -139,6 +140,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU
 			avatarId: null,
 			bannerId: null,
 			createdAt: Date.parse(person.published) || null,
+			updatedAt: new Date(),
 			description: htmlToMFM(person.summary),
 			followersCount,
 			followingCount,
@@ -154,6 +156,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU
 			},
 			inbox: person.inbox,
 			sharedInbox: person.sharedInbox,
+			featured: person.featured,
 			endpoints: person.endpoints,
 			uri: person.id,
 			url: person.url,
@@ -210,15 +213,18 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU
 	user.bannerUrl = bannerUrl;
 	//#endregion
 
+	await updateFeatured(user._id).catch(err => console.log(err));
 	return user;
 }
 
 /**
  * Personの情報を更新します。
- *
  * Misskeyに対象のPersonが登録されていなければ無視します。
+ * @param uri URI of Person
+ * @param resolver Resolver
+ * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
  */
-export async function updatePerson(uri: string, resolver?: Resolver): Promise<void> {
+export async function updatePerson(uri: string, resolver?: Resolver, hint?: object): Promise<void> {
 	if (typeof uri !== 'string') throw 'uri is not string';
 
 	// URIがこのサーバーを指しているならスキップ
@@ -236,7 +242,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo
 
 	if (resolver == null) resolver = new Resolver();
 
-	const object = await resolver.resolve(uri) as any;
+	const object = hint || await resolver.resolve(uri) as any;
 
 	const err = validatePerson(object, uri);
 
@@ -279,6 +285,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo
 			updatedAt: new Date(),
 			inbox: person.inbox,
 			sharedInbox: person.sharedInbox,
+			featured: person.featured,
 			avatarId: avatar ? avatar._id : null,
 			bannerId: banner ? banner._id : null,
 			avatarUrl: (avatar && avatar.metadata.thumbnailUrl) ? avatar.metadata.thumbnailUrl : (avatar && avatar.metadata.url) ? avatar.metadata.url : null,
@@ -290,9 +297,18 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo
 			name: person.name,
 			url: person.url,
 			endpoints: person.endpoints,
-			isCat: (person as any).isCat === true ? true : false
+			isBot: object.type == 'Service',
+			isCat: (person as any).isCat === true ? true : false,
+			isLocked: person.manuallyApprovesFollowers,
+			createdAt: Date.parse(person.published) || null,
+			publicKey: {
+				id: person.publicKey.id,
+				publicKeyPem: person.publicKey.publicKeyPem
+			},
 		}
 	});
+
+	await updateFeatured(exist._id).catch(err => console.log(err));
 }
 
 /**
@@ -301,7 +317,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo
  * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ
  * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
  */
-export async function resolvePerson(uri: string, verifier?: string): Promise<IUser> {
+export async function resolvePerson(uri: string, verifier?: string, resolver?: Resolver): Promise<IUser> {
 	if (typeof uri !== 'string') throw 'uri is not string';
 
 	//#region このサーバーに既に登録されていたらそれを返す
@@ -313,5 +329,37 @@ export async function resolvePerson(uri: string, verifier?: string): Promise<IUs
 	//#endregion
 
 	// リモートサーバーからフェッチしてきて登録
-	return await createPerson(uri);
+	if (resolver == null) resolver = new Resolver();
+	return await createPerson(uri, resolver);
+}
+
+export async function updateFeatured(userId: mongo.ObjectID) {
+	const user = await User.findOne({ _id: userId });
+	if (!isRemoteUser(user)) return;
+	if (!user.featured) return;
+
+	log(`Updating the featured: ${user.uri}`);
+
+	const resolver = new Resolver();
+
+	// Resolve to (Ordered)Collection Object
+	const collection = await resolver.resolveCollection(user.featured);
+	if (!isCollectionOrOrderedCollection(collection)) throw new Error(`Object is not Collection or OrderedCollection`);
+
+	// Resolve to Object(may be Note) arrays
+	const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
+	const items = await resolver.resolve(unresolvedItems);
+	if (!Array.isArray(items)) throw new Error(`Collection items is not an array`);
+
+	// Resolve and regist Notes
+	const featuredNotes = await Promise.all(items
+		.filter(item => item.type === 'Note')
+		.slice(0, 5)
+		.map(item => resolveNote(item, resolver)));
+
+	await User.update({ _id: user._id }, {
+		$set: {
+			pinnedNoteIds: featuredNotes.map(note => note._id)
+		}
+	});
 }
diff --git a/src/remote/activitypub/renderer/add.ts b/src/remote/activitypub/renderer/add.ts
new file mode 100644
index 0000000000..4d6fe392aa
--- /dev/null
+++ b/src/remote/activitypub/renderer/add.ts
@@ -0,0 +1,9 @@
+import config from '../../../config';
+import { ILocalUser } from '../../../models/user';
+
+export default (user: ILocalUser, target: any, object: any) => ({
+	type: 'Add',
+	actor: `${config.url}/users/${user._id}`,
+	target,
+	object
+});
diff --git a/src/remote/activitypub/renderer/announce.ts b/src/remote/activitypub/renderer/announce.ts
index f6276ade04..18e23cc336 100644
--- a/src/remote/activitypub/renderer/announce.ts
+++ b/src/remote/activitypub/renderer/announce.ts
@@ -5,7 +5,7 @@ export default (object: any, note: INote) => {
 	const attributedTo = `${config.url}/users/${note.userId}`;
 
 	return {
-		id: `${config.url}/notes/${note._id}`,
+		id: `${config.url}/notes/${note._id}/activity`,
 		actor: `${config.url}/users/${note.userId}`,
 		type: 'Announce',
 		published: note.createdAt.toISOString(),
diff --git a/src/remote/activitypub/renderer/hashtag.ts b/src/remote/activitypub/renderer/hashtag.ts
index a37ba63532..36563c2df5 100644
--- a/src/remote/activitypub/renderer/hashtag.ts
+++ b/src/remote/activitypub/renderer/hashtag.ts
@@ -3,5 +3,5 @@ import config from '../../../config';
 export default (tag: string) => ({
 	type: 'Hashtag',
 	href: `${config.url}/tags/${encodeURIComponent(tag)}`,
-	name: '#' + tag
+	name: `#${tag}`
 });
diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts
index 1d169d3088..b3ce1c03e4 100644
--- a/src/remote/activitypub/renderer/note.ts
+++ b/src/remote/activitypub/renderer/note.ts
@@ -6,10 +6,11 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file';
 import Note, { INote } from '../../../models/note';
 import User from '../../../models/user';
 import toHtml from '../misc/get-note-html';
+import parseMfm from '../../../mfm/parse';
 
 export default async function renderNote(note: INote, dive = true): Promise<any> {
-	const promisedFiles: Promise<IDriveFile[]> = note.mediaIds
-		? DriveFile.find({ _id: { $in: note.mediaIds } })
+	const promisedFiles: Promise<IDriveFile[]> = note.fileIds
+		? DriveFile.find({ _id: { $in: note.fileIds } })
 		: Promise.resolve([]);
 
 	let inReplyTo;
@@ -81,12 +82,39 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 
 	const files = await promisedFiles;
 
+	let text = note.text;
+
+	if (note.poll != null) {
+		if (text == null) text = '';
+		const url = `${config.url}/notes/${note._id}`;
+		// TODO: i18n
+		text += `\n\n[投票を見る](${url})`;
+	}
+
+	if (note.renoteId != null) {
+		if (text == null) text = '';
+		const url = `${config.url}/notes/${note.renoteId}`;
+		text += `\n\nRE: ${url}`;
+	}
+
+	// 省略されたメンションのホストを復元する
+	if (text != null) {
+		text = parseMfm(text).map(x => {
+			if (x.type == 'mention' && x.host == null) {
+				return `${x.content}@${config.host}`;
+			} else {
+				return x.content;
+			}
+		}).join('');
+	}
+
 	return {
 		id: `${config.url}/notes/${note._id}`,
 		type: 'Note',
 		attributedTo,
 		summary: note.cw,
-		content: toHtml(note),
+		content: toHtml(Object.assign({}, note, { text })),
+		_misskey_content: text,
 		published: note.createdAt.toISOString(),
 		to,
 		cc,
diff --git a/src/remote/activitypub/renderer/ordered-collection.ts b/src/remote/activitypub/renderer/ordered-collection.ts
index 3c448cf873..5461005983 100644
--- a/src/remote/activitypub/renderer/ordered-collection.ts
+++ b/src/remote/activitypub/renderer/ordered-collection.ts
@@ -4,8 +4,9 @@
  * @param totalItems Total number of items
  * @param first URL of first page (optional)
  * @param last URL of last page (optional)
+ * @param orderedItems attached objects (optional)
  */
-export default function(id: string, totalItems: any, first: string, last: string) {
+export default function(id: string, totalItems: any, first?: string, last?: string, orderedItems?: object) {
 	const page: any = {
 		id,
 		type: 'OrderedCollection',
@@ -14,6 +15,7 @@ export default function(id: string, totalItems: any, first: string, last: string
 
 	if (first) page.first = first;
 	if (last) page.last = last;
+	if (orderedItems) page.orderedItems = orderedItems;
 
 	return page;
 }
diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts
index 78918af368..52485e6959 100644
--- a/src/remote/activitypub/renderer/person.ts
+++ b/src/remote/activitypub/renderer/person.ts
@@ -21,6 +21,7 @@ export default async (user: ILocalUser) => {
 		outbox: `${id}/outbox`,
 		followers: `${id}/followers`,
 		following: `${id}/following`,
+		featured: `${id}/collections/featured`,
 		sharedInbox: `${config.url}/inbox`,
 		url: `${config.url}/@${user.username}`,
 		preferredUsername: user.username,
diff --git a/src/remote/activitypub/renderer/remove.ts b/src/remote/activitypub/renderer/remove.ts
new file mode 100644
index 0000000000..ed840be751
--- /dev/null
+++ b/src/remote/activitypub/renderer/remove.ts
@@ -0,0 +1,9 @@
+import config from '../../../config';
+import { ILocalUser } from '../../../models/user';
+
+export default (user: ILocalUser, target: any, object: any) => ({
+	type: 'Remove',
+	actor: `${config.url}/users/${user._id}`,
+	target,
+	object
+});
diff --git a/src/remote/activitypub/renderer/tombstone.ts b/src/remote/activitypub/renderer/tombstone.ts
new file mode 100644
index 0000000000..553406b93b
--- /dev/null
+++ b/src/remote/activitypub/renderer/tombstone.ts
@@ -0,0 +1,4 @@
+export default (id: string) => ({
+	id,
+	type: 'Tombstone'
+});
diff --git a/src/remote/activitypub/renderer/update.ts b/src/remote/activitypub/renderer/update.ts
new file mode 100644
index 0000000000..cf9acc9acb
--- /dev/null
+++ b/src/remote/activitypub/renderer/update.ts
@@ -0,0 +1,14 @@
+import config from '../../../config';
+import { ILocalUser } from '../../../models/user';
+
+export default (object: any, user: ILocalUser) => {
+	const activity = {
+		id: `${config.url}/users/${user._id}#updates/${new Date().getTime()}`,
+		actor: `${config.url}/users/${user._id}`,
+		type: 'Update',
+		to: [ 'https://www.w3.org/ns/activitystreams#Public' ],
+		object
+	} as any;
+
+	return activity;
+};
diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts
index 6238d3acb1..177b6f458e 100644
--- a/src/remote/activitypub/request.ts
+++ b/src/remote/activitypub/request.ts
@@ -2,6 +2,7 @@ import { request } from 'https';
 const { sign } = require('http-signature');
 import { URL } from 'url';
 import * as debug from 'debug';
+const crypto = require('crypto');
 
 import config from '../../config';
 import { ILocalUser } from '../../models/user';
@@ -11,22 +12,33 @@ const log = debug('misskey:activitypub:deliver');
 export default (user: ILocalUser, url: string, object: any) => new Promise((resolve, reject) => {
 	log(`--> ${url}`);
 
+	const timeout = 10 * 1000;
+
 	const { protocol, hostname, port, pathname, search } = new URL(url);
 
+	const data = JSON.stringify(object);
+
+	const sha256 = crypto.createHash('sha256');
+	sha256.update(data);
+	const hash = sha256.digest('base64');
+
 	const req = request({
 		protocol,
 		hostname,
 		port,
 		method: 'POST',
 		path: pathname + search,
+		timeout,
 		headers: {
-			'Content-Type': 'application/activity+json'
+			'User-Agent': config.user_agent,
+			'Content-Type': 'application/activity+json',
+			'Digest': `SHA-256=${hash}`
 		}
 	}, res => {
 		log(`${url} --> ${res.statusCode}`);
 
 		if (res.statusCode >= 400) {
-			reject();
+			reject(res);
 		} else {
 			resolve();
 		}
@@ -35,7 +47,8 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso
 	sign(req, {
 		authorizationHeaderName: 'Signature',
 		key: user.keypair,
-		keyId: `${config.url}/users/${user._id}/publickey`
+		keyId: `${config.url}/users/${user._id}/publickey`,
+		headers: ['date', 'host', 'digest']
 	});
 
 	// Signature: Signature ... => Signature: ...
@@ -43,5 +56,12 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso
 	sig = sig.replace(/^Signature /, '');
 	req.setHeader('Signature', sig);
 
-	req.end(JSON.stringify(object));
+	req.on('timeout', () => req.abort());
+
+	req.on('error', e => {
+		if (req.aborted) reject('timeout');
+		reject(e);
+	});
+
+	req.end(data);
 });
diff --git a/src/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts
index 0b053ca774..ff26971758 100644
--- a/src/remote/activitypub/resolver.ts
+++ b/src/remote/activitypub/resolver.ts
@@ -1,12 +1,13 @@
 import * as request from 'request-promise-native';
 import * as debug from 'debug';
 import { IObject } from './type';
-//import config from '../../config';
+import config from '../../config';
 
 const log = debug('misskey:activitypub:resolver');
 
 export default class Resolver {
 	private history: Set<string>;
+	private timeout = 10 * 1000;
 
 	constructor() {
 		this.history = new Set();
@@ -19,11 +20,11 @@ export default class Resolver {
 
 		switch (collection.type) {
 		case 'Collection':
-			collection.objects = collection.object.items;
+			collection.objects = collection.items;
 			break;
 
 		case 'OrderedCollection':
-			collection.objects = collection.object.orderedItems;
+			collection.objects = collection.orderedItems;
 			break;
 
 		default:
@@ -50,10 +51,14 @@ export default class Resolver {
 
 		const object = await request({
 			url: value,
+			timeout: this.timeout,
 			headers: {
+				'User-Agent': config.user_agent,
 				Accept: 'application/activity+json, application/ld+json'
 			},
 			json: true
+		}).catch(e => {
+			throw new Error(`request error: ${e.message}`);
 		});
 
 		if (object === null || (
diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts
index 3d40ad48cb..5c06ee4ffe 100644
--- a/src/remote/activitypub/type.ts
+++ b/src/remote/activitypub/type.ts
@@ -40,6 +40,7 @@ export interface IOrderedCollection extends IObject {
 
 export interface INote extends IObject {
 	type: 'Note';
+	_misskey_content: string;
 }
 
 export interface IPerson extends IObject {
@@ -52,6 +53,7 @@ export interface IPerson extends IObject {
 	publicKey: any;
 	followers: any;
 	following: any;
+	featured?: any;
 	outbox: any;
 	endpoints: string[];
 }
@@ -89,6 +91,14 @@ export interface IReject extends IActivity {
 	type: 'Reject';
 }
 
+export interface IAdd extends IActivity {
+	type: 'Add';
+}
+
+export interface IRemove extends IActivity {
+	type: 'Remove';
+}
+
 export interface ILike extends IActivity {
 	type: 'Like';
 	_misskey_reaction: string;
@@ -107,5 +117,7 @@ export type Object =
 	IFollow |
 	IAccept |
 	IReject |
+	IAdd |
+	IRemove |
 	ILike |
 	IAnnounce;
diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts
index 1007790ca6..adbc6639fa 100644
--- a/src/server/activitypub.ts
+++ b/src/server/activitypub.ts
@@ -10,9 +10,10 @@ import User, { isLocalUser, ILocalUser, IUser } from '../models/user';
 import renderNote from '../remote/activitypub/renderer/note';
 import renderKey from '../remote/activitypub/renderer/key';
 import renderPerson from '../remote/activitypub/renderer/person';
-import Outbox from './activitypub/outbox';
+import Outbox, { packActivity } from './activitypub/outbox';
 import Followers from './activitypub/followers';
 import Following from './activitypub/following';
+import Featured from './activitypub/featured';
 
 // Init router
 const router = new Router();
@@ -22,7 +23,7 @@ const router = new Router();
 function inbox(ctx: Router.IRouterContext) {
 	let signature;
 
-	ctx.req.headers.authorization = 'Signature ' + ctx.req.headers.signature;
+	ctx.req.headers.authorization = `Signature ${ctx.req.headers.signature}`;
 
 	try {
 		signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
@@ -74,6 +75,24 @@ router.get('/notes/:note', async (ctx, next) => {
 	}
 
 	ctx.body = pack(await renderNote(note, false));
+	ctx.set('Cache-Control', 'public, max-age=180');
+	setResponseType(ctx);
+});
+
+// note activity
+router.get('/notes/:note/activity', async ctx => {
+	const note = await Note.findOne({
+		_id: new mongo.ObjectID(ctx.params.note),
+		visibility: { $in: ['public', 'home'] }
+	});
+
+	if (note === null) {
+		ctx.status = 404;
+		return;
+	}
+
+	ctx.body = pack(await packActivity(note));
+	ctx.set('Cache-Control', 'public, max-age=180');
 	setResponseType(ctx);
 });
 
@@ -86,6 +105,9 @@ router.get('/users/:user/followers', Followers);
 // following
 router.get('/users/:user/following', Following);
 
+// featured
+router.get('/users/:user/collections/featured', Featured);
+
 // publickey
 router.get('/users/:user/publickey', async ctx => {
 	const userId = new mongo.ObjectID(ctx.params.user);
@@ -102,6 +124,7 @@ router.get('/users/:user/publickey', async ctx => {
 
 	if (isLocalUser(user)) {
 		ctx.body = pack(renderKey(user));
+		ctx.set('Cache-Control', 'public, max-age=180');
 		setResponseType(ctx);
 	} else {
 		ctx.status = 400;
@@ -116,6 +139,7 @@ async function userInfo(ctx: Router.IRouterContext, user: IUser) {
 	}
 
 	ctx.body = pack(await renderPerson(user as ILocalUser));
+	ctx.set('Cache-Control', 'public, max-age=180');
 	setResponseType(ctx);
 }
 
diff --git a/src/server/activitypub/featured.ts b/src/server/activitypub/featured.ts
new file mode 100644
index 0000000000..f400cc416f
--- /dev/null
+++ b/src/server/activitypub/featured.ts
@@ -0,0 +1,39 @@
+import * as mongo from 'mongodb';
+import * as Router from 'koa-router';
+import config from '../../config';
+import User from '../../models/user';
+import pack from '../../remote/activitypub/renderer';
+import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection';
+import { setResponseType } from '../activitypub';
+import Note from '../../models/note';
+import renderNote from '../../remote/activitypub/renderer/note';
+
+export default async (ctx: Router.IRouterContext) => {
+	const userId = new mongo.ObjectID(ctx.params.user);
+
+	// Verify user
+	const user = await User.findOne({
+		_id: userId,
+		host: null
+	});
+
+	if (user === null) {
+		ctx.status = 404;
+		return;
+	}
+
+	const pinnedNoteIds = user.pinnedNoteIds || [];
+
+	const pinnedNotes = await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id })));
+
+	const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note)));
+
+	const rendered = renderOrderedCollection(
+		`${config.url}/users/${userId}/collections/featured`,
+		renderedNotes.length, null, null, renderedNotes
+	);
+
+	ctx.body = pack(rendered);
+	ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
+	setResponseType(ctx);
+};
diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts
index eb58703443..fcc75fc5b1 100644
--- a/src/server/activitypub/followers.ts
+++ b/src/server/activitypub/followers.ts
@@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => {
 		// index page
 		const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`, null);
 		ctx.body = pack(rendered);
+		ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
 		setResponseType(ctx);
 	}
 };
diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts
index 80878fd4ca..2c739ff07d 100644
--- a/src/server/activitypub/following.ts
+++ b/src/server/activitypub/following.ts
@@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => {
 		// index page
 		const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`, null);
 		ctx.body = pack(rendered);
+		ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
 		setResponseType(ctx);
 	}
 };
diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts
index 37df190880..aeb6f25dd4 100644
--- a/src/server/activitypub/outbox.ts
+++ b/src/server/activitypub/outbox.ts
@@ -8,8 +8,11 @@ import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-c
 import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page';
 import { setResponseType } from '../activitypub';
 
-import Note from '../../models/note';
+import Note, { INote } from '../../models/note';
 import renderNote from '../../remote/activitypub/renderer/note';
+import renderCreate from '../../remote/activitypub/renderer/create';
+import renderAnnounce from '../../remote/activitypub/renderer/announce';
+import { countIf } from '../../prelude/array';
 
 export default async (ctx: Router.IRouterContext) => {
 	const userId = new mongo.ObjectID(ctx.params.user);
@@ -25,7 +28,7 @@ export default async (ctx: Router.IRouterContext) => {
 	const page: boolean = ctx.request.query.page === 'true';
 
 	// Validate parameters
-	if (sinceIdErr || untilIdErr || pageErr || [sinceId, untilId].filter(x => x != null).length > 1) {
+	if (sinceIdErr || untilIdErr || pageErr || countIf(x => x != null, [sinceId, untilId]) > 1) {
 		ctx.status = 400;
 		return;
 	}
@@ -52,15 +55,7 @@ export default async (ctx: Router.IRouterContext) => {
 
 		const query = {
 			userId: user._id,
-			$and: [{
-				$or: [ { visibility: 'public' }, { visibility: 'home' } ]
-			}, { // exclude renote, but include quote
-				$or: [{
-					text: { $ne: null }
-				}, {
-					mediaIds: { $ne: [] }
-				}]
-			}]
+			visibility: { $in: ['public', 'home'] }
 		} as any;
 
 		if (sinceId) {
@@ -84,15 +79,16 @@ export default async (ctx: Router.IRouterContext) => {
 
 		if (sinceId) notes.reverse();
 
-		const renderedNotes = await Promise.all(notes.map(note => renderNote(note, false)));
+		const activities = await Promise.all(notes.map(note => packActivity(note)));
 		const rendered = renderOrderedCollectionPage(
 			`${partOf}?page=true${sinceId ? `&since_id=${sinceId}` : ''}${untilId ? `&until_id=${untilId}` : ''}`,
-			user.notesCount, renderedNotes, partOf,
+			user.notesCount, activities, partOf,
 			notes.length > 0 ? `${partOf}?page=true&since_id=${notes[0]._id}` : null,
 			notes.length > 0 ? `${partOf}?page=true&until_id=${notes[notes.length - 1]._id}` : null
 		);
 
 		ctx.body = pack(rendered);
+		ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
 		setResponseType(ctx);
 	} else {
 		// index page
@@ -101,6 +97,20 @@ export default async (ctx: Router.IRouterContext) => {
 			`${partOf}?page=true&since_id=000000000000000000000000`
 		);
 		ctx.body = pack(rendered);
+		ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
 		setResponseType(ctx);
 	}
 };
+
+/**
+ * Pack Create<Note> or Announce Activity
+ * @param note Note
+ */
+export async function packActivity(note: INote): Promise<object> {
+	if (note.renoteId && note.text == null && note.poll == null && (note.fileIds == null || note.fileIds.length == 0)) {
+		const renote = await Note.findOne(note.renoteId);
+		return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote._id}`, note);
+	}
+
+	return renderCreate(await renderNote(note, false), note);
+}
diff --git a/src/server/api/call.ts b/src/server/api/call.ts
index e9abc11f54..7419bdc95d 100644
--- a/src/server/api/call.ts
+++ b/src/server/api/call.ts
@@ -9,6 +9,10 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any)
 
 	const ep = endpoints.find(e => e.name === endpoint);
 
+	if (ep == null) {
+		return rej('ENDPOINT_NOT_FOUND');
+	}
+
 	if (ep.meta.secure && !isSecure) {
 		return rej('ACCESS_DENIED');
 	}
@@ -25,10 +29,8 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any)
 		return rej('YOU_ARE_NOT_ADMIN');
 	}
 
-	if (app && ep.meta.kind) {
-		if (!app.permission.some(p => p === ep.meta.kind)) {
-			return rej('PERMISSION_DENIED');
-		}
+	if (app && ep.meta.kind && !app.permission.some(p => p === ep.meta.kind)) {
+		return rej('PERMISSION_DENIED');
 	}
 
 	if (ep.meta.requireCredential && ep.meta.limit) {
diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts
index 005240a37c..075e369832 100644
--- a/src/server/api/common/read-messaging-message.ts
+++ b/src/server/api/common/read-messaging-message.ts
@@ -1,7 +1,7 @@
 import * as mongo from 'mongodb';
 import Message from '../../../models/messaging-message';
 import { IMessagingMessage as IMessage } from '../../../models/messaging-message';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 import { publishMessagingStream } from '../../../stream';
 import { publishMessagingIndexStream } from '../../../stream';
 import User from '../../../models/user';
@@ -71,6 +71,6 @@ export default (
 		});
 
 		// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
-		publishUserStream(userId, 'read_all_messaging_messages');
+		publishMainStream(userId, 'readAllMessagingMessages');
 	}
 });
diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts
index 0b0f3e4e5a..2d58ada4ce 100644
--- a/src/server/api/common/read-notification.ts
+++ b/src/server/api/common/read-notification.ts
@@ -1,6 +1,6 @@
 import * as mongo from 'mongodb';
 import { default as Notification, INotification } from '../../../models/notification';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 import Mute from '../../../models/mute';
 import User from '../../../models/user';
 
@@ -66,6 +66,6 @@ export default (
 		});
 
 		// 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
-		publishUserStream(userId, 'read_all_notifications');
+		publishMainStream(userId, 'readAllNotifications');
 	}
 });
diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts
index d4a44070e6..2b00094269 100644
--- a/src/server/api/endpoints.ts
+++ b/src/server/api/endpoints.ts
@@ -79,7 +79,7 @@ const files = glob.sync('**/*.js', {
 });
 
 const endpoints: IEndpoint[] = files.map(f => {
-	const ep = require('./endpoints/' + f);
+	const ep = require(`./endpoints/${f}`);
 
 	return {
 		name: f.replace('.js', ''),
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index 2c7929fabe..f0ebfbe936 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -11,11 +11,35 @@ export const meta = {
 	requireAdmin: true,
 
 	params: {
+		broadcasts: $.arr($.obj()).optional.nullable.note({
+			desc: {
+				'ja-JP': 'ブロードキャスト'
+			}
+		}),
+
 		disableRegistration: $.bool.optional.nullable.note({
 			desc: {
 				'ja-JP': '招待制か否か'
 			}
 		}),
+
+		disableLocalTimeline: $.bool.optional.nullable.note({
+			desc: {
+				'ja-JP': 'ローカルタイムライン(とソーシャルタイムライン)を無効にするか否か'
+			}
+		}),
+
+		hidedTags: $.arr($.str).optional.nullable.note({
+			desc: {
+				'ja-JP': '統計などで無視するハッシュタグ'
+			}
+		}),
+
+		bannerUrl: $.str.optional.nullable.note({
+			desc: {
+				'ja-JP': 'インスタンスのバナー画像URL'
+			}
+		}),
 	}
 };
 
@@ -25,10 +49,26 @@ export default (params: any) => new Promise(async (res, rej) => {
 
 	const set = {} as any;
 
-	if (ps.disableRegistration === true || ps.disableRegistration === false) {
+	if (ps.broadcasts) {
+		set.broadcasts = ps.broadcasts;
+	}
+
+	if (typeof ps.disableRegistration === 'boolean') {
 		set.disableRegistration = ps.disableRegistration;
 	}
 
+	if (typeof ps.disableLocalTimeline === 'boolean') {
+		set.disableLocalTimeline = ps.disableLocalTimeline;
+	}
+
+	if (Array.isArray(ps.hidedTags)) {
+		set.hidedTags = ps.hidedTags;
+	}
+
+	if (ps.bannerUrl !== undefined) {
+		set.bannerUrl = ps.bannerUrl;
+	}
+
 	await Meta.update({}, {
 		$set: set
 	}, { upsert: true });
diff --git a/src/server/api/endpoints/aggregation/hashtags.ts b/src/server/api/endpoints/aggregation/hashtags.ts
new file mode 100644
index 0000000000..ffeafb2538
--- /dev/null
+++ b/src/server/api/endpoints/aggregation/hashtags.ts
@@ -0,0 +1,66 @@
+import Note from '../../../../models/note';
+import Meta from '../../../../models/meta';
+
+export default () => new Promise(async (res, rej) => {
+	const meta = await Meta.findOne({});
+	const hidedTags = meta ? (meta.hidedTags || []).map(t => t.toLowerCase()) : [];
+
+	const span = 1000 * 60 * 60 * 24 * 7; // 1週間
+
+	//#region 1. 指定期間の内に投稿されたハッシュタグ(とユーザーのペア)を集計
+	const data = await Note.aggregate([{
+		$match: {
+			createdAt: {
+				$gt: new Date(Date.now() - span)
+			},
+			tagsLower: {
+				$exists: true,
+				$ne: []
+			}
+		}
+	}, {
+		$unwind: '$tagsLower'
+	}, {
+		$group: {
+			_id: { tag: '$tagsLower', userId: '$userId' }
+		}
+	}]) as Array<{
+		_id: {
+			tag: string;
+			userId: any;
+		}
+	}>;
+	//#endregion
+
+	if (data.length == 0) {
+		return res([]);
+	}
+
+	let tags: Array<{
+		name: string;
+		count: number;
+	}> = [];
+
+	// カウント
+	data.map(x => x._id).forEach(x => {
+		// ブラックリストに登録されているタグなら弾く
+		if (hidedTags.includes(x.tag)) return;
+
+		const i = tags.findIndex(tag => tag.name == x.tag);
+		if (i != -1) {
+			tags[i].count++;
+		} else {
+			tags.push({
+				name: x.tag,
+				count: 1
+			});
+		}
+	});
+
+	// タグを人気順に並べ替え
+	tags = tags.sort((a, b) => b.count - a.count);
+
+	tags = tags.slice(0, 30);
+
+	res(tags);
+});
diff --git a/src/server/api/endpoints/ap/show.ts b/src/server/api/endpoints/ap/show.ts
new file mode 100644
index 0000000000..1f390d01aa
--- /dev/null
+++ b/src/server/api/endpoints/ap/show.ts
@@ -0,0 +1,116 @@
+import $ from 'cafy';
+import getParams from '../../get-params';
+import config from '../../../../config';
+import * as mongo from 'mongodb';
+import User, { pack as packUser, IUser } from '../../../../models/user';
+import { createPerson } from '../../../../remote/activitypub/models/person';
+import Note, { pack as packNote, INote } from '../../../../models/note';
+import { createNote } from '../../../../remote/activitypub/models/note';
+import Resolver from '../../../../remote/activitypub/resolver';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'URIを指定してActivityPubオブジェクトを参照します。'
+	},
+
+	requireCredential: false,
+
+	params: {
+		uri: $.str.note({
+			desc: {
+				'ja-JP': 'ActivityPubオブジェクトのURI'
+			}
+		}),
+	},
+};
+
+export default (params: any) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) return rej(psErr);
+
+	const object = await fetchAny(ps.uri);
+	if (object !== null) return res(object);
+
+	return rej('object not found');
+});
+
+/***
+ * URIからUserかNoteを解決する
+ */
+async function fetchAny(uri: string) {
+	// URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ
+	if (uri.startsWith(config.url + '/')) {
+		const id = new mongo.ObjectID(uri.split('/').pop());
+		const [ user, note ] = await Promise.all([
+			User.findOne({ _id: id }),
+			Note.findOne({ _id: id })
+		]);
+
+		const packed = await mergePack(user, note);
+		if (packed !== null) return packed;
+	}
+
+	// URI(AP Object id)としてDB検索
+	{
+		const [ user, note ] = await Promise.all([
+			User.findOne({ uri: uri }),
+			Note.findOne({ uri: uri })
+		]);
+
+		const packed = await mergePack(user, note);
+		if (packed !== null) return packed;
+	}
+
+	// リモートから一旦オブジェクトフェッチ
+	const resolver = new Resolver();
+	const object = await resolver.resolve(uri) as any;
+
+	// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
+	// これはDBに存在する可能性があるため再度DB検索
+	if (uri !== object.id) {
+		const [ user, note ] = await Promise.all([
+			User.findOne({ uri: object.id }),
+			Note.findOne({ uri: object.id })
+		]);
+
+		const packed = await mergePack(user, note);
+		if (packed !== null) return packed;
+	}
+
+	// それでもみつからなければ新規であるため登録
+	if (object.type === 'Person') {
+		const user = await createPerson(object.id);
+		return {
+			type: 'User',
+			object: user
+		};
+	}
+
+	if (object.type === 'Note') {
+		const note = await createNote(object.id);
+		return {
+			type: 'Note',
+			object: note
+		};
+	}
+
+	return null;
+}
+
+async function mergePack(user: IUser, note: INote) {
+	if (user !== null) {
+		return {
+			type: 'User',
+			object: await packUser(user, null, { detail: true })
+		};
+	}
+
+	if (note !== null) {
+		return {
+			type: 'Note',
+			object: await packNote(note, null, { detail: true })
+		};
+	}
+
+	return null;
+}
diff --git a/src/server/api/endpoints/chart.ts b/src/server/api/endpoints/chart.ts
index 7da970131e..3b1a3b56fc 100644
--- a/src/server/api/endpoints/chart.ts
+++ b/src/server/api/endpoints/chart.ts
@@ -6,6 +6,15 @@ type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
 
 function migrateStats(stats: IStats[]) {
 	stats.forEach(stat => {
+		if (stat.network == null) {
+			stat.network = {
+				requests: 0,
+				totalTime: 0,
+				incomingBytes: 0,
+				outgoingBytes: 0
+			};
+		}
+
 		const isOldData =
 			stat.users.local.inc == null ||
 			stat.users.local.dec == null ||
@@ -180,6 +189,12 @@ export default (params: any) => new Promise(async (res, rej) => {
 								decCount: 0,
 								decSize: 0
 							}
+						},
+						network: {
+							requests: 0,
+							totalTime: 0,
+							incomingBytes: 0,
+							outgoingBytes: 0
 						}
 					});
 				} else {
@@ -236,6 +251,12 @@ export default (params: any) => new Promise(async (res, rej) => {
 								decCount: 0,
 								decSize: 0
 							}
+						},
+						network: {
+							requests: 0,
+							totalTime: 0,
+							incomingBytes: 0,
+							outgoingBytes: 0
 						}
 					});
 				}
diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts
index dc6a602e10..de0bde086b 100644
--- a/src/server/api/endpoints/drive/files.ts
+++ b/src/server/api/endpoints/drive/files.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import DriveFile, { pack } from '../../../../models/drive-file';
+import DriveFile, { packMany } from '../../../../models/drive-file';
 import { ILocalUser } from '../../../../models/user';
 
 export const meta = {
@@ -73,6 +73,5 @@ export default async (params: any, user: ILocalUser) => {
 		});
 
 	// Serialize
-	const _files = await Promise.all(files.map(file => pack(file)));
-	return _files;
+	return await packMany(files);
 };
diff --git a/src/server/api/endpoints/drive/files/check_existence.ts b/src/server/api/endpoints/drive/files/check_existence.ts
new file mode 100644
index 0000000000..73d75b7caf
--- /dev/null
+++ b/src/server/api/endpoints/drive/files/check_existence.ts
@@ -0,0 +1,38 @@
+import $ from 'cafy';
+import DriveFile, { pack } from '../../../../../models/drive-file';
+import { ILocalUser } from '../../../../../models/user';
+
+export const meta = {
+	desc: {
+		'ja-JP': '与えられたMD5ハッシュ値を持つファイルがドライブに存在するかどうかを返します。',
+		'en-US': 'Returns whether the file with the given MD5 hash exists in the user\'s drive.'
+	},
+
+	requireCredential: true,
+
+	kind: 'drive-read',
+
+	params: {
+		md5: $.str.note({
+			desc: {
+				'ja-JP': 'ファイルのMD5ハッシュ'
+			}
+		})
+	}
+};
+
+export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
+	const [md5, md5Err] = $.str.get(params.md5);
+	if (md5Err) return rej('invalid md5 param');
+
+	const file = await DriveFile.findOne({
+		md5: md5,
+		'metadata.userId': user._id
+	});
+
+	if (file === null) {
+		res({ file: null });
+	} else {
+		res({ file: await pack(file) });
+	}
+});
diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts
index dfbd11d0c2..4b5ffa90e0 100644
--- a/src/server/api/endpoints/drive/files/create.ts
+++ b/src/server/api/endpoints/drive/files/create.ts
@@ -31,8 +31,8 @@ export const meta = {
 			}
 		}),
 
-		isSensitive: $.bool.optional.note({
-			default: false,
+		isSensitive: $.bool.optional.nullable.note({
+			default: null,
 			desc: {
 				'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか',
 				'en-US': 'Whether this media is NSFW'
diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts
index ba9abfec61..3c7932c341 100644
--- a/src/server/api/endpoints/drive/files/update.ts
+++ b/src/server/api/endpoints/drive/files/update.ts
@@ -4,6 +4,7 @@ import DriveFile, { validateFileName, pack } from '../../../../../models/drive-f
 import { publishDriveStream } from '../../../../../stream';
 import { ILocalUser } from '../../../../../models/user';
 import getParams from '../../../get-params';
+import Note from '../../../../../models/note';
 
 export const meta = {
 	desc: {
@@ -93,6 +94,18 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		}
 	});
 
+	// ドライブのファイルが非正規化されているドキュメントも更新
+	Note.find({
+		'_files._id': file._id
+	}).then(notes => {
+		notes.forEach(note => {
+			note._files[note._files.findIndex(f => f._id.equals(file._id))] = file;
+			Note.findOneAndUpdate({ _id: note._id }, {
+				_files: note._files
+			});
+		});
+	});
+
 	// Serialize
 	const fileObj = await pack(file);
 
diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts
index a9f3f7e9a5..3ac7dd0234 100644
--- a/src/server/api/endpoints/drive/stream.ts
+++ b/src/server/api/endpoints/drive/stream.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import DriveFile, { pack } from '../../../../models/drive-file';
+import DriveFile, { packMany } from '../../../../models/drive-file';
 import { ILocalUser } from '../../../../models/user';
 
 export const meta = {
@@ -63,5 +63,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		});
 
 	// Serialize
-	res(await Promise.all(files.map(file => pack(file))));
+	res(await packMany(files));
 });
diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts
index c9bea0e3d2..00aa904f08 100644
--- a/src/server/api/endpoints/following/create.ts
+++ b/src/server/api/endpoints/following/create.ts
@@ -57,7 +57,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	}
 
 	// Create following
-	create(follower, followee);
+	await create(follower, followee);
 
 	// Send response
 	res(await pack(followee._id, user));
diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts
index f3b4a73ae8..cdfbf43cd1 100644
--- a/src/server/api/endpoints/following/delete.ts
+++ b/src/server/api/endpoints/following/delete.ts
@@ -57,7 +57,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	}
 
 	// Delete following
-	deleteFollowing(follower, followee);
+	await deleteFollowing(follower, followee);
 
 	// Send response
 	res(await pack(followee._id, user));
diff --git a/src/server/api/endpoints/games/reversi/match.ts b/src/server/api/endpoints/games/reversi/match.ts
index aba400af1d..d7483a0bfd 100644
--- a/src/server/api/endpoints/games/reversi/match.ts
+++ b/src/server/api/endpoints/games/reversi/match.ts
@@ -2,7 +2,7 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id';
 import Matching, { pack as packMatching } from '../../../../../models/games/reversi/matching';
 import ReversiGame, { pack as packGame } from '../../../../../models/games/reversi/game';
 import User, { ILocalUser } from '../../../../../models/user';
-import { publishUserStream, publishReversiStream } from '../../../../../stream';
+import { publishMainStream, publishReversiStream } from '../../../../../stream';
 import { eighteight } from '../../../../../games/reversi/maps';
 
 export const meta = {
@@ -58,7 +58,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		});
 
 		if (other == 0) {
-			publishUserStream(user._id, 'reversi_no_invites');
+			publishMainStream(user._id, 'reversi_no_invites');
 		}
 	} else {
 		// Fetch child
@@ -94,6 +94,6 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		// 招待
 		publishReversiStream(child._id, 'invited', packed);
 
-		publishUserStream(child._id, 'reversi_invited', packed);
+		publishMainStream(child._id, 'reversiInvited', packed);
 	}
 });
diff --git a/src/server/api/endpoints/hashtags/trend.ts b/src/server/api/endpoints/hashtags/trend.ts
index 01dfccc71c..0ec6a4ffec 100644
--- a/src/server/api/endpoints/hashtags/trend.ts
+++ b/src/server/api/endpoints/hashtags/trend.ts
@@ -1,4 +1,6 @@
 import Note from '../../../../models/note';
+import { erase } from '../../../../prelude/array';
+import Meta from '../../../../models/meta';
 
 /*
 トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要
@@ -16,6 +18,9 @@ const max = 5;
  * Get trends of hashtags
  */
 export default () => new Promise(async (res, rej) => {
+	const meta = await Meta.findOne({});
+	const hidedTags = meta ? (meta.hidedTags || []).map(t => t.toLowerCase()) : [];
+
 	//#region 1. 直近Aの内に投稿されたハッシュタグ(とユーザーのペア)を集計
 	const data = await Note.aggregate([{
 		$match: {
@@ -52,6 +57,9 @@ export default () => new Promise(async (res, rej) => {
 
 	// カウント
 	data.map(x => x._id).forEach(x => {
+		// ブラックリストに登録されているタグなら弾く
+		if (hidedTags.includes(x.tag)) return;
+
 		const i = tags.findIndex(tag => tag.name == x.tag);
 		if (i != -1) {
 			tags[i].count++;
@@ -85,8 +93,7 @@ export default () => new Promise(async (res, rej) => {
 	//#endregion
 
 	// タグを人気順に並べ替え
-	let hots = (await Promise.all(hotsPromises))
-		.filter(x => x != null)
+	let hots = erase(null, await Promise.all(hotsPromises))
 		.sort((a, b) => b.count - a.count)
 		.map(tag => tag.name)
 		.slice(0, max);
diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts
index 1f99ef2d8d..5aa2070650 100644
--- a/src/server/api/endpoints/i.ts
+++ b/src/server/api/endpoints/i.ts
@@ -22,6 +22,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
 	// Serialize
 	res(await pack(user, user, {
 		detail: true,
+		includeHasUnreadNotes: true,
 		includeSecrets: isSecure
 	}));
 
diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts
index 32c1a55fb0..e7cf8a71a7 100644
--- a/src/server/api/endpoints/i/favorites.ts
+++ b/src/server/api/endpoints/i/favorites.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import Favorite, { pack } from '../../../../models/favorite';
+import Favorite, { packMany } from '../../../../models/favorite';
 import { ILocalUser } from '../../../../models/user';
 
 export const meta = {
@@ -55,5 +55,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		.find(query, { limit, sort });
 
 	// Serialize
-	res(await Promise.all(favorites.map(favorite => pack(favorite, user))));
+	res(await packMany(favorites, user));
 });
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index 46242b9d9f..5cc836e362 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Notification from '../../../../models/notification';
 import Mute from '../../../../models/mute';
-import { pack } from '../../../../models/notification';
+import { packMany } from '../../../../models/notification';
 import { getFriendIds } from '../../common/get-friends';
 import read from '../../common/read-notification';
 import { ILocalUser } from '../../../../models/user';
@@ -83,7 +83,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		});
 
 	// Serialize
-	res(await Promise.all(notifications.map(notification => pack(notification))));
+	res(await packMany(notifications));
 
 	// Mark all as read
 	if (notifications.length > 0 && markAsRead) {
diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts
index ae03a86336..bf729ca091 100644
--- a/src/server/api/endpoints/i/pin.ts
+++ b/src/server/api/endpoints/i/pin.ts
@@ -1,31 +1,37 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import User, { ILocalUser } from '../../../../models/user';
-import Note from '../../../../models/note';
+import { ILocalUser } from '../../../../models/user';
 import { pack } from '../../../../models/user';
+import { addPinned } from '../../../../services/i/pin';
+import getParams from '../../get-params';
 
-/**
- * Pin note
- */
-export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
-	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
-	if (noteIdErr) return rej('invalid noteId param');
+export const meta = {
+	desc: {
+		'ja-JP': '指定した投稿をピン留めします。'
+	},
 
-	// Fetch pinee
-	const note = await Note.findOne({
-		_id: noteId,
-		userId: user._id
-	});
+	requireCredential: true,
 
-	if (note === null) {
-		return rej('note not found');
+	kind: 'account-write',
+
+	params: {
+		noteId: $.type(ID).note({
+			desc: {
+				'ja-JP': '対象の投稿のID'
+			}
+		})
 	}
+};
 
-	await User.update(user._id, {
-		$set: {
-			pinnedNoteId: note._id
-		}
-	});
+export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) return rej(psErr);
+
+	// Processing
+	try {
+		await addPinned(user, ps.noteId);
+	} catch (e) {
+		return rej(e.message);
+	}
 
 	// Serialize
 	const iObj = await pack(user, user, {
diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts
index fe4a5cd118..2d85f06cfa 100644
--- a/src/server/api/endpoints/i/regenerate_token.ts
+++ b/src/server/api/endpoints/i/regenerate_token.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy';
 import * as bcrypt from 'bcryptjs';
 import User, { ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 import generateUserToken from '../../common/generate-native-user-token';
 
 export const meta = {
@@ -33,5 +33,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 	res();
 
 	// Publish event
-	publishUserStream(user._id, 'my_token_regenerated');
+	publishMainStream(user._id, 'myTokenRegenerated');
 });
diff --git a/src/server/api/endpoints/i/unpin.ts b/src/server/api/endpoints/i/unpin.ts
new file mode 100644
index 0000000000..2a81993e4b
--- /dev/null
+++ b/src/server/api/endpoints/i/unpin.ts
@@ -0,0 +1,43 @@
+import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
+import { ILocalUser } from '../../../../models/user';
+import { pack } from '../../../../models/user';
+import { removePinned } from '../../../../services/i/pin';
+import getParams from '../../get-params';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定した投稿のピン留めを解除します。'
+	},
+
+	requireCredential: true,
+
+	kind: 'account-write',
+
+	params: {
+		noteId: $.type(ID).note({
+			desc: {
+				'ja-JP': '対象の投稿のID'
+			}
+		})
+	}
+};
+
+export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) return rej(psErr);
+
+	// Processing
+	try {
+		await removePinned(user, ps.noteId);
+	} catch (e) {
+		return rej(e.message);
+	}
+
+	// Serialize
+	const iObj = await pack(user, user, {
+		detail: true
+	});
+
+	// Send response
+	res(iObj);
+});
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index cdb4eb3f56..548ce5cadb 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -1,10 +1,12 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack, ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 import DriveFile from '../../../../models/drive-file';
 import acceptAllFollowRequests from '../../../../services/following/requests/accept-all';
 import { IApp } from '../../../../models/app';
 import config from '../../../../config';
+import { publishToFollowers } from '../../../../services/i/update';
+import getParams from '../../get-params';
 
 export const meta = {
 	desc: {
@@ -14,75 +16,111 @@ export const meta = {
 
 	requireCredential: true,
 
-	kind: 'account-write'
+	kind: 'account-write',
+
+	params: {
+		name: $.str.optional.nullable.pipe(isValidName).note({
+			desc: {
+				'ja-JP': '名前(ハンドルネームやニックネーム)'
+			}
+		}),
+
+		description: $.str.optional.nullable.pipe(isValidDescription).note({
+			desc: {
+				'ja-JP': 'アカウントの説明や自己紹介'
+			}
+		}),
+
+		location: $.str.optional.nullable.pipe(isValidLocation).note({
+			desc: {
+				'ja-JP': '住んでいる地域、所在'
+			}
+		}),
+
+		birthday: $.str.optional.nullable.pipe(isValidBirthday).note({
+			desc: {
+				'ja-JP': '誕生日 (YYYY-MM-DD形式)'
+			}
+		}),
+
+		avatarId: $.type(ID).optional.nullable.note({
+			desc: {
+				'ja-JP': 'アイコンに設定する画像のドライブファイルID'
+			}
+		}),
+
+		bannerId: $.type(ID).optional.nullable.note({
+			desc: {
+				'ja-JP': 'バナーに設定する画像のドライブファイルID'
+			}
+		}),
+
+		wallpaperId: $.type(ID).optional.nullable.note({
+			desc: {
+				'ja-JP': '壁紙に設定する画像のドライブファイルID'
+			}
+		}),
+
+		isLocked: $.bool.optional.note({
+			desc: {
+				'ja-JP': '鍵アカウントか否か'
+			}
+		}),
+
+		isBot: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'Botか否か'
+			}
+		}),
+
+		isCat: $.bool.optional.note({
+			desc: {
+				'ja-JP': '猫か否か'
+			}
+		}),
+
+		autoWatch: $.bool.optional.note({
+			desc: {
+				'ja-JP': '投稿の自動ウォッチをするか否か'
+			}
+		}),
+
+		alwaysMarkNsfw: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'アップロードするメディアをデフォルトで「閲覧注意」として設定するか'
+			}
+		}),
+	}
 };
 
 export default async (params: any, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
+
 	const isSecure = user != null && app == null;
 
 	const updates = {} as any;
 
-	// Get 'name' parameter
-	const [name, nameErr] = $.str.optional.nullable.pipe(isValidName).get(params.name);
-	if (nameErr) return rej('invalid name param');
-	if (name) updates.name = name;
+	if (ps.name !== undefined) updates.name = ps.name;
+	if (ps.description !== undefined) updates.description = ps.description;
+	if (ps.location !== undefined) updates['profile.location'] = ps.location;
+	if (ps.birthday !== undefined) updates['profile.birthday'] = ps.birthday;
+	if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
+	if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
+	if (ps.wallpaperId !== undefined) updates.wallpaperId = ps.wallpaperId;
+	if (typeof ps.isLocked == 'boolean') updates.isLocked = ps.isLocked;
+	if (typeof ps.isBot == 'boolean') updates.isBot = ps.isBot;
+	if (typeof ps.isCat == 'boolean') updates.isCat = ps.isCat;
+	if (typeof ps.autoWatch == 'boolean') updates['settings.autoWatch'] = ps.autoWatch;
+	if (typeof ps.alwaysMarkNsfw == 'boolean') updates['settings.alwaysMarkNsfw'] = ps.alwaysMarkNsfw;
 
-	// Get 'description' parameter
-	const [description, descriptionErr] = $.str.optional.nullable.pipe(isValidDescription).get(params.description);
-	if (descriptionErr) return rej('invalid description param');
-	if (description !== undefined) updates.description = description;
-
-	// Get 'location' parameter
-	const [location, locationErr] = $.str.optional.nullable.pipe(isValidLocation).get(params.location);
-	if (locationErr) return rej('invalid location param');
-	if (location !== undefined) updates['profile.location'] = location;
-
-	// Get 'birthday' parameter
-	const [birthday, birthdayErr] = $.str.optional.nullable.pipe(isValidBirthday).get(params.birthday);
-	if (birthdayErr) return rej('invalid birthday param');
-	if (birthday !== undefined) updates['profile.birthday'] = birthday;
-
-	// Get 'avatarId' parameter
-	const [avatarId, avatarIdErr] = $.type(ID).optional.nullable.get(params.avatarId);
-	if (avatarIdErr) return rej('invalid avatarId param');
-	if (avatarId !== undefined) updates.avatarId = avatarId;
-
-	// Get 'bannerId' parameter
-	const [bannerId, bannerIdErr] = $.type(ID).optional.nullable.get(params.bannerId);
-	if (bannerIdErr) return rej('invalid bannerId param');
-	if (bannerId !== undefined) updates.bannerId = bannerId;
-
-	// Get 'wallpaperId' parameter
-	const [wallpaperId, wallpaperIdErr] = $.type(ID).optional.nullable.get(params.wallpaperId);
-	if (wallpaperIdErr) return rej('invalid wallpaperId param');
-	if (wallpaperId !== undefined) updates.wallpaperId = wallpaperId;
-
-	// Get 'isLocked' parameter
-	const [isLocked, isLockedErr] = $.bool.optional.get(params.isLocked);
-	if (isLockedErr) return rej('invalid isLocked param');
-	if (isLocked != null) updates.isLocked = isLocked;
-
-	// Get 'isBot' parameter
-	const [isBot, isBotErr] = $.bool.optional.get(params.isBot);
-	if (isBotErr) return rej('invalid isBot param');
-	if (isBot != null) updates.isBot = isBot;
-
-	// Get 'isCat' parameter
-	const [isCat, isCatErr] = $.bool.optional.get(params.isCat);
-	if (isCatErr) return rej('invalid isCat param');
-	if (isCat != null) updates.isCat = isCat;
-
-	// Get 'autoWatch' parameter
-	const [autoWatch, autoWatchErr] = $.bool.optional.get(params.autoWatch);
-	if (autoWatchErr) return rej('invalid autoWatch param');
-	if (autoWatch != null) updates['settings.autoWatch'] = autoWatch;
-
-	if (avatarId) {
+	if (ps.avatarId) {
 		const avatar = await DriveFile.findOne({
-			_id: avatarId
+			_id: ps.avatarId
 		});
 
 		if (avatar == null) return rej('avatar not found');
+		if (!avatar.contentType.startsWith('image/')) return rej('avatar not an image');
 
 		updates.avatarUrl = avatar.metadata.thumbnailUrl || avatar.metadata.url || `${config.drive_url}/${avatar._id}`;
 
@@ -91,12 +129,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a
 		}
 	}
 
-	if (bannerId) {
+	if (ps.bannerId) {
 		const banner = await DriveFile.findOne({
-			_id: bannerId
+			_id: ps.bannerId
 		});
 
 		if (banner == null) return rej('banner not found');
+		if (!banner.contentType.startsWith('image/')) return rej('banner not an image');
 
 		updates.bannerUrl = banner.metadata.url || `${config.drive_url}/${banner._id}`;
 
@@ -105,13 +144,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a
 		}
 	}
 
-	if (wallpaperId !== undefined) {
-		if (wallpaperId === null) {
+	if (ps.wallpaperId !== undefined) {
+		if (ps.wallpaperId === null) {
 			updates.wallpaperUrl = null;
 			updates.wallpaperColor = null;
 		} else {
 			const wallpaper = await DriveFile.findOne({
-				_id: wallpaperId
+				_id: ps.wallpaperId
 			});
 
 			if (wallpaper == null) return rej('wallpaper not found');
@@ -138,10 +177,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a
 	res(iObj);
 
 	// Publish meUpdated event
-	publishUserStream(user._id, 'meUpdated', iObj);
+	publishMainStream(user._id, 'meUpdated', iObj);
 
 	// 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認
-	if (user.isLocked && isLocked === false) {
+	if (user.isLocked && ps.isLocked === false) {
 		acceptAllFollowRequests(user);
 	}
+
+	// フォロワーにUpdateを配信
+	publishToFollowers(user._id);
 });
diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts
index aed93c792f..2c05299dff 100644
--- a/src/server/api/endpoints/i/update_client_setting.ts
+++ b/src/server/api/endpoints/i/update_client_setting.ts
@@ -1,6 +1,6 @@
 import $ from 'cafy';
 import User, { ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 
 export const meta = {
 	requireCredential: true,
@@ -26,7 +26,7 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 	res();
 
 	// Publish event
-	publishUserStream(user._id, 'clientSettingUpdated', {
+	publishMainStream(user._id, 'clientSettingUpdated', {
 		key: name,
 		value
 	});
diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts
index ffca9b90b3..27afc9fe5a 100644
--- a/src/server/api/endpoints/i/update_home.ts
+++ b/src/server/api/endpoints/i/update_home.ts
@@ -1,6 +1,6 @@
 import $ from 'cafy';
 import User, { ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 
 export const meta = {
 	requireCredential: true,
@@ -25,5 +25,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 
 	res();
 
-	publishUserStream(user._id, 'home_updated', home);
+	publishMainStream(user._id, 'homeUpdated', home);
 });
diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts
index 0b72fbe2c1..1d4df389e4 100644
--- a/src/server/api/endpoints/i/update_mobile_home.ts
+++ b/src/server/api/endpoints/i/update_mobile_home.ts
@@ -1,6 +1,6 @@
 import $ from 'cafy';
 import User, { ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 
 export const meta = {
 	requireCredential: true,
@@ -24,5 +24,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 
 	res();
 
-	publishUserStream(user._id, 'mobile_home_updated', home);
+	publishMainStream(user._id, 'mobileHomeUpdated', home);
 });
diff --git a/src/server/api/endpoints/i/update_widget.ts b/src/server/api/endpoints/i/update_widget.ts
index 5cbe7c07a3..92499493eb 100644
--- a/src/server/api/endpoints/i/update_widget.ts
+++ b/src/server/api/endpoints/i/update_widget.ts
@@ -1,6 +1,6 @@
 import $ from 'cafy';
 import User, { ILocalUser } from '../../../../models/user';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 
 export const meta = {
 	requireCredential: true,
@@ -73,7 +73,7 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 	//#endregion
 
 	if (widget) {
-		publishUserStream(user._id, 'widgetUpdated', {
+		publishMainStream(user._id, 'widgetUpdated', {
 			id, data
 		});
 
diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts
index a6fabcfa45..cb115cf987 100644
--- a/src/server/api/endpoints/messaging/messages/create.ts
+++ b/src/server/api/endpoints/messaging/messages/create.ts
@@ -6,7 +6,7 @@ import User, { ILocalUser } from '../../../../../models/user';
 import Mute from '../../../../../models/mute';
 import DriveFile from '../../../../../models/drive-file';
 import { pack } from '../../../../../models/messaging-message';
-import { publishUserStream } from '../../../../../stream';
+import { publishMainStream } from '../../../../../stream';
 import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../stream';
 import pushSw from '../../../../../push-sw';
 
@@ -74,7 +74,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		createdAt: new Date(),
 		fileId: file ? file._id : undefined,
 		recipientId: recipient._id,
-		text: text ? text : undefined,
+		text: text ? text.trim() : undefined,
 		userId: user._id,
 		isRead: false
 	});
@@ -88,12 +88,12 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	// 自分のストリーム
 	publishMessagingStream(message.userId, message.recipientId, 'message', messageObj);
 	publishMessagingIndexStream(message.userId, 'message', messageObj);
-	publishUserStream(message.userId, 'messaging_message', messageObj);
+	publishMainStream(message.userId, 'messagingMessage', messageObj);
 
 	// 相手のストリーム
 	publishMessagingStream(message.recipientId, message.userId, 'message', messageObj);
 	publishMessagingIndexStream(message.recipientId, 'message', messageObj);
-	publishUserStream(message.recipientId, 'messaging_message', messageObj);
+	publishMainStream(message.recipientId, 'messagingMessage', messageObj);
 
 	// Update flag
 	User.update({ _id: recipient._id }, {
@@ -102,7 +102,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		}
 	});
 
-	// 3秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
+	// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
 	setTimeout(async () => {
 		const freshMessage = await Message.findOne({ _id: message._id }, { isRead: true });
 		if (!freshMessage.isRead) {
@@ -117,10 +117,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 			}
 			//#endregion
 
-			publishUserStream(message.recipientId, 'unread_messaging_message', messageObj);
-			pushSw(message.recipientId, 'unread_messaging_message', messageObj);
+			publishMainStream(message.recipientId, 'unreadMessagingMessage', messageObj);
+			pushSw(message.recipientId, 'unreadMessagingMessage', messageObj);
 		}
-	}, 3000);
+	}, 2000);
 
 	// 履歴作成(自分)
 	History.update({
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 2b39f26b8e..c76d7f2e8f 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -4,6 +4,7 @@
 import * as os from 'os';
 import config from '../../../config';
 import Meta from '../../../models/meta';
+import { ILocalUser } from '../../../models/user';
 
 const pkg = require('../../../../package.json');
 const client = require('../../../../built/client/meta.json');
@@ -11,7 +12,7 @@ const client = require('../../../../built/client/meta.json');
 /**
  * Show core info
  */
-export default () => new Promise(async (res, rej) => {
+export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
 	const meta: any = (await Meta.findOne()) || {};
 
 	res({
@@ -31,9 +32,22 @@ export default () => new Promise(async (res, rej) => {
 			model: os.cpus()[0].model,
 			cores: os.cpus().length
 		},
-		broadcasts: meta.broadcasts,
+		broadcasts: meta.broadcasts || [],
 		disableRegistration: meta.disableRegistration,
+		disableLocalTimeline: meta.disableLocalTimeline,
+		driveCapacityPerLocalUserMb: config.localDriveCapacityMb,
 		recaptchaSitekey: config.recaptcha ? config.recaptcha.site_key : null,
-		swPublickey: config.sw ? config.sw.public_key : null
+		swPublickey: config.sw ? config.sw.public_key : null,
+		hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined,
+		bannerUrl: meta.bannerUrl,
+		features: {
+			registration: !meta.disableRegistration,
+			localTimeLine: !meta.disableLocalTimeline,
+			elasticsearch: config.elasticsearch ? true : false,
+			recaptcha: config.recaptcha ? true : false,
+			objectStorage: config.drive && config.drive.storage === 'minio',
+			twitter: config.twitter ? true : false,
+			serviceWorker: config.sw ? true : false
+		}
 	});
 });
diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts
index 029bc1a95e..d65710d33f 100644
--- a/src/server/api/endpoints/notes.ts
+++ b/src/server/api/endpoints/notes.ts
@@ -1,51 +1,65 @@
-/**
- * Module dependencies
- */
 import $ from 'cafy'; import ID from '../../../misc/cafy-id';
-import Note, { pack } from '../../../models/note';
+import Note, { packMany } from '../../../models/note';
+import getParams from '../get-params';
+
+export const meta = {
+	desc: {
+		'ja-JP': '投稿を取得します。'
+	},
+
+	params: {
+		local: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ローカルの投稿に限定するか否か'
+			}
+		}),
+
+		reply: $.bool.optional.note({
+			desc: {
+				'ja-JP': '返信に限定するか否か'
+			}
+		}),
+
+		renote: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'Renoteに限定するか否か'
+			}
+		}),
+
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か'
+			}
+		}),
+
+		media: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
+			}
+		}),
+
+		poll: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'アンケートが添付された投稿に限定するか否か'
+			}
+		}),
+
+		limit: $.num.optional.range(1, 100).note({
+			default: 10
+		}),
+
+		sinceId: $.type(ID).optional.note({}),
+
+		untilId: $.type(ID).optional.note({}),
+	}
+};
 
-/**
- * Get all notes
- */
 export default (params: any) => new Promise(async (res, rej) => {
-	// Get 'local' parameter
-	const [local, localErr] = $.bool.optional.get(params.local);
-	if (localErr) return rej('invalid local param');
-
-	// Get 'reply' parameter
-	const [reply, replyErr] = $.bool.optional.get(params.reply);
-	if (replyErr) return rej('invalid reply param');
-
-	// Get 'renote' parameter
-	const [renote, renoteErr] = $.bool.optional.get(params.renote);
-	if (renoteErr) return rej('invalid renote param');
-
-	// Get 'media' parameter
-	const [media, mediaErr] = $.bool.optional.get(params.media);
-	if (mediaErr) return rej('invalid media param');
-
-	// Get 'poll' parameter
-	const [poll, pollErr] = $.bool.optional.get(params.poll);
-	if (pollErr) return rej('invalid poll param');
-
-	// Get 'bot' parameter
-	//const [bot, botErr] = $.bool.optional.get(params.bot);
-	//if (botErr) return rej('invalid bot param');
-
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
-	if (limitErr) return rej('invalid limit param');
-
-	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
-	if (sinceIdErr) return rej('invalid sinceId param');
-
-	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
-	if (untilIdErr) return rej('invalid untilId param');
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
 	// Check if both of sinceId and untilId is specified
-	if (sinceId && untilId) {
+	if (ps.sinceId && ps.untilId) {
 		return rej('cannot set sinceId and untilId');
 	}
 
@@ -56,35 +70,37 @@ export default (params: any) => new Promise(async (res, rej) => {
 	const query = {
 		visibility: 'public'
 	} as any;
-	if (sinceId) {
+	if (ps.sinceId) {
 		sort._id = 1;
 		query._id = {
-			$gt: sinceId
+			$gt: ps.sinceId
 		};
-	} else if (untilId) {
+	} else if (ps.untilId) {
 		query._id = {
-			$lt: untilId
+			$lt: ps.untilId
 		};
 	}
 
-	if (local) {
+	if (ps.local) {
 		query['_user.host'] = null;
 	}
 
-	if (reply != undefined) {
-		query.replyId = reply ? { $exists: true, $ne: null } : null;
+	if (ps.reply != undefined) {
+		query.replyId = ps.reply ? { $exists: true, $ne: null } : null;
 	}
 
-	if (renote != undefined) {
-		query.renoteId = renote ? { $exists: true, $ne: null } : null;
+	if (ps.renote != undefined) {
+		query.renoteId = ps.renote ? { $exists: true, $ne: null } : null;
 	}
 
-	if (media != undefined) {
-		query.mediaIds = media ? { $exists: true, $ne: null } : [];
+	const withFiles = ps.withFiles != undefined ? ps.withFiles : ps.media;
+
+	if (withFiles) {
+		query.fileIds = withFiles ? { $exists: true, $ne: null } : [];
 	}
 
-	if (poll != undefined) {
-		query.poll = poll ? { $exists: true, $ne: null } : null;
+	if (ps.poll != undefined) {
+		query.poll = ps.poll ? { $exists: true, $ne: null } : null;
 	}
 
 	// TODO
@@ -95,10 +111,10 @@ export default (params: any) => new Promise(async (res, rej) => {
 	// Issue query
 	const notes = await Note
 		.find(query, {
-			limit: limit,
+			limit: ps.limit,
 			sort: sort
 		});
 
 	// Serialize
-	res(await Promise.all(notes.map(note => pack(note))));
+	res(await packMany(notes));
 });
diff --git a/src/server/api/endpoints/notes/conversation.ts b/src/server/api/endpoints/notes/conversation.ts
index 2782d14155..0c23f9e5fc 100644
--- a/src/server/api/endpoints/notes/conversation.ts
+++ b/src/server/api/endpoints/notes/conversation.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import Note, { pack, INote } from '../../../../models/note';
+import Note, { packMany, INote } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 
 /**
@@ -52,5 +52,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	}
 
 	// Serialize
-	res(await Promise.all(conversation.map(note => pack(note, user))));
+	res(await packMany(conversation, user));
 });
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index 04f5f7562e..96745132a3 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -71,9 +71,15 @@ export const meta = {
 			ref: 'geo'
 		}),
 
+		fileIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({
+			desc: {
+				'ja-JP': '添付するファイル'
+			}
+		}),
+
 		mediaIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({
 			desc: {
-				'ja-JP': '添付するメディア'
+				'ja-JP': '添付するファイル (このパラメータは廃止予定です。代わりに fileIds を使ってください。)'
 			}
 		}),
 
@@ -124,26 +130,16 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
 	}
 
 	let files: IDriveFile[] = [];
-	if (ps.mediaIds !== undefined) {
-		// Fetch files
-		// forEach だと途中でエラーなどがあっても return できないので
-		// 敢えて for を使っています。
-		for (const mediaId of ps.mediaIds) {
-			// Fetch file
-			// SELECT _id
-			const entity = await DriveFile.findOne({
-				_id: mediaId,
+	const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null;
+	if (fileIds != null) {
+		files = await Promise.all(fileIds.map(fileId => {
+			return DriveFile.findOne({
+				_id: fileId,
 				'metadata.userId': user._id
 			});
+		}));
 
-			if (entity === null) {
-				return rej('file not found');
-			} else {
-				files.push(entity);
-			}
-		}
-	} else {
-		files = null;
+		files = files.filter(file => file != null);
 	}
 
 	let renote: INote = null;
@@ -155,7 +151,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
 
 		if (renote == null) {
 			return rej('renoteee is not found');
-		} else if (renote.renoteId && !renote.text && !renote.mediaIds) {
+		} else if (renote.renoteId && !renote.text && !renote.fileIds) {
 			return rej('cannot renote to renote');
 		}
 	}
@@ -176,7 +172,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
 		}
 
 		// 返信対象が引用でないRenoteだったらエラー
-		if (reply.renoteId && !reply.text && !reply.mediaIds) {
+		if (reply.renoteId && !reply.text && !reply.fileIds) {
 			return rej('cannot reply to renote');
 		}
 	}
@@ -191,13 +187,13 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
 
 	// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
 	if ((ps.text === undefined || ps.text === null) && files === null && renote === null && ps.poll === undefined) {
-		return rej('text, mediaIds, renoteId or poll is required');
+		return rej('text, fileIds, renoteId or poll is required');
 	}
 
 	// 投稿を作成
 	const note = await create(user, {
 		createdAt: new Date(),
-		media: files,
+		files: files,
 		poll: ps.poll,
 		text: ps.text,
 		reply,
diff --git a/src/server/api/endpoints/notes/delete.ts b/src/server/api/endpoints/notes/delete.ts
index 6d9826cf7b..2fe36897c0 100644
--- a/src/server/api/endpoints/notes/delete.ts
+++ b/src/server/api/endpoints/notes/delete.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import deleteNote from '../../../../services/note/delete';
-import { ILocalUser } from '../../../../models/user';
+import User, { ILocalUser } from '../../../../models/user';
 
 export const meta = {
 	desc: {
@@ -21,15 +21,18 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 
 	// Fetch note
 	const note = await Note.findOne({
-		_id: noteId,
-		userId: user._id
+		_id: noteId
 	});
 
 	if (note === null) {
 		return rej('note not found');
 	}
 
-	await deleteNote(user, note);
+	if (!user.isAdmin && !note.userId.equals(user._id)) {
+		return rej('access denied');
+	}
+
+	await deleteNote(await User.findOne({ _id: note.userId }), note);
 
 	res();
 });
diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts
index daf7780abc..9aefb701ae 100644
--- a/src/server/api/endpoints/notes/favorites/create.ts
+++ b/src/server/api/endpoints/notes/favorites/create.ts
@@ -2,6 +2,7 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id';
 import Favorite from '../../../../../models/favorite';
 import Note from '../../../../../models/note';
 import { ILocalUser } from '../../../../../models/user';
+import getParams from '../../../get-params';
 
 export const meta = {
 	desc: {
@@ -11,17 +12,24 @@ export const meta = {
 
 	requireCredential: true,
 
-	kind: 'favorite-write'
+	kind: 'favorite-write',
+
+	params: {
+		noteId: $.type(ID).note({
+			desc: {
+				'ja-JP': '対象の投稿のID'
+			}
+		})
+	}
 };
 
 export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
-	// Get 'noteId' parameter
-	const [noteId, noteIdErr] = $.type(ID).get(params.noteId);
-	if (noteIdErr) return rej('invalid noteId param');
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) return rej(psErr);
 
 	// Get favoritee
 	const note = await Note.findOne({
-		_id: noteId
+		_id: ps.noteId
 	});
 
 	if (note === null) {
diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts
index 8f7233e308..8362143bb2 100644
--- a/src/server/api/endpoints/notes/global-timeline.ts
+++ b/src/server/api/endpoints/notes/global-timeline.ts
@@ -1,42 +1,52 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
+import getParams from '../../get-params';
+import { countIf } from '../../../../prelude/array';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'グローバルタイムラインを取得します。'
+	},
+
+	params: {
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か'
+			}
+		}),
+
+		mediaOnly: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
+			}
+		}),
+
+		limit: $.num.optional.range(1, 100).note({
+			default: 10
+		}),
+
+		sinceId: $.type(ID).optional.note({}),
+
+		untilId: $.type(ID).optional.note({}),
+
+		sinceDate: $.num.optional.note({}),
+
+		untilDate: $.num.optional.note({}),
+	}
+};
 
-/**
- * Get timeline of global
- */
 export default async (params: any, user: ILocalUser) => {
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
-	if (limitErr) throw 'invalid limit param';
-
-	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
-	if (sinceIdErr) throw 'invalid sinceId param';
-
-	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
-	if (untilIdErr) throw 'invalid untilId param';
-
-	// Get 'sinceDate' parameter
-	const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
-	if (sinceDateErr) throw 'invalid sinceDate param';
-
-	// Get 'untilDate' parameter
-	const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
-	if (untilDateErr) throw 'invalid untilDate param';
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
 	// Check if only one of sinceId, untilId, sinceDate, untilDate specified
-	if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+	if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
 		throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
 	}
 
-	// Get 'mediaOnly' parameter
-	const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly);
-	if (mediaOnlyErr) throw 'invalid mediaOnly param';
-
 	// ミュートしているユーザーを取得
 	const mutedUserIds = user ? (await Mute.find({
 		muterId: user._id
@@ -68,27 +78,29 @@ export default async (params: any, user: ILocalUser) => {
 		};
 	}
 
-	if (mediaOnly) {
-		query.mediaIds = { $exists: true, $ne: [] };
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
+
+	if (withFiles) {
+		query.fileIds = { $exists: true, $ne: [] };
 	}
 
-	if (sinceId) {
+	if (ps.sinceId) {
 		sort._id = 1;
 		query._id = {
-			$gt: sinceId
+			$gt: ps.sinceId
 		};
-	} else if (untilId) {
+	} else if (ps.untilId) {
 		query._id = {
-			$lt: untilId
+			$lt: ps.untilId
 		};
-	} else if (sinceDate) {
+	} else if (ps.sinceDate) {
 		sort._id = 1;
 		query.createdAt = {
-			$gt: new Date(sinceDate)
+			$gt: new Date(ps.sinceDate)
 		};
-	} else if (untilDate) {
+	} else if (ps.untilDate) {
 		query.createdAt = {
-			$lt: new Date(untilDate)
+			$lt: new Date(ps.untilDate)
 		};
 	}
 	//#endregion
@@ -96,10 +108,10 @@ export default async (params: any, user: ILocalUser) => {
 	// Issue query
 	const timeline = await Note
 		.find(query, {
-			limit: limit,
+			limit: ps.limit,
 			sort: sort
 		});
 
 	// Serialize
-	return await Promise.all(timeline.map(note => pack(note, user)));
+	return await packMany(timeline, user);
 };
diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts
index 2dbb1190c1..14b4432b33 100644
--- a/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -2,13 +2,12 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
 import { getFriends } from '../../common/get-friends';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 import getParams from '../../get-params';
+import { countIf } from '../../../../prelude/array';
 
 export const meta = {
-	name: 'notes/hybrid-timeline',
-
 	desc: {
 		'ja-JP': 'ハイブリッドタイムラインを取得します。'
 	},
@@ -66,23 +65,26 @@ export const meta = {
 			}
 		}),
 
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
+			}
+		}),
+
 		mediaOnly: $.bool.optional.note({
 			desc: {
-				'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
 			}
 		}),
 	}
 };
 
-/**
- * Get hybrid timeline of myself
- */
 export default async (params: any, user: ILocalUser) => {
 	const [ps, psErr] = getParams(meta, params);
 	if (psErr) throw psErr;
 
 	// Check if only one of sinceId, untilId, sinceDate, untilDate specified
-	if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) {
+	if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
 		throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
 	}
 
@@ -164,7 +166,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -180,7 +182,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -196,16 +198,16 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
 		});
 	}
 
-	if (ps.mediaOnly) {
+	if (ps.withFiles || ps.mediaOnly) {
 		query.$and.push({
-			mediaIds: { $exists: true, $ne: [] }
+			fileIds: { $exists: true, $ne: [] }
 		});
 	}
 
@@ -238,5 +240,5 @@ export default async (params: any, user: ILocalUser) => {
 		});
 
 	// Serialize
-	return await Promise.all(timeline.map(note => pack(note, user)));
+	return await packMany(timeline, user);
 };
diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts
index bbcc6303ca..8ab07d8ea7 100644
--- a/src/server/api/endpoints/notes/local-timeline.ts
+++ b/src/server/api/endpoints/notes/local-timeline.ts
@@ -1,42 +1,65 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
+import getParams from '../../get-params';
+import { countIf } from '../../../../prelude/array';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'ローカルタイムラインを取得します。'
+	},
+
+	params: {
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か'
+			}
+		}),
+
+		mediaOnly: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
+			}
+		}),
+
+		fileType: $.arr($.str).optional.note({
+			desc: {
+				'ja-JP': '指定された種類のファイルが添付された投稿のみを取得します'
+			}
+		}),
+
+		excludeNsfw: $.bool.optional.note({
+			default: false,
+			desc: {
+				'ja-JP': 'true にすると、NSFW指定されたファイルを除外します(fileTypeが指定されている場合のみ有効)'
+			}
+		}),
+
+		limit: $.num.optional.range(1, 100).note({
+			default: 10
+		}),
+
+		sinceId: $.type(ID).optional.note({}),
+
+		untilId: $.type(ID).optional.note({}),
+
+		sinceDate: $.num.optional.note({}),
+
+		untilDate: $.num.optional.note({}),
+	}
+};
 
-/**
- * Get timeline of local
- */
 export default async (params: any, user: ILocalUser) => {
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
-	if (limitErr) throw 'invalid limit param';
-
-	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
-	if (sinceIdErr) throw 'invalid sinceId param';
-
-	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
-	if (untilIdErr) throw 'invalid untilId param';
-
-	// Get 'sinceDate' parameter
-	const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
-	if (sinceDateErr) throw 'invalid sinceDate param';
-
-	// Get 'untilDate' parameter
-	const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
-	if (untilDateErr) throw 'invalid untilDate param';
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
 	// Check if only one of sinceId, untilId, sinceDate, untilDate specified
-	if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+	if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
 		throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
 	}
 
-	// Get 'mediaOnly' parameter
-	const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly);
-	if (mediaOnlyErr) throw 'invalid mediaOnly param';
-
 	// ミュートしているユーザーを取得
 	const mutedUserIds = user ? (await Mute.find({
 		muterId: user._id
@@ -69,27 +92,43 @@ export default async (params: any, user: ILocalUser) => {
 		};
 	}
 
-	if (mediaOnly) {
-		query.mediaIds = { $exists: true, $ne: [] };
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
+
+	if (withFiles) {
+		query.fileIds = { $exists: true, $ne: [] };
 	}
 
-	if (sinceId) {
+	if (ps.fileType) {
+		query.fileIds = { $exists: true, $ne: [] };
+
+		query['_files.contentType'] = {
+			$in: ps.fileType
+		};
+
+		if (ps.excludeNsfw) {
+			query['_files.metadata.isSensitive'] = {
+				$ne: true
+			};
+		}
+	}
+
+	if (ps.sinceId) {
 		sort._id = 1;
 		query._id = {
-			$gt: sinceId
+			$gt: ps.sinceId
 		};
-	} else if (untilId) {
+	} else if (ps.untilId) {
 		query._id = {
-			$lt: untilId
+			$lt: ps.untilId
 		};
-	} else if (sinceDate) {
+	} else if (ps.sinceDate) {
 		sort._id = 1;
 		query.createdAt = {
-			$gt: new Date(sinceDate)
+			$gt: new Date(ps.sinceDate)
 		};
-	} else if (untilDate) {
+	} else if (ps.untilDate) {
 		query.createdAt = {
-			$lt: new Date(untilDate)
+			$lt: new Date(ps.untilDate)
 		};
 	}
 	//#endregion
@@ -97,10 +136,10 @@ export default async (params: any, user: ILocalUser) => {
 	// Issue query
 	const timeline = await Note
 		.find(query, {
-			limit: limit,
+			limit: ps.limit,
 			sort: sort
 		});
 
 	// Serialize
-	return await Promise.all(timeline.map(note => pack(note, user)));
+	return await packMany(timeline, user);
 };
diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts
index a7fb14d8a9..592a94263d 100644
--- a/src/server/api/endpoints/notes/mentions.ts
+++ b/src/server/api/endpoints/notes/mentions.ts
@@ -1,8 +1,10 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import { getFriendIds } from '../../common/get-friends';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
+import getParams from '../../get-params';
+import read from '../../../../services/note/read';
 
 export const meta = {
 	desc: {
@@ -10,42 +12,55 @@ export const meta = {
 		'en-US': 'Get mentions of myself.'
 	},
 
-	requireCredential: true
+	requireCredential: true,
+
+	params: {
+		following: $.bool.optional.note({
+			default: false
+		}),
+
+		limit: $.num.optional.range(1, 100).note({
+			default: 10
+		}),
+
+		sinceId: $.type(ID).optional.note({
+		}),
+
+		untilId: $.type(ID).optional.note({
+		}),
+
+		visibility: $.str.optional.note({
+		}),
+	}
 };
 
 export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
-	// Get 'following' parameter
-	const [following = false, followingError] =
-		$.bool.optional.get(params.following);
-	if (followingError) return rej('invalid following param');
-
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
-	if (limitErr) return rej('invalid limit param');
-
-	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
-	if (sinceIdErr) return rej('invalid sinceId param');
-
-	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
-	if (untilIdErr) return rej('invalid untilId param');
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
 	// Check if both of sinceId and untilId is specified
-	if (sinceId && untilId) {
+	if (ps.sinceId && ps.untilId) {
 		return rej('cannot set sinceId and untilId');
 	}
 
 	// Construct query
 	const query = {
-		mentions: user._id
+		$or: [{
+			mentions: user._id
+		}, {
+			visibleUserIds: user._id
+		}]
 	} as any;
 
 	const sort = {
 		_id: -1
 	};
 
-	if (following) {
+	if (ps.visibility) {
+		query.visibility = ps.visibility;
+	}
+
+	if (ps.following) {
 		const followingIds = await getFriendIds(user._id);
 
 		query.userId = {
@@ -53,26 +68,26 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		};
 	}
 
-	if (sinceId) {
+	if (ps.sinceId) {
 		sort._id = 1;
 		query._id = {
-			$gt: sinceId
+			$gt: ps.sinceId
 		};
-	} else if (untilId) {
+	} else if (ps.untilId) {
 		query._id = {
-			$lt: untilId
+			$lt: ps.untilId
 		};
 	}
 
 	// Issue query
 	const mentions = await Note
 		.find(query, {
-			limit: limit,
+			limit: ps.limit,
 			sort: sort
 		});
 
+	mentions.forEach(note => read(user._id, note._id));
+
 	// Serialize
-	res(await Promise.all(mentions.map(async mention =>
-		await pack(mention, user)
-	)));
+	res(await packMany(mentions, user));
 });
diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts
index ab80e7f5d0..3b78d62fd3 100644
--- a/src/server/api/endpoints/notes/polls/vote.ts
+++ b/src/server/api/endpoints/notes/polls/vote.ts
@@ -72,7 +72,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		$inc: inc
 	});
 
-	publishNoteStream(note._id, 'poll_voted');
+	publishNoteStream(note._id, 'pollVoted', {
+		choice: choice,
+		userId: user._id.toHexString()
+	});
 
 	// Notify
 	notify(note.userId, user._id, 'poll_vote', {
diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts
index 0781db16c5..ec68f065d8 100644
--- a/src/server/api/endpoints/notes/reactions/create.ts
+++ b/src/server/api/endpoints/notes/reactions/create.ts
@@ -43,6 +43,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		return rej('note not found');
 	}
 
+	if (note.deletedAt != null) {
+		return rej('this not is already deleted');
+	}
+
 	try {
 		await create(user, note, ps.reaction);
 	} catch (e) {
diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts
index 44c80afc4a..b2f8f94f69 100644
--- a/src/server/api/endpoints/notes/replies.ts
+++ b/src/server/api/endpoints/notes/replies.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import Note, { pack } from '../../../../models/note';
+import Note, { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 
 /**
@@ -30,5 +30,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	const ids = (note._replyIds || []).slice(offset, offset + limit);
 
 	// Serialize
-	res(await Promise.all(ids.map(id => pack(id, user))));
+	res(await packMany(ids, user));
 });
diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts
index 05e68302ba..2c6e1a499f 100644
--- a/src/server/api/endpoints/notes/reposts.ts
+++ b/src/server/api/endpoints/notes/reposts.ts
@@ -1,5 +1,5 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
-import Note, { pack } from '../../../../models/note';
+import Note, { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 
 /**
@@ -62,6 +62,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 		});
 
 	// Serialize
-	res(await Promise.all(renotes.map(async note =>
-		await pack(note, user))));
+	res(await packMany(renotes, user));
 });
diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts
index 9124899ad8..2755a70483 100644
--- a/src/server/api/endpoints/notes/search.ts
+++ b/src/server/api/endpoints/notes/search.ts
@@ -2,7 +2,7 @@ import $ from 'cafy';
 import * as mongo from 'mongodb';
 import Note from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import es from '../../../../db/elasticsearch';
 
 export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
@@ -60,6 +60,6 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 				}
 			});
 
-		res(await Promise.all(notes.map(note => pack(note, me))));
+		res(await packMany(notes, me));
 	});
 });
diff --git a/src/server/api/endpoints/notes/search_by_tag.ts b/src/server/api/endpoints/notes/search_by_tag.ts
index e092275fe8..d380f27f9c 100644
--- a/src/server/api/endpoints/notes/search_by_tag.ts
+++ b/src/server/api/endpoints/notes/search_by_tag.ts
@@ -3,120 +3,171 @@ import Note from '../../../../models/note';
 import User, { ILocalUser } from '../../../../models/user';
 import Mute from '../../../../models/mute';
 import { getFriendIds } from '../../common/get-friends';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
+import getParams from '../../get-params';
+import { erase } from '../../../../prelude/array';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定されたタグが付けられた投稿を取得します。'
+	},
+
+	params: {
+		tag: $.str.optional.note({
+			desc: {
+				'ja-JP': 'タグ'
+			}
+		}),
+
+		query: $.arr($.arr($.str)).optional.note({
+			desc: {
+				'ja-JP': 'クエリ'
+			}
+		}),
+
+		includeUserIds: $.arr($.type(ID)).optional.note({
+			default: []
+		}),
+
+		excludeUserIds: $.arr($.type(ID)).optional.note({
+			default: []
+		}),
+
+		includeUserUsernames: $.arr($.str).optional.note({
+			default: []
+		}),
+
+		excludeUserUsernames: $.arr($.str).optional.note({
+			default: []
+		}),
+
+		following: $.bool.optional.nullable.note({
+			default: null
+		}),
+
+		mute: $.str.optional.note({
+			default: 'mute_all'
+		}),
+
+		reply: $.bool.optional.nullable.note({
+			default: null,
+
+			desc: {
+				'ja-JP': '返信に限定するか否か'
+			}
+		}),
+
+		renote: $.bool.optional.nullable.note({
+			default: null,
+
+			desc: {
+				'ja-JP': 'Renoteに限定するか否か'
+			}
+		}),
+
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
+			}
+		}),
+
+		media: $.bool.optional.nullable.note({
+			default: null,
+
+			desc: {
+				'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
+			}
+		}),
+
+		poll: $.bool.optional.nullable.note({
+			default: null,
+
+			desc: {
+				'ja-JP': 'アンケートが添付された投稿に限定するか否か'
+			}
+		}),
+
+		untilId: $.type(ID).optional.note({
+			desc: {
+				'ja-JP': '指定すると、この投稿を基点としてより古い投稿を取得します'
+			}
+		}),
+
+		sinceDate: $.num.optional.note({
+		}),
+
+		untilDate: $.num.optional.note({
+		}),
+
+		offset: $.num.optional.min(0).note({
+			default: 0
+		}),
+
+		limit: $.num.optional.range(1, 30).note({
+			default: 10
+		}),
+	}
+};
 
-/**
- * Search notes by tag
- */
 export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
-	// Get 'tag' parameter
-	const [tag, tagError] = $.str.get(params.tag);
-	if (tagError) return rej('invalid tag param');
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
-	// Get 'includeUserIds' parameter
-	const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional.get(params.includeUserIds);
-	if (includeUserIdsErr) return rej('invalid includeUserIds param');
-
-	// Get 'excludeUserIds' parameter
-	const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional.get(params.excludeUserIds);
-	if (excludeUserIdsErr) return rej('invalid excludeUserIds param');
-
-	// Get 'includeUserUsernames' parameter
-	const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional.get(params.includeUserUsernames);
-	if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param');
-
-	// Get 'excludeUserUsernames' parameter
-	const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional.get(params.excludeUserUsernames);
-	if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param');
-
-	// Get 'following' parameter
-	const [following = null, followingErr] = $.bool.optional.nullable.get(params.following);
-	if (followingErr) return rej('invalid following param');
-
-	// Get 'mute' parameter
-	const [mute = 'mute_all', muteErr] = $.str.optional.get(params.mute);
-	if (muteErr) return rej('invalid mute param');
-
-	// Get 'reply' parameter
-	const [reply = null, replyErr] = $.bool.optional.nullable.get(params.reply);
-	if (replyErr) return rej('invalid reply param');
-
-	// Get 'renote' parameter
-	const [renote = null, renoteErr] = $.bool.optional.nullable.get(params.renote);
-	if (renoteErr) return rej('invalid renote param');
-
-	// Get 'media' parameter
-	const [media = null, mediaErr] = $.bool.optional.nullable.get(params.media);
-	if (mediaErr) return rej('invalid media param');
-
-	// Get 'poll' parameter
-	const [poll = null, pollErr] = $.bool.optional.nullable.get(params.poll);
-	if (pollErr) return rej('invalid poll param');
-
-	// Get 'sinceDate' parameter
-	const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
-	if (sinceDateErr) throw 'invalid sinceDate param';
-
-	// Get 'untilDate' parameter
-	const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
-	if (untilDateErr) throw 'invalid untilDate param';
-
-	// Get 'offset' parameter
-	const [offset = 0, offsetErr] = $.num.optional.min(0).get(params.offset);
-	if (offsetErr) return rej('invalid offset param');
-
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 30).get(params.limit);
-	if (limitErr) return rej('invalid limit param');
-
-	if (includeUserUsernames != null) {
-		const ids = (await Promise.all(includeUserUsernames.map(async (username) => {
+	if (ps.includeUserUsernames != null) {
+		const ids = erase(null, await Promise.all(ps.includeUserUsernames.map(async (username) => {
 			const _user = await User.findOne({
 				usernameLower: username.toLowerCase()
 			});
 			return _user ? _user._id : null;
-		}))).filter(id => id != null);
+		})));
 
-		ids.forEach(id => includeUserIds.push(id));
+		ids.forEach(id => ps.includeUserIds.push(id));
 	}
 
-	if (excludeUserUsernames != null) {
-		const ids = (await Promise.all(excludeUserUsernames.map(async (username) => {
+	if (ps.excludeUserUsernames != null) {
+		const ids = erase(null, await Promise.all(ps.excludeUserUsernames.map(async (username) => {
 			const _user = await User.findOne({
 				usernameLower: username.toLowerCase()
 			});
 			return _user ? _user._id : null;
-		}))).filter(id => id != null);
+		})));
 
-		ids.forEach(id => excludeUserIds.push(id));
+		ids.forEach(id => ps.excludeUserIds.push(id));
 	}
 
-	let q: any = {
-		$and: [{
-			tagsLower: tag.toLowerCase()
-		}]
+	const q: any = {
+		$and: [ps.tag ? {
+			tagsLower: ps.tag.toLowerCase()
+		} : {
+			$or: ps.query.map(tags => ({
+				$and: tags.map(t => ({
+					tagsLower: t.toLowerCase()
+				}))
+			}))
+		}],
+		deletedAt: { $exists: false }
 	};
 
 	const push = (x: any) => q.$and.push(x);
 
-	if (includeUserIds && includeUserIds.length != 0) {
+	if (ps.includeUserIds && ps.includeUserIds.length != 0) {
 		push({
 			userId: {
-				$in: includeUserIds
+				$in: ps.includeUserIds
 			}
 		});
-	} else if (excludeUserIds && excludeUserIds.length != 0) {
+	} else if (ps.excludeUserIds && ps.excludeUserIds.length != 0) {
 		push({
 			userId: {
-				$nin: excludeUserIds
+				$nin: ps.excludeUserIds
 			}
 		});
 	}
 
-	if (following != null && me != null) {
+	if (ps.following != null && me != null) {
 		const ids = await getFriendIds(me._id, false);
 		push({
-			userId: following ? {
+			userId: ps.following ? {
 				$in: ids
 			} : {
 				$nin: ids.concat(me._id)
@@ -131,7 +182,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		});
 		const mutedUserIds = mutes.map(m => m.muteeId);
 
-		switch (mute) {
+		switch (ps.mute) {
 			case 'mute_all':
 				push({
 					userId: {
@@ -202,8 +253,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		}
 	}
 
-	if (reply != null) {
-		if (reply) {
+	if (ps.reply != null) {
+		if (ps.reply) {
 			push({
 				replyId: {
 					$exists: true,
@@ -223,8 +274,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		}
 	}
 
-	if (renote != null) {
-		if (renote) {
+	if (ps.renote != null) {
+		if (ps.renote) {
 			push({
 				renoteId: {
 					$exists: true,
@@ -244,29 +295,16 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		}
 	}
 
-	if (media != null) {
-		if (media) {
-			push({
-				mediaIds: {
-					$exists: true,
-					$ne: null
-				}
-			});
-		} else {
-			push({
-				$or: [{
-					mediaIds: {
-						$exists: false
-					}
-				}, {
-					mediaIds: null
-				}]
-			});
-		}
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.media;
+
+	if (withFiles) {
+		push({
+			fileIds: { $exists: true, $ne: [] }
+		});
 	}
 
-	if (poll != null) {
-		if (poll) {
+	if (ps.poll != null) {
+		if (ps.poll) {
 			push({
 				poll: {
 					$exists: true,
@@ -286,24 +324,32 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		}
 	}
 
-	if (sinceDate) {
+	if (ps.untilId) {
 		push({
-			createdAt: {
-				$gt: new Date(sinceDate)
+			_id: {
+				$lt: ps.untilId
 			}
 		});
 	}
 
-	if (untilDate) {
+	if (ps.sinceDate) {
 		push({
 			createdAt: {
-				$lt: new Date(untilDate)
+				$gt: new Date(ps.sinceDate)
+			}
+		});
+	}
+
+	if (ps.untilDate) {
+		push({
+			createdAt: {
+				$lt: new Date(ps.untilDate)
 			}
 		});
 	}
 
 	if (q.$and.length == 0) {
-		q = {};
+		delete q.$and;
 	}
 
 	// Search notes
@@ -312,10 +358,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 			sort: {
 				_id: -1
 			},
-			limit: limit,
-			skip: offset
+			limit: ps.limit,
+			skip: ps.offset
 		});
 
 	// Serialize
-	res(await Promise.all(notes.map(note => pack(note, me))));
+	res(await packMany(notes, me));
 });
diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts
index 099bf2010b..44a504eb18 100644
--- a/src/server/api/endpoints/notes/timeline.ts
+++ b/src/server/api/endpoints/notes/timeline.ts
@@ -2,9 +2,10 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
 import { getFriends } from '../../common/get-friends';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import { ILocalUser } from '../../../../models/user';
 import getParams from '../../get-params';
+import { countIf } from '../../../../prelude/array';
 
 export const meta = {
 	desc: {
@@ -67,9 +68,15 @@ export const meta = {
 			}
 		}),
 
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
+			}
+		}),
+
 		mediaOnly: $.bool.optional.note({
 			desc: {
-				'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
 			}
 		}),
 	}
@@ -80,7 +87,7 @@ export default async (params: any, user: ILocalUser) => {
 	if (psErr) throw psErr;
 
 	// Check if only one of sinceId, untilId, sinceDate, untilDate specified
-	if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) {
+	if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
 		throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
 	}
 
@@ -154,7 +161,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -170,7 +177,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -186,16 +193,18 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
 		});
 	}
 
-	if (ps.mediaOnly) {
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
+
+	if (withFiles) {
 		query.$and.push({
-			mediaIds: { $exists: true, $ne: [] }
+			fileIds: { $exists: true, $ne: [] }
 		});
 	}
 
@@ -228,5 +237,5 @@ export default async (params: any, user: ILocalUser) => {
 		});
 
 	// Serialize
-	return await Promise.all(timeline.map(note => pack(note, user)));
+	return await packMany(timeline, user);
 };
diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts
index 7a0a098f28..9f55ed3243 100644
--- a/src/server/api/endpoints/notes/trend.ts
+++ b/src/server/api/endpoints/notes/trend.ts
@@ -52,7 +52,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	}
 
 	if (media != undefined) {
-		query.mediaIds = media ? { $exists: true, $ne: null } : null;
+		query.fileIds = media ? { $exists: true, $ne: null } : null;
 	}
 
 	if (poll != undefined) {
diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts
index a7b43014ed..6758b4eb73 100644
--- a/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import Note from '../../../../models/note';
 import Mute from '../../../../models/mute';
-import { pack } from '../../../../models/note';
+import { packMany } from '../../../../models/note';
 import UserList from '../../../../models/user-list';
 import { ILocalUser } from '../../../../models/user';
 import getParams from '../../get-params';
@@ -73,9 +73,15 @@ export const meta = {
 			}
 		}),
 
+		withFiles: $.bool.optional.note({
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
+			}
+		}),
+
 		mediaOnly: $.bool.optional.note({
 			desc: {
-				'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
 			}
 		}),
 	}
@@ -160,7 +166,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -176,7 +182,7 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
@@ -192,16 +198,18 @@ export default async (params: any, user: ILocalUser) => {
 			}, {
 				text: { $ne: null }
 			}, {
-				mediaIds: { $ne: [] }
+				fileIds: { $ne: [] }
 			}, {
 				poll: { $ne: null }
 			}]
 		});
 	}
 
-	if (ps.mediaOnly) {
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
+
+	if (withFiles) {
 		query.$and.push({
-			mediaIds: { $exists: true, $ne: [] }
+			fileIds: { $exists: true, $ne: [] }
 		});
 	}
 
@@ -234,5 +242,5 @@ export default async (params: any, user: ILocalUser) => {
 		});
 
 	// Serialize
-	return await Promise.all(timeline.map(note => pack(note, user)));
+	return await packMany(timeline, user);
 };
diff --git a/src/server/api/endpoints/notifications/mark_all_as_read.ts b/src/server/api/endpoints/notifications/mark_all_as_read.ts
index e2bde777b3..6487cd8b48 100644
--- a/src/server/api/endpoints/notifications/mark_all_as_read.ts
+++ b/src/server/api/endpoints/notifications/mark_all_as_read.ts
@@ -1,5 +1,5 @@
 import Notification from '../../../../models/notification';
-import { publishUserStream } from '../../../../stream';
+import { publishMainStream } from '../../../../stream';
 import User, { ILocalUser } from '../../../../models/user';
 
 export const meta = {
@@ -40,5 +40,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
 	});
 
 	// 全ての通知を読みましたよというイベントを発行
-	publishUserStream(user._id, 'read_all_notifications');
+	publishMainStream(user._id, 'readAllNotifications');
 });
diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts
index 3414600048..503fc94654 100644
--- a/src/server/api/endpoints/sw/register.ts
+++ b/src/server/api/endpoints/sw/register.ts
@@ -1,6 +1,7 @@
 import $ from 'cafy';
 import Subscription from '../../../../models/sw-subscription';
 import { ILocalUser } from '../../../../models/user';
+import config from '../../../../config';
 
 export const meta = {
 	requireCredential: true
@@ -31,8 +32,11 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 		deletedAt: { $exists: false }
 	});
 
-	if (exist !== null) {
-		return res();
+	if (exist != null) {
+		return res({
+			state: 'already-subscribed',
+			key: config.sw.public_key
+		});
 	}
 
 	await Subscription.insert({
@@ -42,5 +46,8 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
 		publickey: publickey
 	});
 
-	res();
+	res({
+		state: 'subscribed',
+		key: config.sw.public_key
+	});
 });
diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts
index 9411873573..7fe3ca9943 100644
--- a/src/server/api/endpoints/users/followers.ts
+++ b/src/server/api/endpoints/users/followers.ts
@@ -73,8 +73,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 	}
 
 	// Serialize
-	const users = await Promise.all(following.map(async f =>
-		await pack(f.followerId, me, { detail: true })));
+	const users = await Promise.all(following.map(f => pack(f.followerId, me, { detail: true })));
 
 	// Response
 	res({
diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts
index 7a64d15d7b..0e564fd1b6 100644
--- a/src/server/api/endpoints/users/following.ts
+++ b/src/server/api/endpoints/users/following.ts
@@ -73,8 +73,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 	}
 
 	// Serialize
-	const users = await Promise.all(following.map(async f =>
-		await pack(f.followeeId, me, { detail: true })));
+	const users = await Promise.all(following.map(f => pack(f.followeeId, me, { detail: true })));
 
 	// Response
 	res({
diff --git a/src/server/api/endpoints/users/lists/delete.ts b/src/server/api/endpoints/users/lists/delete.ts
new file mode 100644
index 0000000000..906534922e
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/delete.ts
@@ -0,0 +1,43 @@
+import $ from 'cafy';
+import ID from '../../../../../misc/cafy-id';
+import UserList, { deleteUserList } from '../../../../../models/user-list';
+import { ILocalUser } from '../../../../../models/user';
+import getParams from '../../../get-params';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーリストを削除します。',
+		'en-US': 'Delete a user list'
+	},
+
+	requireCredential: true,
+
+	kind: 'account-write',
+
+	params: {
+		listId: $.type(ID).note({
+			desc: {
+				'ja-JP': '対象となるユーザーリストのID',
+				'en-US': 'ID of target user list'
+			}
+		})
+	}
+};
+
+export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) return rej(psErr);
+
+	const userList = await UserList.findOne({
+		_id: ps.listId,
+		userId: user._id
+	});
+
+	if (userList == null) {
+		return rej('list not found');
+	}
+
+	deleteUserList(userList);
+
+	res();
+});
diff --git a/src/server/api/endpoints/users/lists/update.ts b/src/server/api/endpoints/users/lists/update.ts
new file mode 100644
index 0000000000..e6577eca4f
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/update.ts
@@ -0,0 +1,56 @@
+import $ from 'cafy';
+import ID from '../../../../../misc/cafy-id';
+import UserList, { pack } from '../../../../../models/user-list';
+import { ILocalUser } from '../../../../../models/user';
+import getParams from '../../../get-params';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーリストを更新します。',
+		'en-US': 'Update a user list'
+	},
+
+	requireCredential: true,
+
+	kind: 'account-write',
+
+	params: {
+		listId: $.type(ID).note({
+			desc: {
+				'ja-JP': '対象となるユーザーリストのID',
+				'en-US': 'ID of target user list'
+			}
+		}),
+		title: $.str.range(1, 100).note({
+			desc: {
+				'ja-JP': 'このユーザーリストの名前',
+				'en-US': 'name of this user list'
+			}
+		})
+	}
+};
+
+export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => {
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
+
+	// Fetch the list
+	const userList = await UserList.findOne({
+		_id: ps.listId,
+		userId: user._id
+	});
+
+	if (userList == null) {
+		return rej('list not found');
+	}
+
+	// update
+	await UserList.update({ _id: userList._id }, {
+		$set: {
+			title: ps.title
+		}
+	});
+
+	// Response
+	res(await pack(userList._id));
+});
diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts
index ff7855bde0..1bfe832c51 100644
--- a/src/server/api/endpoints/users/notes.ts
+++ b/src/server/api/endpoints/users/notes.ts
@@ -1,64 +1,123 @@
 import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
 import getHostLower from '../../common/get-host-lower';
-import Note, { pack } from '../../../../models/note';
+import Note, { packMany } from '../../../../models/note';
 import User, { ILocalUser } from '../../../../models/user';
+import getParams from '../../get-params';
+import { countIf } from '../../../../prelude/array';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーのタイムラインを取得します。'
+	},
+
+	params: {
+		userId: $.type(ID).optional.note({
+			desc: {
+				'ja-JP': 'ユーザーID'
+			}
+		}),
+
+		username: $.str.optional.note({
+			desc: {
+				'ja-JP': 'ユーザー名'
+			}
+		}),
+
+		host: $.str.optional.note({
+		}),
+
+		includeReplies: $.bool.optional.note({
+			default: true,
+
+			desc: {
+				'ja-JP': 'リプライを含めるか否か'
+			}
+		}),
+
+		limit: $.num.optional.range(1, 100).note({
+			default: 10,
+			desc: {
+				'ja-JP': '最大数'
+			}
+		}),
+
+		sinceId: $.type(ID).optional.note({
+			desc: {
+				'ja-JP': '指定すると、この投稿を基点としてより新しい投稿を取得します'
+			}
+		}),
+
+		untilId: $.type(ID).optional.note({
+			desc: {
+				'ja-JP': '指定すると、この投稿を基点としてより古い投稿を取得します'
+			}
+		}),
+
+		sinceDate: $.num.optional.note({
+			desc: {
+				'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
+			}
+		}),
+
+		untilDate: $.num.optional.note({
+			desc: {
+				'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
+			}
+		}),
+
+		includeMyRenotes: $.bool.optional.note({
+			default: true,
+			desc: {
+				'ja-JP': '自分の行ったRenoteを含めるかどうか'
+			}
+		}),
+
+		includeRenotedMyNotes: $.bool.optional.note({
+			default: true,
+			desc: {
+				'ja-JP': 'Renoteされた自分の投稿を含めるかどうか'
+			}
+		}),
+
+		includeLocalRenotes: $.bool.optional.note({
+			default: true,
+			desc: {
+				'ja-JP': 'Renoteされたローカルの投稿を含めるかどうか'
+			}
+		}),
+
+		withFiles: $.bool.optional.note({
+			default: false,
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
+			}
+		}),
+
+		mediaOnly: $.bool.optional.note({
+			default: false,
+			desc: {
+				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
+			}
+		}),
+	}
+};
 
-/**
- * Get notes of a user
- */
 export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
-	// Get 'userId' parameter
-	const [userId, userIdErr] = $.type(ID).optional.get(params.userId);
-	if (userIdErr) return rej('invalid userId param');
+	const [ps, psErr] = getParams(meta, params);
+	if (psErr) throw psErr;
 
-	// Get 'username' parameter
-	const [username, usernameErr] = $.str.optional.get(params.username);
-	if (usernameErr) return rej('invalid username param');
-
-	if (userId === undefined && username === undefined) {
+	if (ps.userId === undefined && ps.username === undefined) {
 		return rej('userId or username is required');
 	}
 
-	// Get 'host' parameter
-	const [host, hostErr] = $.str.optional.get(params.host);
-	if (hostErr) return rej('invalid host param');
-
-	// Get 'includeReplies' parameter
-	const [includeReplies = true, includeRepliesErr] = $.bool.optional.get(params.includeReplies);
-	if (includeRepliesErr) return rej('invalid includeReplies param');
-
-	// Get 'withMedia' parameter
-	const [withMedia = false, withMediaErr] = $.bool.optional.get(params.withMedia);
-	if (withMediaErr) return rej('invalid withMedia param');
-
-	// Get 'limit' parameter
-	const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
-	if (limitErr) return rej('invalid limit param');
-
-	// Get 'sinceId' parameter
-	const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
-	if (sinceIdErr) return rej('invalid sinceId param');
-
-	// Get 'untilId' parameter
-	const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
-	if (untilIdErr) return rej('invalid untilId param');
-
-	// Get 'sinceDate' parameter
-	const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
-	if (sinceDateErr) throw 'invalid sinceDate param';
-
-	// Get 'untilDate' parameter
-	const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
-	if (untilDateErr) throw 'invalid untilDate param';
-
 	// Check if only one of sinceId, untilId, sinceDate, untilDate specified
-	if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+	if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
 		throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
 	}
 
-	const q = userId !== undefined
-		? { _id: userId }
-		: { usernameLower: username.toLowerCase(), host: getHostLower(host) } ;
+	const q = ps.userId !== undefined
+		? { _id: ps.userId }
+		: { usernameLower: ps.username.toLowerCase(), host: getHostLower(ps.host) } ;
 
 	// Lookup user
 	const user = await User.findOne(q, {
@@ -80,32 +139,34 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		userId: user._id
 	} as any;
 
-	if (sinceId) {
+	if (ps.sinceId) {
 		sort._id = 1;
 		query._id = {
-			$gt: sinceId
+			$gt: ps.sinceId
 		};
-	} else if (untilId) {
+	} else if (ps.untilId) {
 		query._id = {
-			$lt: untilId
+			$lt: ps.untilId
 		};
-	} else if (sinceDate) {
+	} else if (ps.sinceDate) {
 		sort._id = 1;
 		query.createdAt = {
-			$gt: new Date(sinceDate)
+			$gt: new Date(ps.sinceDate)
 		};
-	} else if (untilDate) {
+	} else if (ps.untilDate) {
 		query.createdAt = {
-			$lt: new Date(untilDate)
+			$lt: new Date(ps.untilDate)
 		};
 	}
 
-	if (!includeReplies) {
+	if (!ps.includeReplies) {
 		query.replyId = null;
 	}
 
-	if (withMedia) {
-		query.mediaIds = {
+	const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
+
+	if (withFiles) {
+		query.fileIds = {
 			$exists: true,
 			$ne: []
 		};
@@ -115,12 +176,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 	// Issue query
 	const notes = await Note
 		.find(query, {
-			limit: limit,
+			limit: ps.limit,
 			sort: sort
 		});
 
 	// Serialize
-	res(await Promise.all(notes.map(async (note) =>
-		await pack(note, me)
-	)));
+	res(await packMany(notes, me));
 });
diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts
index c42fb7bd8c..0e44c2ddd6 100644
--- a/src/server/api/private/signin.ts
+++ b/src/server/api/private/signin.ts
@@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs';
 import * as speakeasy from 'speakeasy';
 import User, { ILocalUser } from '../../../models/user';
 import Signin, { pack } from '../../../models/signin';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 import signin from '../common/signin';
 import config from '../../../config';
 
@@ -87,5 +87,5 @@ export default async (ctx: Koa.Context) => {
 	});
 
 	// Publish signin event
-	publishUserStream(user._id, 'signin', await pack(record));
+	publishMainStream(user._id, 'signin', await pack(record));
 };
diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts
index aad2846bb4..f71e588628 100644
--- a/src/server/api/service/twitter.ts
+++ b/src/server/api/service/twitter.ts
@@ -4,7 +4,7 @@ import * as uuid from 'uuid';
 import autwh from 'autwh';
 import redis from '../../../db/redis';
 import User, { pack, ILocalUser } from '../../../models/user';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 import config from '../../../config';
 import signin from '../common/signin';
 
@@ -49,7 +49,7 @@ router.get('/disconnect/twitter', async ctx => {
 	ctx.body = `Twitterの連携を解除しました :v:`;
 
 	// Publish i updated event
-	publishUserStream(user._id, 'meUpdated', await pack(user, user, {
+	publishMainStream(user._id, 'meUpdated', await pack(user, user, {
 		detail: true,
 		includeSecrets: true
 	}));
@@ -174,7 +174,7 @@ if (config.twitter == null) {
 			ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
 
 			// Publish i updated event
-			publishUserStream(user._id, 'meUpdated', await pack(user, user, {
+			publishMainStream(user._id, 'meUpdated', await pack(user, user, {
 				detail: true,
 				includeSecrets: true
 			}));
diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts
new file mode 100644
index 0000000000..e2726060dc
--- /dev/null
+++ b/src/server/api/stream/channel.ts
@@ -0,0 +1,39 @@
+import autobind from 'autobind-decorator';
+import Connection from '.';
+
+/**
+ * Stream channel
+ */
+export default abstract class Channel {
+	protected connection: Connection;
+	public id: string;
+
+	protected get user() {
+		return this.connection.user;
+	}
+
+	protected get subscriber() {
+		return this.connection.subscriber;
+	}
+
+	constructor(id: string, connection: Connection) {
+		this.id = id;
+		this.connection = connection;
+	}
+
+	@autobind
+	public send(typeOrPayload: any, payload?: any) {
+		const type = payload === undefined ? typeOrPayload.type : typeOrPayload;
+		const body = payload === undefined ? typeOrPayload.body : payload;
+
+		this.connection.sendMessageToWs('channel', {
+			id: this.id,
+			type: type,
+			body: body
+		});
+	}
+
+	public abstract init(params: any): void;
+	public dispose?(): void;
+	public onMessage?(type: string, body: any): void;
+}
diff --git a/src/server/api/stream/channels/drive.ts b/src/server/api/stream/channels/drive.ts
new file mode 100644
index 0000000000..807fc93cd0
--- /dev/null
+++ b/src/server/api/stream/channels/drive.ts
@@ -0,0 +1,12 @@
+import autobind from 'autobind-decorator';
+import Channel from '../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		// Subscribe drive stream
+		this.subscriber.on(`driveStream:${this.user._id}`, data => {
+			this.send(data);
+		});
+	}
+}
diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts
new file mode 100644
index 0000000000..11f1fb1feb
--- /dev/null
+++ b/src/server/api/stream/channels/games/reversi-game.ts
@@ -0,0 +1,309 @@
+import autobind from 'autobind-decorator';
+import * as CRC32 from 'crc-32';
+import ReversiGame, { pack } from '../../../../../models/games/reversi/game';
+import { publishReversiGameStream } from '../../../../../stream';
+import Reversi from '../../../../../games/reversi/core';
+import * as maps from '../../../../../games/reversi/maps';
+import Channel from '../../channel';
+
+export default class extends Channel {
+	private gameId: string;
+
+	@autobind
+	public async init(params: any) {
+		this.gameId = params.gameId as string;
+
+		// Subscribe game stream
+		this.subscriber.on(`reversiGameStream:${this.gameId}`, data => {
+			this.send(data);
+		});
+	}
+
+	@autobind
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'accept': this.accept(true); break;
+			case 'cancel-accept': this.accept(false); break;
+			case 'update-settings': this.updateSettings(body.settings); break;
+			case 'init-form': this.initForm(body); break;
+			case 'update-form': this.updateForm(body.id, body.value); break;
+			case 'message': this.message(body); break;
+			case 'set': this.set(body.pos); break;
+			case 'check': this.check(body.crc32); break;
+		}
+	}
+
+	@autobind
+	private async updateSettings(settings: any) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (game.isStarted) return;
+		if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return;
+		if (game.user1Id.equals(this.user._id) && game.user1Accepted) return;
+		if (game.user2Id.equals(this.user._id) && game.user2Accepted) return;
+
+		await ReversiGame.update({ _id: this.gameId }, {
+			$set: {
+				settings
+			}
+		});
+
+		publishReversiGameStream(this.gameId, 'updateSettings', settings);
+	}
+
+	@autobind
+	private async initForm(form: any) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (game.isStarted) return;
+		if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return;
+
+		const set = game.user1Id.equals(this.user._id) ? {
+			form1: form
+		} : {
+				form2: form
+			};
+
+		await ReversiGame.update({ _id: this.gameId }, {
+			$set: set
+		});
+
+		publishReversiGameStream(this.gameId, 'initForm', {
+			userId: this.user._id,
+			form
+		});
+	}
+
+	@autobind
+	private async updateForm(id: string, value: any) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (game.isStarted) return;
+		if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return;
+
+		const form = game.user1Id.equals(this.user._id) ? game.form2 : game.form1;
+
+		const item = form.find((i: any) => i.id == id);
+
+		if (item == null) return;
+
+		item.value = value;
+
+		const set = game.user1Id.equals(this.user._id) ? {
+			form2: form
+		} : {
+				form1: form
+			};
+
+		await ReversiGame.update({ _id: this.gameId }, {
+			$set: set
+		});
+
+		publishReversiGameStream(this.gameId, 'updateForm', {
+			userId: this.user._id,
+			id,
+			value
+		});
+	}
+
+	@autobind
+	private async message(message: any) {
+		message.id = Math.random();
+		publishReversiGameStream(this.gameId, 'message', {
+			userId: this.user._id,
+			message
+		});
+	}
+
+	@autobind
+	private async accept(accept: boolean) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (game.isStarted) return;
+
+		let bothAccepted = false;
+
+		if (game.user1Id.equals(this.user._id)) {
+			await ReversiGame.update({ _id: this.gameId }, {
+				$set: {
+					user1Accepted: accept
+				}
+			});
+
+			publishReversiGameStream(this.gameId, 'changeAccepts', {
+				user1: accept,
+				user2: game.user2Accepted
+			});
+
+			if (accept && game.user2Accepted) bothAccepted = true;
+		} else if (game.user2Id.equals(this.user._id)) {
+			await ReversiGame.update({ _id: this.gameId }, {
+				$set: {
+					user2Accepted: accept
+				}
+			});
+
+			publishReversiGameStream(this.gameId, 'changeAccepts', {
+				user1: game.user1Accepted,
+				user2: accept
+			});
+
+			if (accept && game.user1Accepted) bothAccepted = true;
+		} else {
+			return;
+		}
+
+		if (bothAccepted) {
+			// 3秒後、まだacceptされていたらゲーム開始
+			setTimeout(async () => {
+				const freshGame = await ReversiGame.findOne({ _id: this.gameId });
+				if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
+				if (!freshGame.user1Accepted || !freshGame.user2Accepted) return;
+
+				let bw: number;
+				if (freshGame.settings.bw == 'random') {
+					bw = Math.random() > 0.5 ? 1 : 2;
+				} else {
+					bw = freshGame.settings.bw as number;
+				}
+
+				function getRandomMap() {
+					const mapCount = Object.entries(maps).length;
+					const rnd = Math.floor(Math.random() * mapCount);
+					return Object.values(maps)[rnd].data;
+				}
+
+				const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap();
+
+				await ReversiGame.update({ _id: this.gameId }, {
+					$set: {
+						startedAt: new Date(),
+						isStarted: true,
+						black: bw,
+						'settings.map': map
+					}
+				});
+
+				//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
+				const o = new Reversi(map, {
+					isLlotheo: freshGame.settings.isLlotheo,
+					canPutEverywhere: freshGame.settings.canPutEverywhere,
+					loopedBoard: freshGame.settings.loopedBoard
+				});
+
+				if (o.isEnded) {
+					let winner;
+					if (o.winner === true) {
+						winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id;
+					} else if (o.winner === false) {
+						winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id;
+					} else {
+						winner = null;
+					}
+
+					await ReversiGame.update({
+						_id: this.gameId
+					}, {
+							$set: {
+								isEnded: true,
+								winnerId: winner
+							}
+						});
+
+					publishReversiGameStream(this.gameId, 'ended', {
+						winnerId: winner,
+						game: await pack(this.gameId, this.user)
+					});
+				}
+				//#endregion
+
+				publishReversiGameStream(this.gameId, 'started', await pack(this.gameId, this.user));
+			}, 3000);
+		}
+	}
+
+	// 石を打つ
+	@autobind
+	private async set(pos: number) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (!game.isStarted) return;
+		if (game.isEnded) return;
+		if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return;
+
+		const o = new Reversi(game.settings.map, {
+			isLlotheo: game.settings.isLlotheo,
+			canPutEverywhere: game.settings.canPutEverywhere,
+			loopedBoard: game.settings.loopedBoard
+		});
+
+		game.logs.forEach(log => {
+			o.put(log.color, log.pos);
+		});
+
+		const myColor =
+			(game.user1Id.equals(this.user._id) && game.black == 1) || (game.user2Id.equals(this.user._id) && game.black == 2)
+				? true
+				: false;
+
+		if (!o.canPut(myColor, pos)) return;
+		o.put(myColor, pos);
+
+		let winner;
+		if (o.isEnded) {
+			if (o.winner === true) {
+				winner = game.black == 1 ? game.user1Id : game.user2Id;
+			} else if (o.winner === false) {
+				winner = game.black == 1 ? game.user2Id : game.user1Id;
+			} else {
+				winner = null;
+			}
+		}
+
+		const log = {
+			at: new Date(),
+			color: myColor,
+			pos
+		};
+
+		const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString());
+
+		await ReversiGame.update({
+			_id: this.gameId
+		}, {
+				$set: {
+					crc32,
+					isEnded: o.isEnded,
+					winnerId: winner
+				},
+				$push: {
+					logs: log
+				}
+			});
+
+		publishReversiGameStream(this.gameId, 'set', Object.assign(log, {
+			next: o.turn
+		}));
+
+		if (o.isEnded) {
+			publishReversiGameStream(this.gameId, 'ended', {
+				winnerId: winner,
+				game: await pack(this.gameId, this.user)
+			});
+		}
+	}
+
+	@autobind
+	private async check(crc32: string) {
+		const game = await ReversiGame.findOne({ _id: this.gameId });
+
+		if (!game.isStarted) return;
+
+		// 互換性のため
+		if (game.crc32 == null) return;
+
+		if (crc32 !== game.crc32) {
+			this.send('rescue', await pack(game, this.user));
+		}
+	}
+}
diff --git a/src/server/api/stream/channels/games/reversi.ts b/src/server/api/stream/channels/games/reversi.ts
new file mode 100644
index 0000000000..d75025c944
--- /dev/null
+++ b/src/server/api/stream/channels/games/reversi.ts
@@ -0,0 +1,30 @@
+import autobind from 'autobind-decorator';
+import * as mongo from 'mongodb';
+import Matching, { pack } from '../../../../../models/games/reversi/matching';
+import { publishMainStream } from '../../../../../stream';
+import Channel from '../../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		// Subscribe reversi stream
+		this.subscriber.on(`reversiStream:${this.user._id}`, data => {
+			this.send(data);
+		});
+	}
+
+	@autobind
+	public async onMessage(type: string, body: any) {
+		switch (type) {
+			case 'ping':
+				if (body.id == null) return;
+				const matching = await Matching.findOne({
+					parentId: this.user._id,
+					childId: new mongo.ObjectID(body.id)
+				});
+				if (matching == null) return;
+				publishMainStream(matching.childId, 'reversiInvited', await pack(matching, matching.childId));
+				break;
+		}
+	}
+}
diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts
new file mode 100644
index 0000000000..ab0fe5d094
--- /dev/null
+++ b/src/server/api/stream/channels/global-timeline.ts
@@ -0,0 +1,39 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
+import Channel from '../channel';
+
+export default class extends Channel {
+	private mutedUserIds: string[] = [];
+
+	@autobind
+	public async init(params: any) {
+		// Subscribe events
+		this.subscriber.on('globalTimeline', this.onNote);
+
+		const mute = await Mute.find({ muterId: this.user._id });
+		this.mutedUserIds = mute.map(m => m.muteeId.toString());
+	}
+
+	@autobind
+	private async onNote(note: any) {
+		// Renoteなら再pack
+		if (note.renoteId != null) {
+			note.renote = await pack(note.renoteId, this.user, {
+				detail: true
+			});
+		}
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (shouldMuteThisNote(note, this.mutedUserIds)) return;
+
+		this.send('note', note);
+	}
+
+	@autobind
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off('globalTimeline', this.onNote);
+	}
+}
diff --git a/src/server/api/stream/channels/hashtag.ts b/src/server/api/stream/channels/hashtag.ts
new file mode 100644
index 0000000000..652b0caa5b
--- /dev/null
+++ b/src/server/api/stream/channels/hashtag.ts
@@ -0,0 +1,33 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
+import Channel from '../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		const mute = this.user ? await Mute.find({ muterId: this.user._id }) : null;
+		const mutedUserIds = mute ? mute.map(m => m.muteeId.toString()) : [];
+
+		const q: Array<string[]> = params.q;
+
+		// Subscribe stream
+		this.subscriber.on('hashtag', async note => {
+			const matched = q.some(tags => tags.every(tag => note.tags.map((t: string) => t.toLowerCase()).includes(tag.toLowerCase())));
+			if (!matched) return;
+
+			// Renoteなら再pack
+			if (note.renoteId != null) {
+				note.renote = await pack(note.renoteId, this.user, {
+					detail: true
+				});
+			}
+
+			// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+			if (shouldMuteThisNote(note, mutedUserIds)) return;
+
+			this.send('note', note);
+		});
+	}
+}
diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts
new file mode 100644
index 0000000000..4c674e75ef
--- /dev/null
+++ b/src/server/api/stream/channels/home-timeline.ts
@@ -0,0 +1,39 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
+import Channel from '../channel';
+
+export default class extends Channel {
+	private mutedUserIds: string[] = [];
+
+	@autobind
+	public async init(params: any) {
+		// Subscribe events
+		this.subscriber.on(`homeTimeline:${this.user._id}`, this.onNote);
+
+		const mute = await Mute.find({ muterId: this.user._id });
+		this.mutedUserIds = mute.map(m => m.muteeId.toString());
+	}
+
+	@autobind
+	private async onNote(note: any) {
+		// Renoteなら再pack
+		if (note.renoteId != null) {
+			note.renote = await pack(note.renoteId, this.user, {
+				detail: true
+			});
+		}
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (shouldMuteThisNote(note, this.mutedUserIds)) return;
+
+		this.send('note', note);
+	}
+
+	@autobind
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off(`homeTimeline:${this.user._id}`, this.onNote);
+	}
+}
diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts
new file mode 100644
index 0000000000..0b12ab3a8f
--- /dev/null
+++ b/src/server/api/stream/channels/hybrid-timeline.ts
@@ -0,0 +1,41 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
+import Channel from '../channel';
+
+export default class extends Channel {
+	private mutedUserIds: string[] = [];
+
+	@autobind
+	public async init(params: any) {
+		// Subscribe events
+		this.subscriber.on('hybridTimeline', this.onNewNote);
+		this.subscriber.on(`hybridTimeline:${this.user._id}`, this.onNewNote);
+
+		const mute = await Mute.find({ muterId: this.user._id });
+		this.mutedUserIds = mute.map(m => m.muteeId.toString());
+	}
+
+	@autobind
+	private async onNewNote(note: any) {
+		// Renoteなら再pack
+		if (note.renoteId != null) {
+			note.renote = await pack(note.renoteId, this.user, {
+				detail: true
+			});
+		}
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (shouldMuteThisNote(note, this.mutedUserIds)) return;
+
+		this.send('note', note);
+	}
+
+	@autobind
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off('hybridTimeline', this.onNewNote);
+		this.subscriber.off(`hybridTimeline:${this.user._id}`, this.onNewNote);
+	}
+}
diff --git a/src/server/api/stream/channels/index.ts b/src/server/api/stream/channels/index.ts
new file mode 100644
index 0000000000..7e71590d00
--- /dev/null
+++ b/src/server/api/stream/channels/index.ts
@@ -0,0 +1,31 @@
+import main from './main';
+import homeTimeline from './home-timeline';
+import localTimeline from './local-timeline';
+import hybridTimeline from './hybrid-timeline';
+import globalTimeline from './global-timeline';
+import notesStats from './notes-stats';
+import serverStats from './server-stats';
+import userList from './user-list';
+import messaging from './messaging';
+import messagingIndex from './messaging-index';
+import drive from './drive';
+import hashtag from './hashtag';
+import gamesReversi from './games/reversi';
+import gamesReversiGame from './games/reversi-game';
+
+export default {
+	main,
+	homeTimeline,
+	localTimeline,
+	hybridTimeline,
+	globalTimeline,
+	notesStats,
+	serverStats,
+	userList,
+	messaging,
+	messagingIndex,
+	drive,
+	hashtag,
+	gamesReversi,
+	gamesReversiGame
+};
diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts
new file mode 100644
index 0000000000..769ec6392f
--- /dev/null
+++ b/src/server/api/stream/channels/local-timeline.ts
@@ -0,0 +1,39 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import { pack } from '../../../../models/note';
+import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
+import Channel from '../channel';
+
+export default class extends Channel {
+	private mutedUserIds: string[] = [];
+
+	@autobind
+	public async init(params: any) {
+		// Subscribe events
+		this.subscriber.on('localTimeline', this.onNote);
+
+		const mute = this.user ? await Mute.find({ muterId: this.user._id }) : null;
+		this.mutedUserIds = mute ? mute.map(m => m.muteeId.toString()) : [];
+	}
+
+	@autobind
+	private async onNote(note: any) {
+		// Renoteなら再pack
+		if (note.renoteId != null) {
+			note.renote = await pack(note.renoteId, this.user, {
+				detail: true
+			});
+		}
+
+		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+		if (shouldMuteThisNote(note, this.mutedUserIds)) return;
+
+		this.send('note', note);
+	}
+
+	@autobind
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off('localTimeline', this.onNote);
+	}
+}
diff --git a/src/server/api/stream/channels/main.ts b/src/server/api/stream/channels/main.ts
new file mode 100644
index 0000000000..fd0984c833
--- /dev/null
+++ b/src/server/api/stream/channels/main.ts
@@ -0,0 +1,25 @@
+import autobind from 'autobind-decorator';
+import Mute from '../../../../models/mute';
+import Channel from '../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		const mute = await Mute.find({ muterId: this.user._id });
+		const mutedUserIds = mute.map(m => m.muteeId.toString());
+
+		// Subscribe main stream channel
+		this.subscriber.on(`mainStream:${this.user._id}`, async data => {
+			const { type, body } = data;
+
+			switch (type) {
+				case 'notification': {
+					if (mutedUserIds.includes(body.userId)) return;
+					break;
+				}
+			}
+
+			this.send(type, body);
+		});
+	}
+}
diff --git a/src/server/api/stream/channels/messaging-index.ts b/src/server/api/stream/channels/messaging-index.ts
new file mode 100644
index 0000000000..6e87cca7f4
--- /dev/null
+++ b/src/server/api/stream/channels/messaging-index.ts
@@ -0,0 +1,12 @@
+import autobind from 'autobind-decorator';
+import Channel from '../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		// Subscribe messaging index stream
+		this.subscriber.on(`messagingIndexStream:${this.user._id}`, data => {
+			this.send(data);
+		});
+	}
+}
diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts
new file mode 100644
index 0000000000..e1a78c8678
--- /dev/null
+++ b/src/server/api/stream/channels/messaging.ts
@@ -0,0 +1,26 @@
+import autobind from 'autobind-decorator';
+import read from '../../common/read-messaging-message';
+import Channel from '../channel';
+
+export default class extends Channel {
+	private otherpartyId: string;
+
+	@autobind
+	public async init(params: any) {
+		this.otherpartyId = params.otherparty as string;
+
+		// Subscribe messaging stream
+		this.subscriber.on(`messagingStream:${this.user._id}-${this.otherpartyId}`, data => {
+			this.send(data);
+		});
+	}
+
+	@autobind
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'read':
+				read(this.user._id, this.otherpartyId, body.id);
+				break;
+		}
+	}
+}
diff --git a/src/server/api/stream/channels/notes-stats.ts b/src/server/api/stream/channels/notes-stats.ts
new file mode 100644
index 0000000000..cc68d9886d
--- /dev/null
+++ b/src/server/api/stream/channels/notes-stats.ts
@@ -0,0 +1,34 @@
+import autobind from 'autobind-decorator';
+import Xev from 'xev';
+import Channel from '../channel';
+
+const ev = new Xev();
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		ev.addListener('notesStats', this.onStats);
+	}
+
+	@autobind
+	private onStats(stats: any) {
+		this.send('stats', stats);
+	}
+
+	@autobind
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'requestLog':
+				ev.once(`notesStatsLog:${body.id}`, statsLog => {
+					this.send('statsLog', statsLog);
+				});
+				ev.emit('requestNotesStatsLog', body.id);
+				break;
+		}
+	}
+
+	@autobind
+	public dispose() {
+		ev.removeListener('notesStats', this.onStats);
+	}
+}
diff --git a/src/server/api/stream/channels/server-stats.ts b/src/server/api/stream/channels/server-stats.ts
new file mode 100644
index 0000000000..28a566e8ae
--- /dev/null
+++ b/src/server/api/stream/channels/server-stats.ts
@@ -0,0 +1,37 @@
+import autobind from 'autobind-decorator';
+import Xev from 'xev';
+import Channel from '../channel';
+
+const ev = new Xev();
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		ev.addListener('serverStats', this.onStats);
+	}
+
+	@autobind
+	private onStats(stats: any) {
+		this.send('stats', stats);
+	}
+
+	@autobind
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'requestLog':
+				ev.once(`serverStatsLog:${body.id}`, statsLog => {
+					this.send('statsLog', statsLog);
+				});
+				ev.emit('requestServerStatsLog', {
+					id: body.id,
+					length: body.length
+				});
+				break;
+		}
+	}
+
+	@autobind
+	public dispose() {
+		ev.removeListener('serverStats', this.onStats);
+	}
+}
diff --git a/src/server/api/stream/channels/user-list.ts b/src/server/api/stream/channels/user-list.ts
new file mode 100644
index 0000000000..4ace308923
--- /dev/null
+++ b/src/server/api/stream/channels/user-list.ts
@@ -0,0 +1,14 @@
+import autobind from 'autobind-decorator';
+import Channel from '../channel';
+
+export default class extends Channel {
+	@autobind
+	public async init(params: any) {
+		const listId = params.listId as string;
+
+		// Subscribe stream
+		this.subscriber.on(`userListStream:${listId}`, data => {
+			this.send(data);
+		});
+	}
+}
diff --git a/src/server/api/stream/drive.ts b/src/server/api/stream/drive.ts
deleted file mode 100644
index 28c241e1bc..0000000000
--- a/src/server/api/stream/drive.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void {
-	// Subscribe drive stream
-	subscriber.on(`drive-stream:${user._id}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-}
diff --git a/src/server/api/stream/games/reversi-game.ts b/src/server/api/stream/games/reversi-game.ts
deleted file mode 100644
index 5cbbf42d59..0000000000
--- a/src/server/api/stream/games/reversi-game.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-import * as CRC32 from 'crc-32';
-import ReversiGame, { pack } from '../../../../models/games/reversi/game';
-import { publishReversiGameStream } from '../../../../stream';
-import Reversi from '../../../../games/reversi/core';
-import * as maps from '../../../../games/reversi/maps';
-import { ParsedUrlQuery } from 'querystring';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user?: any): void {
-	const q = request.resourceURL.query as ParsedUrlQuery;
-	const gameId = q.game as string;
-
-	// Subscribe game stream
-	subscriber.on(`reversi-game-stream:${gameId}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-
-	connection.on('message', async (data) => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'accept':
-				accept(true);
-				break;
-
-			case 'cancel-accept':
-				accept(false);
-				break;
-
-			case 'update-settings':
-				if (msg.settings == null) return;
-				updateSettings(msg.settings);
-				break;
-
-			case 'init-form':
-				if (msg.body == null) return;
-				initForm(msg.body);
-				break;
-
-			case 'update-form':
-				if (msg.id == null || msg.value === undefined) return;
-				updateForm(msg.id, msg.value);
-				break;
-
-			case 'message':
-				if (msg.body == null) return;
-				message(msg.body);
-				break;
-
-			case 'set':
-				if (msg.pos == null) return;
-				set(msg.pos);
-				break;
-
-			case 'check':
-				if (msg.crc32 == null) return;
-				check(msg.crc32);
-				break;
-		}
-	});
-
-	async function updateSettings(settings: any) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (game.isStarted) return;
-		if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return;
-		if (game.user1Id.equals(user._id) && game.user1Accepted) return;
-		if (game.user2Id.equals(user._id) && game.user2Accepted) return;
-
-		await ReversiGame.update({ _id: gameId }, {
-			$set: {
-				settings
-			}
-		});
-
-		publishReversiGameStream(gameId, 'update-settings', settings);
-	}
-
-	async function initForm(form: any) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (game.isStarted) return;
-		if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return;
-
-		const set = game.user1Id.equals(user._id) ? {
-			form1: form
-		} : {
-				form2: form
-			};
-
-		await ReversiGame.update({ _id: gameId }, {
-			$set: set
-		});
-
-		publishReversiGameStream(gameId, 'init-form', {
-			userId: user._id,
-			form
-		});
-	}
-
-	async function updateForm(id: string, value: any) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (game.isStarted) return;
-		if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return;
-
-		const form = game.user1Id.equals(user._id) ? game.form2 : game.form1;
-
-		const item = form.find((i: any) => i.id == id);
-
-		if (item == null) return;
-
-		item.value = value;
-
-		const set = game.user1Id.equals(user._id) ? {
-			form2: form
-		} : {
-				form1: form
-			};
-
-		await ReversiGame.update({ _id: gameId }, {
-			$set: set
-		});
-
-		publishReversiGameStream(gameId, 'update-form', {
-			userId: user._id,
-			id,
-			value
-		});
-	}
-
-	async function message(message: any) {
-		message.id = Math.random();
-		publishReversiGameStream(gameId, 'message', {
-			userId: user._id,
-			message
-		});
-	}
-
-	async function accept(accept: boolean) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (game.isStarted) return;
-
-		let bothAccepted = false;
-
-		if (game.user1Id.equals(user._id)) {
-			await ReversiGame.update({ _id: gameId }, {
-				$set: {
-					user1Accepted: accept
-				}
-			});
-
-			publishReversiGameStream(gameId, 'change-accepts', {
-				user1: accept,
-				user2: game.user2Accepted
-			});
-
-			if (accept && game.user2Accepted) bothAccepted = true;
-		} else if (game.user2Id.equals(user._id)) {
-			await ReversiGame.update({ _id: gameId }, {
-				$set: {
-					user2Accepted: accept
-				}
-			});
-
-			publishReversiGameStream(gameId, 'change-accepts', {
-				user1: game.user1Accepted,
-				user2: accept
-			});
-
-			if (accept && game.user1Accepted) bothAccepted = true;
-		} else {
-			return;
-		}
-
-		if (bothAccepted) {
-			// 3秒後、まだacceptされていたらゲーム開始
-			setTimeout(async () => {
-				const freshGame = await ReversiGame.findOne({ _id: gameId });
-				if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
-				if (!freshGame.user1Accepted || !freshGame.user2Accepted) return;
-
-				let bw: number;
-				if (freshGame.settings.bw == 'random') {
-					bw = Math.random() > 0.5 ? 1 : 2;
-				} else {
-					bw = freshGame.settings.bw as number;
-				}
-
-				function getRandomMap() {
-					const mapCount = Object.entries(maps).length;
-					const rnd = Math.floor(Math.random() * mapCount);
-					return Object.values(maps)[rnd].data;
-				}
-
-				const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap();
-
-				await ReversiGame.update({ _id: gameId }, {
-					$set: {
-						startedAt: new Date(),
-						isStarted: true,
-						black: bw,
-						'settings.map': map
-					}
-				});
-
-				//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
-				const o = new Reversi(map, {
-					isLlotheo: freshGame.settings.isLlotheo,
-					canPutEverywhere: freshGame.settings.canPutEverywhere,
-					loopedBoard: freshGame.settings.loopedBoard
-				});
-
-				if (o.isEnded) {
-					let winner;
-					if (o.winner === true) {
-						winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id;
-					} else if (o.winner === false) {
-						winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id;
-					} else {
-						winner = null;
-					}
-
-					await ReversiGame.update({
-						_id: gameId
-					}, {
-							$set: {
-								isEnded: true,
-								winnerId: winner
-							}
-						});
-
-					publishReversiGameStream(gameId, 'ended', {
-						winnerId: winner,
-						game: await pack(gameId, user)
-					});
-				}
-				//#endregion
-
-				publishReversiGameStream(gameId, 'started', await pack(gameId, user));
-			}, 3000);
-		}
-	}
-
-	// 石を打つ
-	async function set(pos: number) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (!game.isStarted) return;
-		if (game.isEnded) return;
-		if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return;
-
-		const o = new Reversi(game.settings.map, {
-			isLlotheo: game.settings.isLlotheo,
-			canPutEverywhere: game.settings.canPutEverywhere,
-			loopedBoard: game.settings.loopedBoard
-		});
-
-		game.logs.forEach(log => {
-			o.put(log.color, log.pos);
-		});
-
-		const myColor =
-			(game.user1Id.equals(user._id) && game.black == 1) || (game.user2Id.equals(user._id) && game.black == 2)
-				? true
-				: false;
-
-		if (!o.canPut(myColor, pos)) return;
-		o.put(myColor, pos);
-
-		let winner;
-		if (o.isEnded) {
-			if (o.winner === true) {
-				winner = game.black == 1 ? game.user1Id : game.user2Id;
-			} else if (o.winner === false) {
-				winner = game.black == 1 ? game.user2Id : game.user1Id;
-			} else {
-				winner = null;
-			}
-		}
-
-		const log = {
-			at: new Date(),
-			color: myColor,
-			pos
-		};
-
-		const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString());
-
-		await ReversiGame.update({
-			_id: gameId
-		}, {
-				$set: {
-					crc32,
-					isEnded: o.isEnded,
-					winnerId: winner
-				},
-				$push: {
-					logs: log
-				}
-			});
-
-		publishReversiGameStream(gameId, 'set', Object.assign(log, {
-			next: o.turn
-		}));
-
-		if (o.isEnded) {
-			publishReversiGameStream(gameId, 'ended', {
-				winnerId: winner,
-				game: await pack(gameId, user)
-			});
-		}
-	}
-
-	async function check(crc32: string) {
-		const game = await ReversiGame.findOne({ _id: gameId });
-
-		if (!game.isStarted) return;
-
-		// 互換性のため
-		if (game.crc32 == null) return;
-
-		if (crc32 !== game.crc32) {
-			connection.send(JSON.stringify({
-				type: 'rescue',
-				body: await pack(game, user)
-			}));
-		}
-	}
-}
diff --git a/src/server/api/stream/games/reversi.ts b/src/server/api/stream/games/reversi.ts
deleted file mode 100644
index f467613b21..0000000000
--- a/src/server/api/stream/games/reversi.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import * as mongo from 'mongodb';
-import * as websocket from 'websocket';
-import Xev from 'xev';
-import Matching, { pack } from '../../../../models/games/reversi/matching';
-import { publishUserStream } from '../../../../stream';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void {
-	// Subscribe reversi stream
-	subscriber.on(`reversi-stream:${user._id}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-
-	connection.on('message', async (data) => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'ping':
-				if (msg.id == null) return;
-				const matching = await Matching.findOne({
-					parentId: user._id,
-					childId: new mongo.ObjectID(msg.id)
-				});
-				if (matching == null) return;
-				publishUserStream(matching.childId, 'reversi_invited', await pack(matching, matching.childId));
-				break;
-		}
-	});
-}
diff --git a/src/server/api/stream/global-timeline.ts b/src/server/api/stream/global-timeline.ts
deleted file mode 100644
index 4786450cbb..0000000000
--- a/src/server/api/stream/global-timeline.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-import { IUser } from '../../../models/user';
-import Mute from '../../../models/mute';
-
-export default async function(
-	request: websocket.request,
-	connection: websocket.connection,
-	subscriber: Xev,
-	user: IUser
-) {
-	const mute = await Mute.find({ muterId: user._id });
-	const mutedUserIds = mute.map(m => m.muteeId.toString());
-
-	// Subscribe stream
-	subscriber.on('global-timeline', async note => {
-		//#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
-		if (mutedUserIds.indexOf(note.userId) != -1) {
-			return;
-		}
-		if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) {
-			return;
-		}
-		if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) {
-			return;
-		}
-		//#endregion
-
-		connection.send(JSON.stringify({
-			type: 'note',
-			body: note
-		}));
-	});
-}
diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts
deleted file mode 100644
index dc3ce9d19f..0000000000
--- a/src/server/api/stream/home.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-import * as debug from 'debug';
-
-import User, { IUser } from '../../../models/user';
-import Mute from '../../../models/mute';
-import { pack as packNote, pack } from '../../../models/note';
-import readNotification from '../common/read-notification';
-import call from '../call';
-import { IApp } from '../../../models/app';
-
-const log = debug('misskey');
-
-export default async function(
-	request: websocket.request,
-	connection: websocket.connection,
-	subscriber: Xev,
-	user: IUser,
-	app: IApp
-) {
-	const mute = await Mute.find({ muterId: user._id });
-	const mutedUserIds = mute.map(m => m.muteeId.toString());
-
-	async function onNoteStream(noteId: any) {
-		const note = await packNote(noteId, user, {
-			detail: true
-		});
-
-		connection.send(JSON.stringify({
-			type: 'note-updated',
-			body: {
-				note: note
-			}
-		}));
-	}
-
-	// Subscribe Home stream channel
-	subscriber.on(`user-stream:${user._id}`, async x => {
-		//#region 流れてきたメッセージがミュートしているユーザーが関わるものだったら無視する
-		if (x.type == 'note') {
-			if (mutedUserIds.includes(x.body.userId)) {
-				return;
-			}
-			if (x.body.reply != null && mutedUserIds.includes(x.body.reply.userId)) {
-				return;
-			}
-			if (x.body.renote != null && mutedUserIds.includes(x.body.renote.userId)) {
-				return;
-			}
-		} else if (x.type == 'notification') {
-			if (mutedUserIds.includes(x.body.userId)) {
-				return;
-			}
-		}
-		//#endregion
-
-		// Renoteなら再pack
-		if (x.type == 'note' && x.body.renoteId != null) {
-			x.body.renote = await pack(x.body.renoteId, user, {
-				detail: true
-			});
-		}
-
-		connection.send(JSON.stringify(x));
-	});
-
-	connection.on('message', async data => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'api':
-				// 新鮮なデータを利用するためにユーザーをフェッチ
-				call(msg.endpoint, await User.findOne({ _id: user._id }), app, msg.data).then(res => {
-					connection.send(JSON.stringify({
-						type: `api-res:${msg.id}`,
-						body: { res }
-					}));
-				}).catch(e => {
-					connection.send(JSON.stringify({
-						type: `api-res:${msg.id}`,
-						body: { e }
-					}));
-				});
-				break;
-
-			case 'alive':
-				// Update lastUsedAt
-				User.update({ _id: user._id }, {
-					$set: {
-						'lastUsedAt': new Date()
-					}
-				});
-				break;
-
-			case 'read_notification':
-				if (!msg.id) return;
-				readNotification(user._id, msg.id);
-				break;
-
-			case 'capture':
-				if (!msg.id) return;
-				log(`CAPTURE: ${msg.id} by @${user.username}`);
-				subscriber.on(`note-stream:${msg.id}`, onNoteStream);
-				break;
-
-			case 'decapture':
-				if (!msg.id) return;
-				log(`DECAPTURE: ${msg.id} by @${user.username}`);
-				subscriber.off(`note-stream:${msg.id}`, onNoteStream);
-				break;
-		}
-	});
-}
diff --git a/src/server/api/stream/hybrid-timeline.ts b/src/server/api/stream/hybrid-timeline.ts
deleted file mode 100644
index c401145abe..0000000000
--- a/src/server/api/stream/hybrid-timeline.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-import { IUser } from '../../../models/user';
-import Mute from '../../../models/mute';
-import { pack } from '../../../models/note';
-
-export default async function(
-	request: websocket.request,
-	connection: websocket.connection,
-	subscriber: Xev,
-	user: IUser
-) {
-	const mute = await Mute.find({ muterId: user._id });
-	const mutedUserIds = mute.map(m => m.muteeId.toString());
-
-	// Subscribe stream
-	subscriber.on('hybrid-timeline', onEvent);
-	subscriber.on(`hybrid-timeline:${user._id}`, onEvent);
-
-	async function onEvent(note: any) {
-		//#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
-		if (mutedUserIds.indexOf(note.userId) != -1) {
-			return;
-		}
-		if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) {
-			return;
-		}
-		if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) {
-			return;
-		}
-		//#endregion
-
-		// Renoteなら再pack
-		if (note.renoteId != null) {
-			note.renote = await pack(note.renoteId, user, {
-				detail: true
-			});
-		}
-
-		connection.send(JSON.stringify({
-			type: 'note',
-			body: note
-		}));
-	}
-}
diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts
new file mode 100644
index 0000000000..ef6397fcd9
--- /dev/null
+++ b/src/server/api/stream/index.ts
@@ -0,0 +1,220 @@
+import autobind from 'autobind-decorator';
+import * as websocket from 'websocket';
+import Xev from 'xev';
+import * as debug from 'debug';
+
+import User, { IUser } from '../../../models/user';
+import readNotification from '../common/read-notification';
+import call from '../call';
+import { IApp } from '../../../models/app';
+import readNote from '../../../services/note/read';
+
+import Channel from './channel';
+import channels from './channels';
+
+const log = debug('misskey');
+
+/**
+ * Main stream connection
+ */
+export default class Connection {
+	public user?: IUser;
+	public app: IApp;
+	private wsConnection: websocket.connection;
+	public subscriber: Xev;
+	private channels: Channel[] = [];
+	private subscribingNotes: any = {};
+	public sendMessageToWsOverride: any = null; // 後方互換性のため
+
+	constructor(
+		wsConnection: websocket.connection,
+		subscriber: Xev,
+		user: IUser,
+		app: IApp
+	) {
+		this.wsConnection = wsConnection;
+		this.user = user;
+		this.app = app;
+		this.subscriber = subscriber;
+
+		this.wsConnection.on('message', this.onWsConnectionMessage);
+	}
+
+	/**
+	 * クライアントからメッセージ受信時
+	 */
+	@autobind
+	private async onWsConnectionMessage(data: websocket.IMessage) {
+		const { type, body } = JSON.parse(data.utf8Data);
+
+		switch (type) {
+			case 'api': this.onApiRequest(body); break;
+			case 'alive': this.onAlive(); break;
+			case 'readNotification': this.onReadNotification(body); break;
+			case 'subNote': this.onSubscribeNote(body); break;
+			case 'sn': this.onSubscribeNote(body); break; // alias
+			case 'unsubNote': this.onUnsubscribeNote(body); break;
+			case 'un': this.onUnsubscribeNote(body); break; // alias
+			case 'connect': this.onChannelConnectRequested(body); break;
+			case 'disconnect': this.onChannelDisconnectRequested(body); break;
+			case 'channel': this.onChannelMessageRequested(body); break;
+		}
+	}
+
+	/**
+	 * APIリクエスト要求時
+	 */
+	@autobind
+	private async onApiRequest(payload: any) {
+		// 新鮮なデータを利用するためにユーザーをフェッチ
+		const user = this.user ? await User.findOne({ _id: this.user._id }) : null;
+
+		const endpoint = payload.endpoint || payload.ep; // alias
+
+		// 呼び出し
+		call(endpoint, user, this.app, payload.data).then(res => {
+			this.sendMessageToWs(`api:${payload.id}`, { res });
+		}).catch(e => {
+			this.sendMessageToWs(`api:${payload.id}`, { e });
+		});
+	}
+
+	@autobind
+	private onAlive() {
+		// Update lastUsedAt
+		User.update({ _id: this.user._id }, {
+			$set: {
+				'lastUsedAt': new Date()
+			}
+		});
+	}
+
+	@autobind
+	private onReadNotification(payload: any) {
+		if (!payload.id) return;
+		readNotification(this.user._id, payload.id);
+	}
+
+	/**
+	 * 投稿購読要求時
+	 */
+	@autobind
+	private onSubscribeNote(payload: any) {
+		if (!payload.id) return;
+
+		if (this.subscribingNotes[payload.id] == null) {
+			this.subscribingNotes[payload.id] = 0;
+		}
+
+		this.subscribingNotes[payload.id]++;
+
+		if (this.subscribingNotes[payload.id] == 1) {
+			this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage);
+		}
+
+		if (payload.read) {
+			readNote(this.user._id, payload.id);
+		}
+	}
+
+	/**
+	 * 投稿購読解除要求時
+	 */
+	@autobind
+	private onUnsubscribeNote(payload: any) {
+		if (!payload.id) return;
+
+		this.subscribingNotes[payload.id]--;
+		if (this.subscribingNotes[payload.id] <= 0) {
+			delete this.subscribingNotes[payload.id];
+			this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage);
+		}
+	}
+
+	@autobind
+	private async onNoteStreamMessage(data: any) {
+		this.sendMessageToWs('noteUpdated', {
+			id: data.body.id,
+			type: data.type,
+			body: data.body.body,
+		});
+	}
+
+	/**
+	 * チャンネル接続要求時
+	 */
+	@autobind
+	private onChannelConnectRequested(payload: any) {
+		const { channel, id, params } = payload;
+		log(`CH CONNECT: ${id} ${channel} by @${this.user.username}`);
+		this.connectChannel(id, params, (channels as any)[channel]);
+	}
+
+	/**
+	 * チャンネル切断要求時
+	 */
+	@autobind
+	private onChannelDisconnectRequested(payload: any) {
+		const { id } = payload;
+		log(`CH DISCONNECT: ${id} by @${this.user.username}`);
+		this.disconnectChannel(id);
+	}
+
+	/**
+	 * クライアントにメッセージ送信
+	 */
+	@autobind
+	public sendMessageToWs(type: string, payload: any) {
+		if (this.sendMessageToWsOverride) return this.sendMessageToWsOverride(type, payload); // 後方互換性のため
+		this.wsConnection.send(JSON.stringify({
+			type: type,
+			body: payload
+		}));
+	}
+
+	/**
+	 * チャンネルに接続
+	 */
+	@autobind
+	public connectChannel(id: string, params: any, channelClass: { new(id: string, connection: Connection): Channel }) {
+		const channel = new channelClass(id, this);
+		this.channels.push(channel);
+		channel.init(params);
+	}
+
+	/**
+	 * チャンネルから切断
+	 * @param id チャンネルコネクションID
+	 */
+	@autobind
+	public disconnectChannel(id: string) {
+		const channel = this.channels.find(c => c.id === id);
+
+		if (channel) {
+			if (channel.dispose) channel.dispose();
+			this.channels = this.channels.filter(c => c.id !== id);
+		}
+	}
+
+	/**
+	 * チャンネルへメッセージ送信要求時
+	 * @param data メッセージ
+	 */
+	@autobind
+	private onChannelMessageRequested(data: any) {
+		const channel = this.channels.find(c => c.id === data.id);
+		if (channel != null && channel.onMessage != null) {
+			channel.onMessage(data.type, data.body);
+		}
+	}
+
+	/**
+	 * ストリームが切れたとき
+	 */
+	@autobind
+	public dispose() {
+		this.channels.forEach(c => {
+			if (c.dispose) c.dispose();
+		});
+	}
+}
diff --git a/src/server/api/stream/local-timeline.ts b/src/server/api/stream/local-timeline.ts
deleted file mode 100644
index 82060a7aaa..0000000000
--- a/src/server/api/stream/local-timeline.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-import { IUser } from '../../../models/user';
-import Mute from '../../../models/mute';
-import { pack } from '../../../models/note';
-
-export default async function(
-	request: websocket.request,
-	connection: websocket.connection,
-	subscriber: Xev,
-	user: IUser
-) {
-	const mute = await Mute.find({ muterId: user._id });
-	const mutedUserIds = mute.map(m => m.muteeId.toString());
-
-	// Subscribe stream
-	subscriber.on('local-timeline', async note => {
-		//#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
-		if (mutedUserIds.indexOf(note.userId) != -1) {
-			return;
-		}
-		if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) {
-			return;
-		}
-		if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) {
-			return;
-		}
-		//#endregion
-
-		// Renoteなら再pack
-		if (note.renoteId != null) {
-			note.renote = await pack(note.renoteId, user, {
-				detail: true
-			});
-		}
-
-		connection.send(JSON.stringify({
-			type: 'note',
-			body: note
-		}));
-	});
-}
diff --git a/src/server/api/stream/messaging-index.ts b/src/server/api/stream/messaging-index.ts
deleted file mode 100644
index 9af63f2812..0000000000
--- a/src/server/api/stream/messaging-index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void {
-	// Subscribe messaging index stream
-	subscriber.on(`messaging-index-stream:${user._id}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-}
diff --git a/src/server/api/stream/messaging.ts b/src/server/api/stream/messaging.ts
deleted file mode 100644
index 8b352cea3c..0000000000
--- a/src/server/api/stream/messaging.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-import read from '../common/read-messaging-message';
-import { ParsedUrlQuery } from 'querystring';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void {
-	const q = request.resourceURL.query as ParsedUrlQuery;
-	const otherparty = q.otherparty as string;
-
-	// Subscribe messaging stream
-	subscriber.on(`messaging-stream:${user._id}-${otherparty}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-
-	connection.on('message', async (data) => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'read':
-				if (!msg.id) return;
-				read(user._id, otherparty, msg.id);
-				break;
-		}
-	});
-}
diff --git a/src/server/api/stream/notes-stats.ts b/src/server/api/stream/notes-stats.ts
deleted file mode 100644
index ab00620018..0000000000
--- a/src/server/api/stream/notes-stats.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-const ev = new Xev();
-
-export default function(request: websocket.request, connection: websocket.connection): void {
-	const onStats = (stats: any) => {
-		connection.send(JSON.stringify({
-			type: 'stats',
-			body: stats
-		}));
-	};
-
-	connection.on('message', async data => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'requestLog':
-				ev.once('notesStatsLog:' + msg.id, statsLog => {
-					connection.send(JSON.stringify({
-						type: 'statsLog',
-						body: statsLog
-					}));
-				});
-				ev.emit('requestNotesStatsLog', msg.id);
-				break;
-		}
-	});
-
-	ev.addListener('notesStats', onStats);
-
-	connection.on('close', () => {
-		ev.removeListener('notesStats', onStats);
-	});
-}
diff --git a/src/server/api/stream/server-stats.ts b/src/server/api/stream/server-stats.ts
deleted file mode 100644
index f6c1f14ebe..0000000000
--- a/src/server/api/stream/server-stats.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-
-const ev = new Xev();
-
-export default function(request: websocket.request, connection: websocket.connection): void {
-	const onStats = (stats: any) => {
-		connection.send(JSON.stringify({
-			type: 'stats',
-			body: stats
-		}));
-	};
-
-	connection.on('message', async data => {
-		const msg = JSON.parse(data.utf8Data);
-
-		switch (msg.type) {
-			case 'requestLog':
-				ev.once('serverStatsLog:' + msg.id, statsLog => {
-					connection.send(JSON.stringify({
-						type: 'statsLog',
-						body: statsLog
-					}));
-				});
-				ev.emit('requestServerStatsLog', {
-					id: msg.id,
-					length: msg.length
-				});
-				break;
-		}
-	});
-
-	ev.addListener('serverStats', onStats);
-
-	connection.on('close', () => {
-		ev.removeListener('serverStats', onStats);
-	});
-}
diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts
deleted file mode 100644
index 30f94d5251..0000000000
--- a/src/server/api/stream/user-list.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as websocket from 'websocket';
-import Xev from 'xev';
-import { ParsedUrlQuery } from 'querystring';
-
-export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void {
-	const q = request.resourceURL.query as ParsedUrlQuery;
-	const listId = q.listId as string;
-
-	// Subscribe stream
-	subscriber.on(`user-list-stream:${listId}`, data => {
-		connection.send(JSON.stringify(data));
-	});
-}
diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts
index c8b2d4e0b9..c8c4a8a294 100644
--- a/src/server/api/streaming.ts
+++ b/src/server/api/streaming.ts
@@ -2,25 +2,13 @@ import * as http from 'http';
 import * as websocket from 'websocket';
 import Xev from 'xev';
 
-import homeStream from './stream/home';
-import localTimelineStream from './stream/local-timeline';
-import hybridTimelineStream from './stream/hybrid-timeline';
-import globalTimelineStream from './stream/global-timeline';
-import userListStream from './stream/user-list';
-import driveStream from './stream/drive';
-import messagingStream from './stream/messaging';
-import messagingIndexStream from './stream/messaging-index';
-import reversiGameStream from './stream/games/reversi-game';
-import reversiStream from './stream/games/reversi';
-import serverStatsStream from './stream/server-stats';
-import notesStatsStream from './stream/notes-stats';
+import MainStreamConnection from './stream';
 import { ParsedUrlQuery } from 'querystring';
 import authenticate from './authenticate';
+import channels from './stream/channels';
 
 module.exports = (server: http.Server) => {
-	/**
-	 * Init websocket server
-	 */
+	// Init websocket server
 	const ws = new websocket.server({
 		httpServer: server
 	});
@@ -28,52 +16,45 @@ module.exports = (server: http.Server) => {
 	ws.on('request', async (request) => {
 		const connection = request.accept();
 
-		if (request.resourceURL.pathname === '/server-stats') {
-			serverStatsStream(request, connection);
-			return;
-		}
-
-		if (request.resourceURL.pathname === '/notes-stats') {
-			notesStatsStream(request, connection);
-			return;
-		}
-
 		const ev = new Xev();
 
-		connection.once('close', () => {
-			ev.removeAllListeners();
-		});
-
 		const q = request.resourceURL.query as ParsedUrlQuery;
 		const [user, app] = await authenticate(q.i as string);
 
-		if (request.resourceURL.pathname === '/games/reversi-game') {
-			reversiGameStream(request, connection, ev, user);
-			return;
+		const main = new MainStreamConnection(connection, ev, user, app);
+
+		// 後方互換性のため
+		if (request.resourceURL.pathname !== '/streaming') {
+			main.sendMessageToWsOverride = (type: string, payload: any) => {
+				if (type == 'channel') {
+					type = payload.type;
+					payload = payload.body;
+				}
+				if (type.startsWith('api:')) {
+					type = type.replace('api:', 'api-res:');
+				}
+				connection.send(JSON.stringify({
+					type: type,
+					body: payload
+				}));
+			};
+
+			main.connectChannel(Math.random().toString(), null,
+				request.resourceURL.pathname === '/' ? channels.homeTimeline :
+				request.resourceURL.pathname === '/local-timeline' ? channels.localTimeline :
+				request.resourceURL.pathname === '/hybrid-timeline' ? channels.hybridTimeline :
+				request.resourceURL.pathname === '/global-timeline' ? channels.globalTimeline : null);
 		}
 
-		if (user == null) {
-			connection.send('authentication-failed');
-			connection.close();
-			return;
-		}
+		connection.once('close', () => {
+			ev.removeAllListeners();
+			main.dispose();
+		});
 
-		const channel: any =
-			request.resourceURL.pathname === '/' ? homeStream :
-			request.resourceURL.pathname === '/local-timeline' ? localTimelineStream :
-			request.resourceURL.pathname === '/hybrid-timeline' ? hybridTimelineStream :
-			request.resourceURL.pathname === '/global-timeline' ? globalTimelineStream :
-			request.resourceURL.pathname === '/user-list' ? userListStream :
-			request.resourceURL.pathname === '/drive' ? driveStream :
-			request.resourceURL.pathname === '/messaging' ? messagingStream :
-			request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream :
-			request.resourceURL.pathname === '/games/reversi' ? reversiStream :
-			null;
-
-		if (channel !== null) {
-			channel(request, connection, ev, user, app);
-		} else {
-			connection.close();
-		}
+		connection.on('message', async (data) => {
+			if (data.utf8Data == 'ping') {
+				connection.send('pong');
+			}
+		});
 	});
 };
diff --git a/src/server/index.ts b/src/server/index.ts
index f1fcf58c8d..dc60b0d9ec 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -11,11 +11,13 @@ import * as Router from 'koa-router';
 import * as mount from 'koa-mount';
 import * as compress from 'koa-compress';
 import * as logger from 'koa-logger';
+const requestStats = require('request-stats');
 //const slow = require('koa-slow');
 
 import activityPub from './activitypub';
 import webFinger from './webfinger';
 import config from '../config';
+import { updateNetworkStats } from '../services/update-chart';
 
 // Init app
 const app = new Koa();
@@ -81,4 +83,27 @@ export default () => new Promise(resolve => {
 
 	// Listen
 	server.listen(config.port, resolve);
+
+	//#region Network stats
+	let queue: any[] = [];
+
+	requestStats(server, (stats: any) => {
+		if (stats.ok) {
+			queue.push(stats);
+		}
+	});
+
+	// Bulk write
+	setInterval(() => {
+		if (queue.length == 0) return;
+
+		const requests = queue.length;
+		const time = queue.reduce((a, b) => a + b.time, 0);
+		const incomingBytes = queue.reduce((a, b) => a + b.req.bytes, 0);
+		const outgoingBytes = queue.reduce((a, b) => a + b.res.bytes, 0);
+		queue = [];
+
+		updateNetworkStats(requests, time, incomingBytes, outgoingBytes);
+	}, 5000);
+	//#endregion
 });
diff --git a/src/server/web/docs.ts b/src/server/web/docs.ts
index 81e5ace3e8..3432861989 100644
--- a/src/server/web/docs.ts
+++ b/src/server/web/docs.ts
@@ -162,8 +162,7 @@ const router = new Router();
 router.get('/assets/*', async ctx => {
 	await send(ctx, ctx.params[0], {
 		root: `${__dirname}/../../docs/assets/`,
-		maxage: ms('7 days'),
-		immutable: true
+		maxage: ms('1 days')
 	});
 });
 
@@ -196,7 +195,7 @@ router.get('/*/api/entities/*', async ctx => {
 	const lang = ctx.params[0];
 	const entity = ctx.params[1];
 
-	const x = yaml.safeLoad(fs.readFileSync(path.resolve(__dirname + '/../../../src/docs/api/entities/' + entity + '.yaml'), 'utf-8')) as any;
+	const x = yaml.safeLoad(fs.readFileSync(path.resolve(`${__dirname}/../../../src/docs/api/entities/${entity}.yaml`), 'utf-8')) as any;
 
 	await ctx.render('../../../../src/docs/api/entities/view', Object.assign(await genVars(lang), {
 		id: `api/entities/${entity}`,
diff --git a/src/server/web/index.ts b/src/server/web/index.ts
index 452e36fe95..e7332f4230 100644
--- a/src/server/web/index.ts
+++ b/src/server/web/index.ts
@@ -63,7 +63,7 @@ router.get('/apple-touch-icon.png', async ctx => {
 	});
 });
 
-// ServiceWroker
+// ServiceWorker
 router.get(/^\/sw\.(.+?)\.js$/, async ctx => {
 	await send(ctx, `/assets/sw.${ctx.params[0]}.js`, {
 		root: client
diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug
index 22f1834059..234ecabe22 100644
--- a/src/server/web/views/note.pug
+++ b/src/server/web/views/note.pug
@@ -6,7 +6,7 @@ block vars
 	- const url = `${config.url}/notes/${note.id}`;
 
 block title
-	= `${title} | Misskey`
+	= `${title} | ${config.name}`
 
 block desc
 	meta(name='description' content= summary)
@@ -23,3 +23,6 @@ block meta
 		link(rel='prev' href=`${config.url}/notes/${note.prev}`)
 	if note.next
 		link(rel='next' href=`${config.url}/notes/${note.next}`)
+
+	if !user.host
+		link(rel='alternate' href=url type='application/activity+json')
diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug
index b5ea2f6eb4..506a889d98 100644
--- a/src/server/web/views/user.pug
+++ b/src/server/web/views/user.pug
@@ -2,11 +2,11 @@ extends ../../../../src/client/app/base
 
 block vars
 	- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
-	- const url = config.url + '/@' + (user.host ? `${user.username}@${user.host}` : user.username);
+	- const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`;
 	- const img = user.avatarId ? `${config.drive_url}/${user.avatarId}` : null;
 
 block title
-	= `${title} | Misskey`
+	= `${title} | ${config.name}`
 
 block desc
 	meta(name='description' content= user.description)
@@ -18,3 +18,10 @@ block meta
 	meta(property='og:description' content= user.description)
 	meta(property='og:url'         content= url)
 	meta(property='og:image'       content= img)
+	
+	if !user.host
+		link(rel='alternate' href=`${config.url}/users/${user._id}` type='application/activity+json')
+	if user.uri
+		link(rel='alternate' href=user.uri type='application/activity+json')
+	if user.url
+		link(rel='alternate' href=user.url type='text/html')
diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts
index 1da0f49a24..f8c54b2af4 100644
--- a/src/services/drive/add-file.ts
+++ b/src/services/drive/add-file.ts
@@ -12,7 +12,7 @@ import * as sharp from 'sharp';
 import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile } from '../../models/drive-file';
 import DriveFolder from '../../models/drive-folder';
 import { pack } from '../../models/drive-file';
-import { publishUserStream, publishDriveStream } from '../../stream';
+import { publishMainStream, publishDriveStream } from '../../stream';
 import { isLocalUser, IUser, IRemoteUser } from '../../models/user';
 import delFile from './delete-file';
 import config from '../../config';
@@ -36,11 +36,14 @@ async function save(path: string, name: string, type: string, hash: string, size
 
 	if (config.drive && config.drive.storage == 'minio') {
 		const minio = new Minio.Client(config.drive.config);
-		const key = `${config.drive.prefix}/${uuid.v4()}/${name}`;
-		const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}/${name}.thumbnail.jpg`;
+
+		const keyDir = `${config.drive.prefix}/${uuid.v4()}`;
+		const key = `${keyDir}/${name}`;
+		const thumbnailKeyDir = `${config.drive.prefix}/${uuid.v4()}`;
+		const thumbnailKey = `${thumbnailKeyDir}/${name}.thumbnail.jpg`;
 
 		const baseUrl = config.drive.baseUrl
-			|| `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? ':' + config.drive.config.port : '' }/${ config.drive.bucket }`;
+			|| `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`;
 
 		await minio.putObject(config.drive.bucket, key, fs.createReadStream(path), size, {
 			'Content-Type': type,
@@ -61,8 +64,8 @@ async function save(path: string, name: string, type: string, hash: string, size
 				key: key,
 				thumbnailKey: thumbnailKey
 			},
-			url: `${ baseUrl }/${ key }`,
-			thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null
+			url: `${ baseUrl }/${ keyDir }/${ encodeURIComponent(name) }`,
+			thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKeyDir }/${ encodeURIComponent(name) }.thumbnail.jpg` : null
 		});
 
 		const file = await DriveFile.insert({
@@ -150,7 +153,7 @@ export default async function(
 	isLink: boolean = false,
 	url: string = null,
 	uri: string = null,
-	sensitive = false
+	sensitive: boolean = null
 ): Promise<IDriveFile> {
 	// Calc md5 hash
 	const calcHash = new Promise<string>((res, rej) => {
@@ -326,7 +329,13 @@ export default async function(
 		properties: properties,
 		withoutChunks: isLink,
 		isRemote: isLink,
-		isSensitive: sensitive
+		isSensitive: (sensitive !== null && sensitive !== undefined)
+			? sensitive
+			: isLocalUser(user)
+				? user.settings.alwaysMarkNsfw
+					? true
+					: false
+				: false
 	} as IMetadata;
 
 	if (url !== null) {
@@ -374,8 +383,8 @@ export default async function(
 	log(`drive file has been created ${driveFile._id}`);
 
 	pack(driveFile).then(packedFile => {
-		// Publish drive_file_created event
-		publishUserStream(user._id, 'drive_file_created', packedFile);
+		// Publish driveFileCreated event
+		publishMainStream(user._id, 'driveFileCreated', packedFile);
 		publishDriveStream(user._id, 'file_created', packedFile);
 	});
 
diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts
index 4e297d3bb1..35d4ec9883 100644
--- a/src/services/drive/upload-from-url.ts
+++ b/src/services/drive/upload-from-url.ts
@@ -34,7 +34,13 @@ export default async (url: string, user: IUser, folderId: mongodb.ObjectID = nul
 	// write content at URL to temp file
 	await new Promise((res, rej) => {
 		const writable = fs.createWriteStream(path);
-		request(url)
+		const requestUrl = URL.parse(url).pathname.match(/[^\u0021-\u00ff]/) ? encodeURI(url) : url;
+		request({
+			url: requestUrl,
+			headers: {
+				'User-Agent': config.user_agent
+			}
+		})
 			.on('error', rej)
 			.on('end', () => {
 				writable.close();
diff --git a/src/services/following/create.ts b/src/services/following/create.ts
index bd39b8e183..637e3e8093 100644
--- a/src/services/following/create.ts
+++ b/src/services/following/create.ts
@@ -2,7 +2,7 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../
 import Following from '../../models/following';
 import FollowingLog from '../../models/following-log';
 import FollowedLog from '../../models/followed-log';
-import { publishUserStream } from '../../stream';
+import { publishMainStream } from '../../stream';
 import notify from '../../notify';
 import pack from '../../remote/activitypub/renderer';
 import renderFollow from '../../remote/activitypub/renderer/follow';
@@ -11,7 +11,7 @@ import { deliver } from '../../queue';
 import createFollowRequest from './requests/create';
 
 export default async function(follower: IUser, followee: IUser) {
-	if (followee.isLocked) {
+	if (followee.isLocked || isLocalUser(follower) && isRemoteUser(followee)) {
 		await createFollowRequest(follower, followee);
 	} else {
 		const following = await Following.insert({
@@ -61,22 +61,17 @@ export default async function(follower: IUser, followee: IUser) {
 
 		// Publish follow event
 		if (isLocalUser(follower)) {
-			packUser(followee, follower).then(packed => publishUserStream(follower._id, 'follow', packed));
+			packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed));
 		}
 
 		// Publish followed event
 		if (isLocalUser(followee)) {
-			packUser(follower, followee).then(packed => publishUserStream(followee._id, 'followed', packed)),
+			packUser(follower, followee).then(packed => publishMainStream(followee._id, 'followed', packed)),
 
 			// 通知を作成
 			notify(followee._id, follower._id, 'follow');
 		}
 
-		if (isLocalUser(follower) && isRemoteUser(followee)) {
-			const content = pack(renderFollow(follower, followee));
-			deliver(follower, content, followee.inbox);
-		}
-
 		if (isRemoteUser(follower) && isLocalUser(followee)) {
 			const content = pack(renderAccept(renderFollow(follower, followee)));
 			deliver(followee, content, follower.inbox);
diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts
index 7c285e9eac..2a67acbf05 100644
--- a/src/services/following/delete.ts
+++ b/src/services/following/delete.ts
@@ -2,7 +2,7 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../
 import Following from '../../models/following';
 import FollowingLog from '../../models/following-log';
 import FollowedLog from '../../models/followed-log';
-import { publishUserStream } from '../../stream';
+import { publishMainStream } from '../../stream';
 import pack from '../../remote/activitypub/renderer';
 import renderFollow from '../../remote/activitypub/renderer/follow';
 import renderUndo from '../../remote/activitypub/renderer/undo';
@@ -52,7 +52,7 @@ export default async function(follower: IUser, followee: IUser) {
 
 	// Publish unfollow event
 	if (isLocalUser(follower)) {
-		packUser(followee, follower).then(packed => publishUserStream(follower._id, 'unfollow', packed));
+		packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed));
 	}
 
 	if (isLocalUser(follower) && isRemoteUser(followee)) {
diff --git a/src/services/following/requests/accept.ts b/src/services/following/requests/accept.ts
index bf8ed99e13..e7c8df844a 100644
--- a/src/services/following/requests/accept.ts
+++ b/src/services/following/requests/accept.ts
@@ -7,7 +7,7 @@ import { deliver } from '../../../queue';
 import Following from '../../../models/following';
 import FollowingLog from '../../../models/following-log';
 import FollowedLog from '../../../models/followed-log';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 
 export default async function(followee: IUser, follower: IUser) {
 	const following = await Following.insert({
@@ -74,5 +74,7 @@ export default async function(followee: IUser, follower: IUser) {
 
 	packUser(followee, followee, {
 		detail: true
-	}).then(packed => publishUserStream(followee._id, 'meUpdated', packed));
+	}).then(packed => publishMainStream(followee._id, 'meUpdated', packed));
+
+	packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed));
 }
diff --git a/src/services/following/requests/cancel.ts b/src/services/following/requests/cancel.ts
index 9655a95f04..def02d59d9 100644
--- a/src/services/following/requests/cancel.ts
+++ b/src/services/following/requests/cancel.ts
@@ -4,7 +4,7 @@ import pack from '../../../remote/activitypub/renderer';
 import renderFollow from '../../../remote/activitypub/renderer/follow';
 import renderUndo from '../../../remote/activitypub/renderer/undo';
 import { deliver } from '../../../queue';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 
 export default async function(followee: IUser, follower: IUser) {
 	if (isRemoteUser(followee)) {
@@ -34,5 +34,5 @@ export default async function(followee: IUser, follower: IUser) {
 
 	packUser(followee, followee, {
 		detail: true
-	}).then(packed => publishUserStream(followee._id, 'meUpdated', packed));
+	}).then(packed => publishMainStream(followee._id, 'meUpdated', packed));
 }
diff --git a/src/services/following/requests/create.ts b/src/services/following/requests/create.ts
index 4c7c90cc08..5e613fd053 100644
--- a/src/services/following/requests/create.ts
+++ b/src/services/following/requests/create.ts
@@ -1,5 +1,5 @@
 import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../../models/user';
-import { publishUserStream } from '../../../stream';
+import { publishMainStream } from '../../../stream';
 import notify from '../../../notify';
 import pack from '../../../remote/activitypub/renderer';
 import renderFollow from '../../../remote/activitypub/renderer/follow';
@@ -7,8 +7,6 @@ import { deliver } from '../../../queue';
 import FollowRequest from '../../../models/follow-request';
 
 export default async function(follower: IUser, followee: IUser) {
-	if (!followee.isLocked) throw '対象のアカウントは鍵アカウントではありません';
-
 	await FollowRequest.insert({
 		createdAt: new Date(),
 		followerId: follower._id,
@@ -35,11 +33,11 @@ export default async function(follower: IUser, followee: IUser) {
 
 	// Publish receiveRequest event
 	if (isLocalUser(followee)) {
-		packUser(follower, followee).then(packed => publishUserStream(followee._id, 'receiveFollowRequest', packed));
+		packUser(follower, followee).then(packed => publishMainStream(followee._id, 'receiveFollowRequest', packed));
 
 		packUser(followee, followee, {
 			detail: true
-		}).then(packed => publishUserStream(followee._id, 'meUpdated', packed));
+		}).then(packed => publishMainStream(followee._id, 'meUpdated', packed));
 
 		// 通知を作成
 		notify(followee._id, follower._id, 'receiveFollowRequest');
diff --git a/src/services/following/requests/reject.ts b/src/services/following/requests/reject.ts
index affcd2ef5a..91a49db997 100644
--- a/src/services/following/requests/reject.ts
+++ b/src/services/following/requests/reject.ts
@@ -1,9 +1,10 @@
-import User, { IUser, isRemoteUser, ILocalUser } from '../../../models/user';
+import User, { IUser, isRemoteUser, ILocalUser, pack as packUser } from '../../../models/user';
 import FollowRequest from '../../../models/follow-request';
 import pack from '../../../remote/activitypub/renderer';
 import renderFollow from '../../../remote/activitypub/renderer/follow';
 import renderReject from '../../../remote/activitypub/renderer/reject';
 import { deliver } from '../../../queue';
+import { publishMainStream } from '../../../stream';
 
 export default async function(followee: IUser, follower: IUser) {
 	if (isRemoteUser(follower)) {
@@ -21,4 +22,6 @@ export default async function(followee: IUser, follower: IUser) {
 			pendingReceivedFollowRequestsCount: -1
 		}
 	});
+
+	packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed));
 }
diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts
new file mode 100644
index 0000000000..ff390eb781
--- /dev/null
+++ b/src/services/i/pin.ts
@@ -0,0 +1,130 @@
+import config from '../../config';
+import * as mongo from 'mongodb';
+import User, { isLocalUser, isRemoteUser, ILocalUser, IUser } from '../../models/user';
+import Note from '../../models/note';
+import Following from '../../models/following';
+import renderAdd from '../../remote/activitypub/renderer/add';
+import renderRemove from '../../remote/activitypub/renderer/remove';
+import packAp from '../../remote/activitypub/renderer';
+import { deliver } from '../../queue';
+
+/**
+ * 指定した投稿をピン留めします
+ * @param user
+ * @param noteId
+ */
+export async function addPinned(user: IUser, noteId: mongo.ObjectID) {
+	// Fetch pinee
+	const note = await Note.findOne({
+		_id: noteId,
+		userId: user._id
+	});
+
+	if (note === null) {
+		throw new Error('note not found');
+	}
+
+	let pinnedNoteIds = user.pinnedNoteIds || [];
+
+	//#region 現在ピン留め投稿している投稿が実際にデータベースに存在しているのかチェック
+	// データベースの欠損などで存在していない場合があるので。
+	// 存在していなかったらピン留め投稿から外す
+	const pinnedNotes = (await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id })))).filter(x => x != null);
+
+	pinnedNoteIds = pinnedNoteIds.filter(id => pinnedNotes.some(n => n._id.equals(id)));
+	//#endregion
+
+	if (pinnedNoteIds.length >= 5) {
+		throw new Error('cannot pin more notes');
+	}
+
+	if (pinnedNoteIds.some(id => id.equals(note._id))) {
+		throw new Error('already exists');
+	}
+
+	pinnedNoteIds.unshift(note._id);
+
+	await User.update(user._id, {
+		$set: {
+			pinnedNoteIds: pinnedNoteIds
+		}
+	});
+
+	// Deliver to remote followers
+	if (isLocalUser(user)) {
+		deliverPinnedChange(user._id, note._id, true);
+	}
+}
+
+/**
+ * 指定した投稿のピン留めを解除します
+ * @param user
+ * @param noteId
+ */
+export async function removePinned(user: IUser, noteId: mongo.ObjectID) {
+	// Fetch unpinee
+	const note = await Note.findOne({
+		_id: noteId,
+		userId: user._id
+	});
+
+	if (note === null) {
+		throw new Error('note not found');
+	}
+
+	const pinnedNoteIds = (user.pinnedNoteIds || []).filter(id => !id.equals(note._id));
+
+	await User.update(user._id, {
+		$set: {
+			pinnedNoteIds: pinnedNoteIds
+		}
+	});
+
+	// Deliver to remote followers
+	if (isLocalUser(user)) {
+		deliverPinnedChange(user._id, noteId, false);
+	}
+}
+
+export async function deliverPinnedChange(userId: mongo.ObjectID, noteId: mongo.ObjectID, isAddition: boolean) {
+	const user = await User.findOne({
+		_id: userId
+	});
+
+	if (!isLocalUser(user)) return;
+
+	const queue = await CreateRemoteInboxes(user);
+
+	if (queue.length < 1) return;
+
+	const target = `${config.url}/users/${user._id}/collections/featured`;
+
+	const item = `${config.url}/notes/${noteId}`;
+	const content = packAp(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item));
+	queue.forEach(inbox => {
+		deliver(user, content, inbox);
+	});
+}
+
+/**
+ * ローカルユーザーのリモートフォロワーのinboxリストを作成する
+ * @param user ローカルユーザー
+ */
+async function CreateRemoteInboxes(user: ILocalUser): Promise<string[]> {
+	const followers = await Following.find({
+		followeeId: user._id
+	});
+
+	const queue: string[] = [];
+
+	followers.map(following => {
+		const follower = following._follower;
+
+		if (isRemoteUser(follower)) {
+			const inbox = follower.sharedInbox || follower.inbox;
+			if (!queue.includes(inbox)) queue.push(inbox);
+		}
+	});
+
+	return queue;
+}
diff --git a/src/services/i/update.ts b/src/services/i/update.ts
new file mode 100644
index 0000000000..25b55b0355
--- /dev/null
+++ b/src/services/i/update.ts
@@ -0,0 +1,38 @@
+import * as mongo from 'mongodb';
+import User, { isLocalUser, isRemoteUser } from '../../models/user';
+import Following from '../../models/following';
+import renderPerson from '../../remote/activitypub/renderer/person';
+import renderUpdate from '../../remote/activitypub/renderer/update';
+import packAp from '../../remote/activitypub/renderer';
+import { deliver } from '../../queue';
+
+export async function publishToFollowers(userId: mongo.ObjectID) {
+	const user = await User.findOne({
+		_id: userId
+	});
+
+	const followers = await Following.find({
+		followeeId: user._id
+	});
+
+	const queue: string[] = [];
+
+	// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
+	if (isLocalUser(user)) {
+		followers.map(following => {
+			const follower = following._follower;
+
+			if (isRemoteUser(follower)) {
+				const inbox = follower.sharedInbox || follower.inbox;
+				if (!queue.includes(inbox)) queue.push(inbox);
+			}
+		});
+
+		if (queue.length > 0) {
+			const content = packAp(renderUpdate(await renderPerson(user), user));
+			queue.forEach(inbox => {
+				deliver(user, content, inbox);
+			});
+		}
+	}
+}
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 63e3557828..3dc411d434 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -1,7 +1,7 @@
 import es from '../../db/elasticsearch';
 import Note, { pack, INote } from '../../models/note';
 import User, { isLocalUser, IUser, isRemoteUser, IRemoteUser, ILocalUser } from '../../models/user';
-import { publishUserStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../stream';
+import { publishMainStream, publishHomeTimelineStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream, publishHashtagStream } from '../../stream';
 import Following from '../../models/following';
 import { deliver } from '../../queue';
 import renderNote from '../../remote/activitypub/renderer/note';
@@ -24,6 +24,8 @@ import isQuote from '../../misc/is-quote';
 import { TextElementMention } from '../../mfm/parse/elements/mention';
 import { TextElementHashtag } from '../../mfm/parse/elements/hashtag';
 import { updateNoteStats } from '../update-chart';
+import { erase, unique } from '../../prelude/array';
+import insertNoteUnread from './unread';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -84,7 +86,7 @@ type Option = {
 	text?: string;
 	reply?: INote;
 	renote?: INote;
-	media?: IDriveFile[];
+	files?: IDriveFile[];
 	geo?: any;
 	poll?: any;
 	viaMobile?: boolean;
@@ -103,23 +105,30 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 	if (data.viaMobile == null) data.viaMobile = false;
 
 	if (data.visibleUsers) {
-		data.visibleUsers = data.visibleUsers.filter(x => x != null);
+		data.visibleUsers = erase(null, data.visibleUsers);
 	}
 
+	// リプライ対象が削除された投稿だったらreject
 	if (data.reply && data.reply.deletedAt != null) {
 		return rej();
 	}
 
+	// Renote対象が削除された投稿だったらreject
 	if (data.renote && data.renote.deletedAt != null) {
 		return rej();
 	}
 
-	// リプライ先が自分以外の非公開の投稿なら禁止
+	// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
+	if (data.renote && data.renote.visibility != 'public' && data.renote.visibility != 'home') {
+		return rej();
+	}
+
+	// リプライ対象が自分以外の非公開の投稿なら禁止
 	if (data.reply && data.reply.visibility == 'private' && !data.reply.userId.equals(user._id)) {
 		return rej();
 	}
 
-	// Renote先が自分以外の非公開の投稿なら禁止
+	// Renote対象が自分以外の非公開の投稿なら禁止
 	if (data.renote && data.renote.visibility == 'private' && !data.renote.userId.equals(user._id)) {
 		return rej();
 	}
@@ -135,7 +144,19 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 
 	const mentionedUsers = await extractMentionedUsers(tokens);
 
-	const note = await insertNote(user, data, tokens, tags, mentionedUsers);
+	if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) {
+		mentionedUsers.push(await User.findOne({ _id: data.reply.userId }));
+	}
+
+	if (data.visibility == 'specified') {
+		data.visibleUsers.forEach(u => {
+			if (!mentionedUsers.some(x => x._id.equals(u._id))) {
+				mentionedUsers.push(u);
+			}
+		});
+	}
+
+	const note = await insertNote(user, data, tags, mentionedUsers);
 
 	res(note);
 
@@ -155,6 +176,17 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 	// Increment notes count (user)
 	incNotesCountOfUser(user);
 
+	// 未読通知を作成
+	if (data.visibility == 'specified') {
+		data.visibleUsers.forEach(u => {
+			insertNoteUnread(u, note, true);
+		});
+	} else {
+		mentionedUsers.forEach(u => {
+			insertNoteUnread(u, note, false);
+		});
+	}
+
 	if (data.reply) {
 		saveReply(data.reply, note);
 	}
@@ -174,14 +206,18 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 		noteObj.isFirstNote = true;
 	}
 
+	if (tags.length > 0) {
+		publishHashtagStream(noteObj);
+	}
+
 	const nm = new NotificationManager(user, note);
 	const nmRelatedPromises = [];
 
-	createMentionedEvents(mentionedUsers, noteObj, nm);
+	createMentionedEvents(mentionedUsers, note, nm);
 
 	const noteActivity = await renderActivity(data, note);
 
-	if (isLocalUser(user)) {
+	if (isLocalUser(user) && note.visibility != 'private') {
 		deliverNoteToMentionedRemoteUsers(mentionedUsers, user, noteActivity);
 	}
 
@@ -198,7 +234,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 		// 通知
 		if (isLocalUser(data.reply._user)) {
 			nm.push(data.reply.userId, 'reply');
-			publishUserStream(data.reply.userId, 'reply', noteObj);
+			publishMainStream(data.reply.userId, 'reply', noteObj);
 		}
 	}
 
@@ -221,7 +257,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 
 		// Publish event
 		if (!user._id.equals(data.renote.userId) && isLocalUser(data.renote._user)) {
-			publishUserStream(data.renote.userId, 'renote', noteObj);
+			publishMainStream(data.renote.userId, 'renote', noteObj);
 		}
 	}
 
@@ -238,7 +274,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 });
 
 async function renderActivity(data: Option, note: INote) {
-	const content = data.renote && data.text == null
+	const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length == 0)
 		? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote._id}`, note)
 		: renderCreate(await renderNote(note, false), note);
 
@@ -266,13 +302,15 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren
 		}
 
 		if (['private', 'followers', 'specified'].includes(note.visibility)) {
-			// Publish event to myself's stream
-			publishUserStream(note.userId, 'note', await pack(note, user, {
+			const detailPackedNote = await pack(note, user, {
 				detail: true
-			}));
+			});
+			// Publish event to myself's stream
+			publishHomeTimelineStream(note.userId, detailPackedNote);
+			publishHybridTimelineStream(note.userId, detailPackedNote);
 		} else {
 			// Publish event to myself's stream
-			publishUserStream(note.userId, 'note', noteObj);
+			publishHomeTimelineStream(note.userId, noteObj);
 
 			// Publish note to local and hybrid timeline stream
 			if (note.visibility != 'home') {
@@ -281,6 +319,9 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren
 
 			if (note.visibility == 'public') {
 				publishHybridTimelineStream(null, noteObj);
+			} else {
+				// Publish event to myself's stream
+				publishHybridTimelineStream(note.userId, noteObj);
 			}
 		}
 	}
@@ -290,29 +331,19 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren
 		publishGlobalTimelineStream(noteObj);
 	}
 
-	if (note.visibility == 'specified') {
-		visibleUsers.forEach(async (u) => {
-			const n = await pack(note, u, {
-				detail: true
-			});
-			publishUserStream(u._id, 'note', n);
-			publishHybridTimelineStream(u._id, n);
-		});
-	}
-
 	if (['public', 'home', 'followers'].includes(note.visibility)) {
 		// フォロワーに配信
-		publishToFollowers(note, noteObj, user, noteActivity);
+		publishToFollowers(note, user, noteActivity);
 	}
 
 	// リストに配信
 	publishToUserLists(note, noteObj);
 }
 
-async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof parse>, tags: string[], mentionedUsers: IUser[]) {
+async function insertNote(user: IUser, data: Option, tags: string[], mentionedUsers: IUser[]) {
 	const insert: any = {
 		createdAt: data.createdAt,
-		mediaIds: data.media ? data.media.map(file => file._id) : [],
+		fileIds: data.files ? data.files.map(file => file._id) : [],
 		replyId: data.reply ? data.reply._id : null,
 		renoteId: data.renote ? data.renote._id : null,
 		text: data.text,
@@ -347,7 +378,8 @@ async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof p
 		_user: {
 			host: user.host,
 			inbox: isRemoteUser(user) ? user.inbox : undefined
-		}
+		},
+		_files: data.files ? data.files : []
 	};
 
 	if (data.uri != null) insert.uri = data.uri;
@@ -383,7 +415,7 @@ function extractHashtags(tokens: ReturnType<typeof parse>): string[] {
 		.map(t => (t as TextElementHashtag).hashtag)
 		.filter(tag => tag.length <= 100);
 
-	return [...new Set(hashtags)];
+	return unique(hashtags);
 }
 
 function index(note: INote) {
@@ -439,7 +471,12 @@ async function publishToUserLists(note: INote, noteObj: any) {
 	});
 }
 
-async function publishToFollowers(note: INote, noteObj: any, user: IUser, noteActivity: any) {
+async function publishToFollowers(note: INote, user: IUser, noteActivity: any) {
+	const detailPackedNote = await pack(note, null, {
+		detail: true,
+		skipHide: true
+	});
+
 	const followers = await Following.find({
 		followeeId: note.userId
 	});
@@ -458,10 +495,10 @@ async function publishToFollowers(note: INote, noteObj: any, user: IUser, noteAc
 			}
 
 			// Publish event to followers stream
-			publishUserStream(following.followerId, 'note', noteObj);
+			publishHomeTimelineStream(following.followerId, detailPackedNote);
 
 			if (isRemoteUser(user) || note.visibility != 'public') {
-				publishHybridTimelineStream(following.followerId, noteObj);
+				publishHybridTimelineStream(following.followerId, detailPackedNote);
 			}
 		} else {
 			// フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信
@@ -483,9 +520,13 @@ function deliverNoteToMentionedRemoteUsers(mentionedUsers: IUser[], user: ILocal
 	});
 }
 
-function createMentionedEvents(mentionedUsers: IUser[], noteObj: any, nm: NotificationManager) {
+function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: NotificationManager) {
 	mentionedUsers.filter(u => isLocalUser(u)).forEach(async (u) => {
-		publishUserStream(u._id, 'mention', noteObj);
+		const detailPackedNote = await pack(note, u, {
+			detail: true
+		});
+
+		publishMainStream(u._id, 'mention', detailPackedNote);
 
 		// Create notification
 		nm.push(u._id, 'mention');
@@ -540,20 +581,20 @@ function incNotesCount(user: IUser) {
 async function extractMentionedUsers(tokens: ReturnType<typeof parse>): Promise<IUser[]> {
 	if (tokens == null) return [];
 
-	const mentionTokens = [...new Set(
+	const mentionTokens = unique(
 		tokens
 			.filter(t => t.type == 'mention') as TextElementMention[]
-	)];
+	);
 
-	const mentionedUsers = [...new Set(
-		(await Promise.all(mentionTokens.map(async m => {
+	const mentionedUsers = unique(
+		erase(null, await Promise.all(mentionTokens.map(async m => {
 			try {
 				return await resolveUser(m.username, m.host);
 			} catch (e) {
 				return null;
 			}
-		}))).filter(x => x != null)
-	)];
+		})))
+	);
 
 	return mentionedUsers;
 }
diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts
index d0e2b12b41..2b99b4b85e 100644
--- a/src/services/note/delete.ts
+++ b/src/services/note/delete.ts
@@ -5,8 +5,9 @@ import renderDelete from '../../remote/activitypub/renderer/delete';
 import pack from '../../remote/activitypub/renderer';
 import { deliver } from '../../queue';
 import Following from '../../models/following';
-import renderNote from '../../remote/activitypub/renderer/note';
+import renderTombstone from '../../remote/activitypub/renderer/tombstone';
 import { updateNoteStats } from '../update-chart';
+import config from '../../config';
 
 /**
  * 投稿を削除します。
@@ -14,25 +15,30 @@ import { updateNoteStats } from '../update-chart';
  * @param note 投稿
  */
 export default async function(user: IUser, note: INote) {
+	const deletedAt = new Date();
+
 	await Note.update({
 		_id: note._id,
 		userId: user._id
 	}, {
 		$set: {
-			deletedAt: new Date(),
+			deletedAt: deletedAt,
 			text: null,
 			tags: [],
-			mediaIds: [],
+			fileIds: [],
 			poll: null,
-			geo: null
+			geo: null,
+			cw: null
 		}
 	});
 
-	publishNoteStream(note._id, 'deleted');
+	publishNoteStream(note._id, 'deleted', {
+		deletedAt: deletedAt
+	});
 
 	//#region ローカルの投稿なら削除アクティビティを配送
 	if (isLocalUser(user)) {
-		const content = pack(renderDelete(await renderNote(note), user));
+		const content = pack(renderDelete(renderTombstone(`${config.url}/notes/${note._id}`), user));
 
 		const followings = await Following.find({
 			followeeId: user._id,
diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts
index 5b6267b0dd..6884014e33 100644
--- a/src/services/note/reaction/create.ts
+++ b/src/services/note/reaction/create.ts
@@ -43,7 +43,9 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise
 		$inc: inc
 	});
 
-	publishNoteStream(note._id, 'reacted');
+	publishNoteStream(note._id, 'reacted', {
+		reaction: reaction
+	});
 
 	// リアクションされたユーザーがローカルユーザーなら通知を作成
 	if (isLocalUser(note._user)) {
diff --git a/src/services/note/read.ts b/src/services/note/read.ts
new file mode 100644
index 0000000000..caf5cf318f
--- /dev/null
+++ b/src/services/note/read.ts
@@ -0,0 +1,66 @@
+import * as mongo from 'mongodb';
+import { publishMainStream } from '../../stream';
+import User from '../../models/user';
+import NoteUnread from '../../models/note-unread';
+
+/**
+ * Mark a note as read
+ */
+export default (
+	user: string | mongo.ObjectID,
+	note: string | mongo.ObjectID
+) => new Promise<any>(async (resolve, reject) => {
+
+	const userId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(user)
+		? user as mongo.ObjectID
+		: new mongo.ObjectID(user);
+
+	const noteId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(note)
+		? note as mongo.ObjectID
+		: new mongo.ObjectID(note);
+
+	// Remove document
+	const res = await NoteUnread.remove({
+		userId: userId,
+		noteId: noteId
+	});
+
+	if (res.deletedCount == 0) {
+		return;
+	}
+
+	const count1 = await NoteUnread
+		.count({
+			userId: userId,
+			isSpecified: false
+		}, {
+			limit: 1
+		});
+
+	const count2 = await NoteUnread
+		.count({
+			userId: userId,
+			isSpecified: true
+		}, {
+			limit: 1
+		});
+
+	if (count1 == 0 || count2 == 0) {
+		User.update({ _id: userId }, {
+			$set: {
+				hasUnreadMentions: count1 != 0 || count2 != 0,
+				hasUnreadSpecifiedNotes: count2 != 0
+			}
+		});
+	}
+
+	if (count1 == 0) {
+		// 全て既読になったイベントを発行
+		publishMainStream(userId, 'readAllUnreadMentions');
+	}
+
+	if (count2 == 0) {
+		// 全て既読になったイベントを発行
+		publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
+	}
+});
diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts
new file mode 100644
index 0000000000..e84ac2a4bf
--- /dev/null
+++ b/src/services/note/unread.ts
@@ -0,0 +1,47 @@
+import NoteUnread from '../../models/note-unread';
+import User, { IUser } from '../../models/user';
+import { INote } from '../../models/note';
+import Mute from '../../models/mute';
+import { publishMainStream } from '../../stream';
+
+export default async function(user: IUser, note: INote, isSpecified = false) {
+	//#region ミュートしているなら無視
+	const mute = await Mute.find({
+		muterId: user._id
+	});
+	const mutedUserIds = mute.map(m => m.muteeId.toString());
+	if (mutedUserIds.includes(note.userId.toString())) return;
+	//#endregion
+
+	const unread = await NoteUnread.insert({
+		noteId: note._id,
+		userId: user._id,
+		isSpecified,
+		_note: {
+			userId: note.userId
+		}
+	});
+
+	// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
+	setTimeout(async () => {
+		const exist = await NoteUnread.findOne({ _id: unread._id });
+		if (exist == null) return;
+
+		User.update({
+			_id: user._id
+		}, {
+			$set: isSpecified ? {
+				hasUnreadSpecifiedNotes: true,
+				hasUnreadMentions: true
+			} : {
+				hasUnreadMentions: true
+			}
+		});
+
+		publishMainStream(user._id, 'unreadMention', note._id);
+
+		if (isSpecified) {
+			publishMainStream(user._id, 'unreadSpecifiedNote', note._id);
+		}
+	}, 2000);
+}
diff --git a/src/services/update-chart.ts b/src/services/update-chart.ts
index 1f8da6be9f..78834ba601 100644
--- a/src/services/update-chart.ts
+++ b/src/services/update-chart.ts
@@ -96,6 +96,12 @@ async function getCurrentStats(span: 'day' | 'hour'): Promise<IStats> {
 						decCount: 0,
 						decSize: 0
 					}
+				},
+				network: {
+					requests: 0,
+					totalTime: 0,
+					incomingBytes: 0,
+					outgoingBytes: 0
 				}
 			};
 
@@ -161,6 +167,12 @@ async function getCurrentStats(span: 'day' | 'hour'): Promise<IStats> {
 						decCount: 0,
 						decSize: 0
 					}
+				},
+				network: {
+					requests: 0,
+					totalTime: 0,
+					incomingBytes: 0,
+					outgoingBytes: 0
 				}
 			};
 
@@ -243,3 +255,13 @@ export async function updateDriveStats(file: IDriveFile, isAdditional: boolean)
 
 	await update(inc);
 }
+
+export async function updateNetworkStats(requests: number, time: number, incomingBytes: number, outgoingBytes: number) {
+	const inc = {} as any;
+	inc['network.requests'] = requests;
+	inc['network.totalTime'] = time;
+	inc['network.incomingBytes'] = incomingBytes;
+	inc['network.outgoingBytes'] = outgoingBytes;
+
+	await update(inc);
+}
diff --git a/src/stream.ts b/src/stream.ts
index be7a8c4ba1..45b353d904 100644
--- a/src/stream.ts
+++ b/src/stream.ts
@@ -1,58 +1,110 @@
 import * as mongo from 'mongodb';
 import Xev from 'xev';
-
-const ev = new Xev();
+import Meta, { IMeta } from './models/meta';
 
 type ID = string | mongo.ObjectID;
 
-function publish(channel: string, type: string, value?: any): void {
-	const message = type == null ? value : value == null ?
-		{ type: type } :
-		{ type: type, body: value };
+class Publisher {
+	private ev: Xev;
+	private meta: IMeta;
 
-		ev.emit(channel, message);
+	constructor() {
+		this.ev = new Xev();
+
+		setInterval(async () => {
+			this.meta = await Meta.findOne({});
+		}, 5000);
+	}
+
+	public getMeta = async () => {
+		if (this.meta != null) return this.meta;
+
+		this.meta = await Meta.findOne({});
+		return this.meta;
+	}
+
+	private publish = (channel: string, type: string, value?: any): void => {
+		const message = type == null ? value : value == null ?
+			{ type: type, body: null } :
+			{ type: type, body: value };
+
+		this.ev.emit(channel, message);
+	}
+
+	public publishMainStream = (userId: ID, type: string, value?: any): void => {
+		this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishDriveStream = (userId: ID, type: string, value?: any): void => {
+		this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishNoteStream = (noteId: ID, type: string, value: any): void => {
+		this.publish(`noteStream:${noteId}`, type, {
+			id: noteId,
+			body: value
+		});
+	}
+
+	public publishUserListStream = (listId: ID, type: string, value?: any): void => {
+		this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishMessagingStream = (userId: ID, otherpartyId: ID, type: string, value?: any): void => {
+		this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishMessagingIndexStream = (userId: ID, type: string, value?: any): void => {
+		this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishReversiStream = (userId: ID, type: string, value?: any): void => {
+		this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishReversiGameStream = (gameId: ID, type: string, value?: any): void => {
+		this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	public publishHomeTimelineStream = (userId: ID, note: any): void => {
+		this.publish(`homeTimeline:${userId}`, null, note);
+	}
+
+	public publishLocalTimelineStream = async (note: any): Promise<void> => {
+		const meta = await this.getMeta();
+		if (meta.disableLocalTimeline) return;
+		this.publish('localTimeline', null, note);
+	}
+
+	public publishHybridTimelineStream = async (userId: ID, note: any): Promise<void> => {
+		const meta = await this.getMeta();
+		if (meta.disableLocalTimeline) return;
+		this.publish(userId ? `hybridTimeline:${userId}` : 'hybridTimeline', null, note);
+	}
+
+	public publishGlobalTimelineStream = (note: any): void => {
+		this.publish('globalTimeline', null, note);
+	}
+
+	public publishHashtagStream = (note: any): void => {
+		this.publish('hashtag', null, note);
+	}
 }
 
-export function publishUserStream(userId: ID, type: string, value?: any): void {
-	publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
-}
+const publisher = new Publisher();
 
-export function publishDriveStream(userId: ID, type: string, value?: any): void {
-	publish(`drive-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
-}
+export default publisher;
 
-export function publishNoteStream(noteId: ID, type: string): void {
-	publish(`note-stream:${noteId}`, null, noteId);
-}
-
-export function publishUserListStream(listId: ID, type: string, value?: any): void {
-	publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value);
-}
-
-export function publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void {
-	publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
-}
-
-export function publishMessagingIndexStream(userId: ID, type: string, value?: any): void {
-	publish(`messaging-index-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
-}
-
-export function publishReversiStream(userId: ID, type: string, value?: any): void {
-	publish(`reversi-stream:${userId}`, type, typeof value === 'undefined' ? null : value);
-}
-
-export function publishReversiGameStream(gameId: ID, type: string, value?: any): void {
-	publish(`reversi-game-stream:${gameId}`, type, typeof value === 'undefined' ? null : value);
-}
-
-export function publishLocalTimelineStream(note: any): void {
-	publish('local-timeline', null, note);
-}
-
-export function publishHybridTimelineStream(userId: ID, note: any): void {
-	publish(userId ? `hybrid-timeline:${userId}` : 'hybrid-timeline', null, note);
-}
-
-export function publishGlobalTimelineStream(note: any): void {
-	publish('global-timeline', null, note);
-}
+export const publishMainStream = publisher.publishMainStream;
+export const publishDriveStream = publisher.publishDriveStream;
+export const publishNoteStream = publisher.publishNoteStream;
+export const publishUserListStream = publisher.publishUserListStream;
+export const publishMessagingStream = publisher.publishMessagingStream;
+export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
+export const publishReversiStream = publisher.publishReversiStream;
+export const publishReversiGameStream = publisher.publishReversiGameStream;
+export const publishHomeTimelineStream = publisher.publishHomeTimelineStream;
+export const publishLocalTimelineStream = publisher.publishLocalTimelineStream;
+export const publishHybridTimelineStream = publisher.publishHybridTimelineStream;
+export const publishGlobalTimelineStream = publisher.publishGlobalTimelineStream;
+export const publishHashtagStream = publisher.publishHashtagStream;
diff --git a/test/mfm.ts b/test/mfm.ts
index 706c4c549a..dc0947e5e9 100644
--- a/test/mfm.ts
+++ b/test/mfm.ts
@@ -1,6 +1,7 @@
 import * as assert from 'assert';
 
 import analyze from '../src/mfm/parse';
+import toHtml from '../src/mfm/html';
 import syntaxhighlighter from '../src/mfm/parse/core/syntax-highlighter';
 
 describe('Text', () => {
@@ -53,28 +54,86 @@ describe('Text', () => {
 			], tokens2);
 		});
 
-		it('mention', () => {
-			const tokens = analyze('@himawari お腹ペコい');
-			assert.deepEqual([
-				{ type: 'mention', content: '@himawari', username: 'himawari', host: null },
-				{ type: 'text', content: ' お腹ペコい' }
-			], tokens);
-		});
+		describe('mention', () => {
+			it('local', () => {
+				const tokens = analyze('@himawari お腹ペコい');
+				assert.deepEqual([
+					{ type: 'mention', content: '@himawari', username: 'himawari', host: null },
+					{ type: 'text', content: ' お腹ペコい' }
+				], tokens);
+			});
 
-		it('remote mention', () => {
-			const tokens = analyze('@hima_sub@namori.net お腹ペコい');
-			assert.deepEqual([
-				{ type: 'mention', content: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
-				{ type: 'text', content: ' お腹ペコい' }
-			], tokens);
+			it('remote', () => {
+				const tokens = analyze('@hima_sub@namori.net お腹ペコい');
+				assert.deepEqual([
+					{ type: 'mention', content: '@hima_sub@namori.net', username: 'hima_sub', host: 'namori.net' },
+					{ type: 'text', content: ' お腹ペコい' }
+				], tokens);
+			});
+/*
+			it('ignore', () => {
+				const tokens = analyze('idolm@ster');
+				assert.deepEqual([
+					{ type: 'text', content: 'idolm@ster' }
+				], tokens);
+
+				const tokens2 = analyze('@a\n@b\n@c');
+				assert.deepEqual([
+					{ type: 'mention', content: '@a', username: 'a', host: null },
+					{ type: 'text', content: '\n' },
+					{ type: 'mention', content: '@b', username: 'b', host: null },
+					{ type: 'text', content: '\n' },
+					{ type: 'mention', content: '@c', username: 'c', host: null }
+				], tokens2);
+
+				const tokens3 = analyze('**x**@a');
+				assert.deepEqual([
+					{ type: 'bold', content: '**x**', bold: 'x' },
+					{ type: 'mention', content: '@a', username: 'a', host: null }
+				], tokens3);
+			});
+*/
 		});
 
 		it('hashtag', () => {
-			const tokens = analyze('Strawberry Pasta #alice');
+			const tokens1 = analyze('Strawberry Pasta #alice');
 			assert.deepEqual([
 				{ type: 'text', content: 'Strawberry Pasta ' },
 				{ type: 'hashtag', content: '#alice', hashtag: 'alice' }
-			], tokens);
+			], tokens1);
+
+			const tokens2 = analyze('Foo #bar, baz #piyo.');
+			assert.deepEqual([
+				{ type: 'text', content: 'Foo ' },
+				{ type: 'hashtag', content: '#bar', hashtag: 'bar' },
+				{ type: 'text', content: ', baz ' },
+				{ type: 'hashtag', content: '#piyo', hashtag: 'piyo' },
+				{ type: 'text', content: '.' }
+			], tokens2);
+		});
+
+		it('quote', () => {
+			const tokens1 = analyze('> foo\nbar\nbaz');
+			assert.deepEqual([
+				{ type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' }
+			], tokens1);
+
+			const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter');
+			assert.deepEqual([
+				{ type: 'text', content: 'before' },
+				{ type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' },
+				{ type: 'text', content: 'after' }
+			], tokens2);
+
+			const tokens3 = analyze('piyo> foo\nbar\nbaz');
+			assert.deepEqual([
+				{ type: 'text', content: 'piyo> foo\nbar\nbaz' }
+			], tokens3);
+
+			const tokens4 = analyze('> foo\n> bar\n> baz');
+			assert.deepEqual([
+				{ type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' }
+			], tokens4);
 		});
 
 		it('url', () => {
@@ -170,4 +229,12 @@ describe('Text', () => {
 			assert.equal(html, '<span class="symbol">/</span>');
 		});
 	});
+
+	describe('toHtml', () => {
+		it('br', () => {
+			const input = 'foo\nbar\nbaz';
+			const output = '<p>foo<br>bar<br>baz</p>';
+			assert.equal(toHtml(analyze(input)), output);
+		});
+	});
 });
diff --git a/tsconfig.json b/tsconfig.json
index 76221c282a..ff4eaf917a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,8 @@
     "removeComments": false,
     "noLib": false,
     "strict": true,
-    "strictNullChecks": false
+    "strictNullChecks": false,
+    "experimentalDecorators": true
   },
   "compileOnSave": false,
   "include": [
diff --git a/tslint.json b/tslint.json
index ae0df46b96..1adc0a2aed 100644
--- a/tslint.json
+++ b/tslint.json
@@ -17,6 +17,7 @@
 		"no-empty":false,
 		"ordered-imports": [false],
 		"arrow-parens": false,
+		"array-type": false,
 		"object-literal-shorthand": false,
 		"object-literal-key-quotes": false,
 		"triple-equals": [false],
diff --git a/webpack.config.ts b/webpack.config.ts
index 1e295c245d..e1163133c0 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -6,7 +6,6 @@ import * as fs from 'fs';
 import * as webpack from 'webpack';
 import chalk from 'chalk';
 const { VueLoaderPlugin } = require('vue-loader');
-const jsonImporter = require('node-sass-json-importer');
 const minifyHtml = require('html-minifier').minify;
 const WebpackOnBuildPlugin = require('on-build-webpack');
 //const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
@@ -41,7 +40,7 @@ global['collapseSpacesReplacement'] = (html: string) => {
 };
 
 global['base64replacement'] = (_: any, key: string) => {
-	return fs.readFileSync(__dirname + '/src/client/' + key, 'base64');
+	return fs.readFileSync(`${__dirname}/src/client/${key}`, 'base64');
 };
 
 global['i18nReplacement'] = i18nReplacement;
@@ -73,7 +72,8 @@ const consts = {
 	_VERSION_: version,
 	_CODENAME_: codename,
 	_LANG_: '%lang%',
-	_LANGS_: Object.keys(locales).map(l => [l, locales[l].meta.lang])
+	_LANGS_: Object.keys(locales).map(l => [l, locales[l].meta.lang]),
+	_ENV_: process.env.NODE_ENV
 };
 
 const _consts: { [ key: string ]: any } = {};
@@ -134,6 +134,8 @@ module.exports = {
 						preserveWhitespace: false
 					}
 				}
+			}, {
+				loader: 'vue-svg-inline-loader'
 			}, {
 				loader: 'replace',
 				query: {
@@ -181,22 +183,6 @@ module.exports = {
 					loader: 'stylus-loader'
 				}]
 			}]
-		}, {
-			test: /\.scss$/,
-			exclude: /node_modules/,
-			use: [{
-				loader: 'style-loader'
-			}, {
-				loader: 'css-loader',
-				options: {
-					minimize: true
-				}
-			}, {
-				loader: 'sass-loader',
-				options: {
-					importer: jsonImporter,
-				}
-			}]
 		}, {
 			test: /\.css$/,
 			use: [{
@@ -210,6 +196,9 @@ module.exports = {
 		}, {
 			test: /\.(eot|woff|woff2|svg|ttf)([\?]?.*)$/,
 			loader: 'url-loader'
+		}, {
+			test: /\.json5$/,
+			loader: 'json5-loader'
 		}, {
 			test: /\.ts$/,
 			exclude: /node_modules/,