From c7cc3dcdfd2c0962a39e7186852a17dbd09b6a5b Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 18 May 2019 20:36:33 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=82=B0?=
 =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Resolve #3218
---
 locales/ja-JP.yml                             |  48 +++--
 migration/1558103093633-UserGroup.ts          |  41 ++++
 .../app/common/views/components/dialog.vue    |   1 +
 .../app/common/views/components/index.ts      |   4 +
 .../views/components/messaging-room.form.vue  |  16 +-
 .../components/messaging-room.message.vue     |  10 +-
 .../views/components/messaging-room.vue       |  56 ++++--
 .../app/common/views/components/messaging.vue | 141 +++++++++++---
 .../app/common/views/components/ui/hr.vue     |  15 ++
 .../app/common/views/components/ui/margin.vue |  16 ++
 .../common/views/components/user-lists.vue    |  95 ---------
 .../app/common/views/components/user-menu.vue |   2 +-
 ...re-column.vue => deck.column-template.vue} |  29 ++-
 src/client/app/common/views/pages/explore.vue |   4 +
 .../views/pages/follow-requests.vue}          |  39 ++--
 src/client/app/common/views/pages/pages.vue   |   5 +
 .../common/views/pages/user-group-editor.vue  | 180 ++++++++++++++++++
 .../app/common/views/pages/user-groups.vue    |  63 ++++++
 .../user-list-editor.vue                      |  60 ++++--
 .../app/common/views/pages/user-lists.vue     |  63 ++++++
 src/client/app/desktop/script.ts              |  18 +-
 .../components/messaging-room-window.vue      |  12 +-
 .../views/components/messaging-window.vue     |   7 +-
 .../received-follow-requests-window.vue       |  70 -------
 .../views/components/ui.header.account.vue    |  33 ++--
 .../desktop/views/components/ui.sidebar.vue   |   2 -
 .../views/components/user-list-window.vue     |  24 ---
 .../views/components/user-lists-window.vue    |  36 ----
 .../desktop/views/pages/messaging-room.vue    |  28 ++-
 .../app/desktop/views/widgets/messaging.vue   |   7 +-
 src/client/app/mobile/script.ts               |  31 +--
 .../app/mobile/views/components/ui.nav.vue    |   2 +-
 src/client/app/mobile/views/pages/explore.vue |  28 ---
 .../app/mobile/views/pages/messaging-room.vue |  23 ++-
 .../app/mobile/views/pages/messaging.vue      |   5 +-
 src/client/app/mobile/views/pages/pages.vue   |  29 ---
 src/client/app/mobile/views/pages/ui.vue      |  38 ++++
 .../app/mobile/views/pages/user-list.vue      |  48 -----
 .../app/mobile/views/pages/user-lists.vue     |  35 ----
 src/db/postgre.ts                             |   4 +
 src/models/entities/messaging-message.ts      |  24 ++-
 src/models/entities/user-group-joining.ts     |  41 ++++
 src/models/entities/user-group.ts             |  46 +++++
 src/models/index.ts                           |   6 +-
 src/models/repositories/messaging-message.ts  |  38 +++-
 src/models/repositories/user-group.ts         |  61 ++++++
 src/models/repositories/user.ts               |  35 +++-
 .../api/common/read-messaging-message.ts      |  84 ++++++--
 src/server/api/endpoints/messaging/history.ts |  53 ++++--
 .../api/endpoints/messaging/messages.ts       | 105 ++++++++--
 .../endpoints/messaging/messages/create.ts    | 125 +++++++++---
 .../endpoints/messaging/messages/delete.ts    |  12 +-
 .../api/endpoints/messaging/messages/read.ts  |  17 +-
 .../api/endpoints/users/groups/create.ts      |  51 +++++
 .../api/endpoints/users/groups/delete.ts      |  49 +++++
 .../api/endpoints/users/groups/joined.ts      |  33 ++++
 .../api/endpoints/users/groups/owned.ts       |  33 ++++
 src/server/api/endpoints/users/groups/pull.ts |  68 +++++++
 src/server/api/endpoints/users/groups/push.ts |  90 +++++++++
 src/server/api/endpoints/users/groups/show.ts |  53 ++++++
 src/server/api/endpoints/users/lists/push.ts  |   2 +-
 src/server/api/kinds.ts                       |   2 +
 src/server/api/openapi/schemas.ts             |   2 +
 src/server/api/stream/channels/messaging.ts   |  31 ++-
 src/services/stream.ts                        |   6 +
 65 files changed, 1797 insertions(+), 638 deletions(-)
 create mode 100644 migration/1558103093633-UserGroup.ts
 create mode 100644 src/client/app/common/views/components/ui/hr.vue
 create mode 100644 src/client/app/common/views/components/ui/margin.vue
 delete mode 100644 src/client/app/common/views/components/user-lists.vue
 rename src/client/app/common/views/deck/{deck.explore-column.vue => deck.column-template.vue} (50%)
 rename src/client/app/{mobile/views/pages/received-follow-requests.vue => common/views/pages/follow-requests.vue} (57%)
 create mode 100644 src/client/app/common/views/pages/user-group-editor.vue
 create mode 100644 src/client/app/common/views/pages/user-groups.vue
 rename src/client/app/common/views/{components => pages}/user-list-editor.vue (66%)
 create mode 100644 src/client/app/common/views/pages/user-lists.vue
 delete mode 100644 src/client/app/desktop/views/components/received-follow-requests-window.vue
 delete mode 100644 src/client/app/desktop/views/components/user-list-window.vue
 delete mode 100644 src/client/app/desktop/views/components/user-lists-window.vue
 delete mode 100644 src/client/app/mobile/views/pages/explore.vue
 delete mode 100644 src/client/app/mobile/views/pages/pages.vue
 create mode 100644 src/client/app/mobile/views/pages/ui.vue
 delete mode 100644 src/client/app/mobile/views/pages/user-list.vue
 delete mode 100644 src/client/app/mobile/views/pages/user-lists.vue
 create mode 100644 src/models/entities/user-group-joining.ts
 create mode 100644 src/models/entities/user-group.ts
 create mode 100644 src/models/repositories/user-group.ts
 create mode 100644 src/server/api/endpoints/users/groups/create.ts
 create mode 100644 src/server/api/endpoints/users/groups/delete.ts
 create mode 100644 src/server/api/endpoints/users/groups/joined.ts
 create mode 100644 src/server/api/endpoints/users/groups/owned.ts
 create mode 100644 src/server/api/endpoints/users/groups/pull.ts
 create mode 100644 src/server/api/endpoints/users/groups/push.ts
 create mode 100644 src/server/api/endpoints/users/groups/show.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index dc0692e4b9..437fd39971 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -265,6 +265,7 @@ common:
   my-token-regenerated: "あなたのトークンが更新されたのでサインアウトします。"
   hide-password: "パスワードを隠す"
   show-password: "パスワードを表示する"
+  enter-username: "ユーザー名を入力してください"
 
   do-not-use-in-production: "これは開発ビルドです。本番環境で使用しないでください。"
   user-suspended: "このユーザーは凍結されています。"
@@ -480,20 +481,24 @@ common/views/components/messaging.vue:
   search-user: "ユーザーを探す"
   you: "あなた"
   no-history: "履歴はありません"
+  user: "ユーザー"
+  group: "グループ"
+  start-with-user: "ユーザーとトークを開始"
+  start-with-group: "グループとトークを開始"
 
 common/views/components/messaging-room.vue:
-  empty: "このユーザーと話したことはありません"
+  not-talked-user: "このユーザーとの会話はありません"
+  not-talked-group: "このグループでの会話はありません"
   no-history: "これより過去の履歴はありません"
-  resize-form: "ドラッグしてフォームの広さを調整"
   new-message: "新しいメッセージがあります"
-  only-one-file-attached: "メッセージに添付できるのはひとつのファイルのみです"
+  only-one-file-attached: "メッセージに添付できるファイルはひとつです"
 
 common/views/components/messaging-room.form.vue:
   input-message-here: "ここにメッセージを入力"
   send: "送信"
   attach-from-local: "PCからファイルを添付する"
   attach-from-drive: "ドライブからファイルを添付する"
-  only-one-file-attached: "メッセージに添付できるのはひとつのファイルのみです"
+  only-one-file-attached: "メッセージに添付できるファイルはひとつです"
 
 common/views/components/messaging-room.message.vue:
   is-read: "既読"
@@ -750,11 +755,27 @@ common/views/components/user-list-editor.vue:
   remove-user: "このリストから削除"
   delete-are-you-sure: "リスト「$1」を削除しますか?"
   deleted: "削除しました"
+  add-user: "ユーザーを追加"
+
+common/views/components/user-group-editor.vue:
+  users: "メンバー"
+  rename: "グループ名を変更"
+  delete: "グループを削除"
+  remove-user: "このグループから削除"
+  delete-are-you-sure: "グループ「$1」を削除しますか?"
+  deleted: "削除しました"
+  add-user: "メンバーを追加"
 
 common/views/components/user-lists.vue:
+  user-lists: "リスト"
   create-list: "リストを作成"
   list-name: "リスト名"
 
+common/views/components/user-groups.vue:
+  user-groups: "グループ"
+  create-group: "グループを作成"
+  group-name: "グループ名"
+
 common/views/widgets/broadcast.vue:
   fetching: "確認中"
   no-broadcasts: "お知らせはありません"
@@ -827,6 +848,11 @@ common/views/pages/follow.vue:
   follow-processing: "フォロー処理中"
   follow-request: "フォロー申請"
 
+common/views/pages/follow-requests.vue:
+  received-follow-requests: "フォロー申請"
+  accept: "承認"
+  reject: "拒否"
+
 desktop:
   banner-crop-title: "バナーとして表示する部分を選択"
   banner: "バナー"
@@ -1139,6 +1165,7 @@ desktop/views/components/ui.header.vue:
 desktop/views/components/ui.header.account.vue:
   profile: "プロフィール"
   lists: "リスト"
+  groups: "グループ"
   follow-requests: "フォロー申請"
   admin: "管理"
 
@@ -1154,14 +1181,6 @@ desktop/views/components/ui.header.post.vue:
 desktop/views/components/ui.header.search.vue:
   placeholder: "検索"
 
-desktop/views/components/received-follow-requests-window.vue:
-  title: "フォロー申請"
-  accept: "承認"
-  reject: "拒否"
-
-desktop/views/components/user-lists-window.vue:
-  title: "リスト"
-
 desktop/views/components/user-preview.vue:
   notes: "投稿"
   following: "フォロー"
@@ -1749,11 +1768,6 @@ mobile/views/pages/widgets/activity.vue:
 mobile/views/pages/share.vue:
   share-with: "{name}で共有"
 
-mobile/views/pages/received-follow-requests.vue:
-  title: "フォロー申請"
-  accept: "承認"
-  reject: "拒否"
-
 mobile/views/pages/note.vue:
   title: "投稿"
   prev: "前の投稿"
diff --git a/migration/1558103093633-UserGroup.ts b/migration/1558103093633-UserGroup.ts
new file mode 100644
index 0000000000..04783b8dfa
--- /dev/null
+++ b/migration/1558103093633-UserGroup.ts
@@ -0,0 +1,41 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class UserGroup1558103093633 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`CREATE TABLE "user_group" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "isPrivate" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c29fba6fe013ec8724378ce7c9" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_20e30aa35180e317e133d75316" ON "user_group" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_3d6b372788ab01be58853003c9" ON "user_group" ("userId") `);
+        await queryRunner.query(`CREATE TABLE "user_group_joining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_15f2425885253c5507e1599cfe7" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23" ON "user_group_joining" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_67dc758bc0566985d1b3d39986" ON "user_group_joining" ("userGroupId") `);
+        await queryRunner.query(`ALTER TABLE "messaging_message" ADD "groupId" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" ADD "reads" character varying(32) array NOT NULL DEFAULT '{}'::varchar[]`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" DROP NOT NULL`);
+        await queryRunner.query(`COMMENT ON COLUMN "messaging_message"."recipientId" IS 'The recipient user ID.'`);
+        await queryRunner.query(`CREATE INDEX "IDX_2c4be03b446884f9e9c502135b" ON "messaging_message" ("groupId") `);
+        await queryRunner.query(`ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_2c4be03b446884f9e9c502135be" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "user_group" ADD CONSTRAINT "FK_3d6b372788ab01be58853003c93" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_67dc758bc0566985d1b3d399865" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<any> {
+        await queryRunner.query(`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_67dc758bc0566985d1b3d399865"`);
+        await queryRunner.query(`ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231"`);
+        await queryRunner.query(`ALTER TABLE "user_group" DROP CONSTRAINT "FK_3d6b372788ab01be58853003c93"`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_2c4be03b446884f9e9c502135be"`);
+        await queryRunner.query(`DROP INDEX "IDX_2c4be03b446884f9e9c502135b"`);
+        await queryRunner.query(`COMMENT ON COLUMN "messaging_message"."recipientId" IS ''`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" SET NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" DROP COLUMN "reads"`);
+        await queryRunner.query(`ALTER TABLE "messaging_message" DROP COLUMN "groupId"`);
+        await queryRunner.query(`DROP INDEX "IDX_67dc758bc0566985d1b3d39986"`);
+        await queryRunner.query(`DROP INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23"`);
+        await queryRunner.query(`DROP TABLE "user_group_joining"`);
+        await queryRunner.query(`DROP INDEX "IDX_3d6b372788ab01be58853003c9"`);
+        await queryRunner.query(`DROP INDEX "IDX_20e30aa35180e317e133d75316"`);
+        await queryRunner.query(`DROP TABLE "user_group"`);
+    }
+
+}
diff --git a/src/client/app/common/views/components/dialog.vue b/src/client/app/common/views/components/dialog.vue
index f22e0174b3..9f38031d62 100644
--- a/src/client/app/common/views/components/dialog.vue
+++ b/src/client/app/common/views/components/dialog.vue
@@ -18,6 +18,7 @@
 				<fa icon="spinner" pulse v-if="type === 'waiting'"/>
 			</div>
 			<header v-if="title" v-html="title"></header>
+			<header v-if="title == null && user">{{ $t('@.enter-username') }}</header>
 			<div class="body" v-if="text" v-html="text"></div>
 			<ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input>
 			<ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input>
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index f4d40f9b1a..174fa36c00 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -44,6 +44,8 @@ import uiSwitch from './ui/switch.vue';
 import uiRadio from './ui/radio.vue';
 import uiSelect from './ui/select.vue';
 import uiInfo from './ui/info.vue';
+import uiMargin from './ui/margin.vue';
+import uiHr from './ui/hr.vue';
 import formButton from './ui/form/button.vue';
 import formRadio from './ui/form/radio.vue';
 
@@ -91,5 +93,7 @@ Vue.component('ui-switch', uiSwitch);
 Vue.component('ui-radio', uiRadio);
 Vue.component('ui-select', uiSelect);
 Vue.component('ui-info', uiInfo);
+Vue.component('ui-margin', uiMargin);
+Vue.component('ui-hr', uiHr);
 Vue.component('form-button', formButton);
 Vue.component('form-radio', formRadio);
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 ee6c312bce..1dfb0589e4 100644
--- a/src/client/app/common/views/components/messaging-room.form.vue
+++ b/src/client/app/common/views/components/messaging-room.form.vue
@@ -33,7 +33,16 @@ import * as autosize from 'autosize';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/messaging-room.form.vue'),
-	props: ['user'],
+	props: {
+		user: {
+			type: Object,
+			requird: false,
+		},
+		group: {
+			type: Object,
+			requird: false,
+		},
+	},
 	data() {
 		return {
 			text: null,
@@ -43,7 +52,7 @@ export default Vue.extend({
 	},
 	computed: {
 		draftId(): string {
-			return this.user.id;
+			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
 		},
 		canSend(): boolean {
 			return (this.text != null && this.text != '') || this.file != null;
@@ -159,7 +168,8 @@ export default Vue.extend({
 		send() {
 			this.sending = true;
 			this.$root.api('messaging/messages/create', {
-				userId: this.user.id,
+				userId: this.user ? this.user.id : undefined,
+				groupId: this.group ? this.group.id : undefined,
 				text: this.text ? this.text : undefined,
 				fileId: this.file ? this.file.id : undefined
 			}).then(message => {
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 908533e0cc..aff89c2573 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -23,7 +23,12 @@
 		<div></div>
 		<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 		<footer>
-			<span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span>
+			<template v-if="isGroup">
+				<span class="read" v-if="message.reads.length > 0">{{ $t('is-read') }} {{ message.reads.length }}</span>
+			</template>
+			<template v-else>
+				<span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span>
+			</template>
 			<mk-time :time="message.createdAt"/>
 			<template v-if="message.is_edited"><fa icon="pencil-alt"/></template>
 		</footer>
@@ -42,6 +47,9 @@ export default Vue.extend({
 	props: {
 		message: {
 			required: true
+		},
+		isGroup: {
+			required: false
 		}
 	},
 	computed: {
diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue
index a8980e068f..658dc93f64 100644
--- a/src/client/app/common/views/components/messaging-room.vue
+++ b/src/client/app/common/views/components/messaging-room.vue
@@ -4,14 +4,14 @@
 	@drop.prevent.stop="onDrop"
 >
 	<div class="body">
-		<p class="init" v-if="init"><fa icon="spinner .spin"/>{{ $t('@.loading') }}</p>
-		<p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ $t('empty') }}</p>
+		<p class="init" v-if="init"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}</p>
+		<p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p>
 		<p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('no-history') }}</p>
 		<button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
 			<template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }}
 		</button>
 		<template v-for="(message, i) in _messages">
-			<x-message :message="message" :key="message.id"/>
+			<x-message :message="message" :key="message.id" :is-group="group != null"/>
 			<p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date">
 				<span>{{ _messages[i + 1]._datetext }}</span>
 			</p>
@@ -23,7 +23,7 @@
 				<button @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button>
 			</div>
 		</transition>
-		<x-form :user="user" ref="form"/>
+		<x-form :user="user" :group="group" ref="form"/>
 	</footer>
 </div>
 </template>
@@ -34,17 +34,30 @@ import i18n from '../../../i18n';
 import XMessage from './messaging-room.message.vue';
 import XForm from './messaging-room.form.vue';
 import { url } from '../../../config';
-import { faArrowCircleDown } from '@fortawesome/free-solid-svg-icons';
-import { faFlag } from '@fortawesome/free-regular-svg-icons';
+import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/messaging-room.vue'),
+
 	components: {
 		XMessage,
 		XForm
 	},
 
-	props: ['user', 'isNaked'],
+	props: {
+		user: {
+			type: Object,
+			requird: false,
+		},
+		group: {
+			type: Object,
+			requird: false,
+		},
+		isNaked: {
+			type: Boolean,
+			requird: false,
+		},
+	},
 
 	data() {
 		return {
@@ -76,7 +89,10 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		this.connection = this.$root.stream.connectToChannel('messaging', { otherparty: this.user.id });
+		this.connection = this.$root.stream.connectToChannel('messaging', {
+			otherparty: this.user ? this.user.id : undefined,
+			group: this.group ? this.group.id : undefined,
+		});
 
 		this.connection.on('message', this.onMessage);
 		this.connection.on('read', this.onRead);
@@ -147,7 +163,8 @@ export default Vue.extend({
 				const max = this.existMoreMessages ? 20 : 10;
 
 				this.$root.api('messaging/messages', {
-					userId: this.user.id,
+					userId: this.user ? this.user.id : undefined,
+					groupId: this.group ? this.group.id : undefined,
 					limit: max + 1,
 					untilId: this.existMoreMessages ? this.messages[0].id : undefined
 				}).then(messages => {
@@ -199,12 +216,21 @@ export default Vue.extend({
 			}
 		},
 
-		onRead(ids) {
-			if (!Array.isArray(ids)) ids = [ids];
-			for (const id of ids) {
-				if (this.messages.some(x => x.id == id)) {
-					const exist = this.messages.map(x => x.id).indexOf(id);
-					this.messages[exist].isRead = true;
+		onRead(x) {
+			if (this.user) {
+				if (!Array.isArray(x)) x = [x];
+				for (const id of x) {
+					if (this.messages.some(x => x.id == id)) {
+						const exist = this.messages.map(x => x.id).indexOf(id);
+						this.messages[exist].isRead = true;
+					}
+				}
+			} else if (this.group) {
+				for (const id of x.ids) {
+					if (this.messages.some(x => x.id == id)) {
+						const exist = this.messages.map(x => x.id).indexOf(id);
+						this.messages[exist].reads.push(x.userId);
+					}
 				}
 			}
 		},
diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue
index f884a599d7..01d7a5a798 100644
--- a/src/client/app/common/views/components/messaging.vue
+++ b/src/client/app/common/views/components/messaging.vue
@@ -21,36 +21,62 @@
 		</div>
 	</div>
 	<div class="history" v-if="messages.length > 0">
-		<template>
-			<a v-for="message in messages"
-				class="user"
-				:href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
-				:data-is-me="isMe(message)"
-				:data-is-read="message.isRead"
-				@click.prevent="navigate(isMe(message) ? message.recipient : message.user)"
-				:key="message.id"
-			>
-				<div>
-					<mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/>
-					<header>
-						<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
-						<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
-						<mk-time :time="message.createdAt"/>
-					</header>
-					<div class="body">
-						<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
-					</div>
+		<div class="title">{{ $t('user') }}</div>
+		<a v-for="message in messages"
+			class="user"
+			:href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+			:data-is-me="isMe(message)"
+			:data-is-read="message.isRead"
+			@click.prevent="navigate(isMe(message) ? message.recipient : message.user)"
+			:key="message.id"
+		>
+			<div>
+				<mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/>
+				<header>
+					<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
+					<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
+					<mk-time :time="message.createdAt"/>
+				</header>
+				<div class="body">
+					<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
 				</div>
-			</a>
-		</template>
+			</div>
+		</a>
 	</div>
-	<p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p>
+	<div class="history" v-if="groupMessages.length > 0">
+		<div class="title">{{ $t('group') }}</div>
+		<a v-for="message in groupMessages"
+			class="user"
+			:href="`/i/messaging/group/${message.groupId}`"
+			:data-is-me="isMe(message)"
+			:data-is-read="message.reads.includes($store.state.i.id)"
+			@click.prevent="navigateGroup(message.group)"
+			:key="message.id"
+		>
+			<div>
+				<mk-avatar class="avatar" :user="message.user"/>
+				<header>
+					<span class="name">{{ message.group.name }}</span>
+					<mk-time :time="message.createdAt"/>
+				</header>
+				<div class="body">
+					<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
+				</div>
+			</div>
+		</a>
+	</div>
+	<p class="no-history" v-if="!fetching && (messages.length == 0 && groupMessages.length == 0)">{{ $t('no-history') }}</p>
 	<p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p>
+	<ui-margin>
+		<ui-button @click="startUser()"><fa :icon="faUser"/> {{ $t('start-with-user') }}</ui-button>
+		<ui-button @click="startGroup()"><fa :icon="faUsers"/> {{ $t('start-with-group') }}</ui-button>
+	</ui-margin>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import { faUser, faUsers } from '@fortawesome/free-solid-svg-icons';
 import i18n from '../../../i18n';
 import getAcct from '../../../../../misc/acct/render';
 
@@ -71,9 +97,11 @@ export default Vue.extend({
 			fetching: true,
 			moreFetching: false,
 			messages: [],
+			groupMessages: [],
 			q: null,
 			result: [],
-			connection: null
+			connection: null,
+			faUser, faUsers
 		};
 	},
 	mounted() {
@@ -82,9 +110,12 @@ export default Vue.extend({
 		this.connection.on('message', this.onMessage);
 		this.connection.on('read', this.onRead);
 
-		this.$root.api('messaging/history').then(messages => {
-			this.messages = messages;
-			this.fetching = false;
+		this.$root.api('messaging/history', { group: false }).then(messages => {
+			this.$root.api('messaging/history', { group: true }).then(groupMessages => {
+				this.messages = messages;
+				this.groupMessages = groupMessages;
+				this.fetching = false;
+			});
 		});
 	},
 	beforeDestroy() {
@@ -96,16 +127,27 @@ export default Vue.extend({
 			return message.userId == this.$store.state.i.id;
 		},
 		onMessage(message) {
-			this.messages = this.messages.filter(m => !(
-				(m.recipientId == message.recipientId && m.userId == message.userId) ||
-				(m.recipientId == message.userId && m.userId == message.recipientId)));
+			if (message.recipientId) {
+				this.messages = this.messages.filter(m => !(
+					(m.recipientId == message.recipientId && m.userId == message.userId) ||
+					(m.recipientId == message.userId && m.userId == message.recipientId)));
 
-			this.messages.unshift(message);
+				this.messages.unshift(message);
+			} else if (message.groupId) {
+				this.groupMessages = this.groupMessages.filter(m => m.groupId !== message.groupId);
+				this.groupMessages.unshift(message);
+			}
 		},
 		onRead(ids) {
 			for (const id of ids) {
 				const found = this.messages.find(m => m.id == id);
-				if (found) found.isRead = true;
+				if (found) {
+					if (found.recipientId) {
+						found.isRead = true;
+					} else if (found.groupId) {
+						found.reads.push(this.$store.state.i.id);
+					}
+				}
 			}
 		},
 		search() {
@@ -125,6 +167,9 @@ export default Vue.extend({
 		navigate(user) {
 			this.$emit('navigate', user);
 		},
+		navigateGroup(group) {
+			this.$emit('navigateGroup', group);
+		},
 		onSearchKeydown(e) {
 			switch (e.which) {
 				case 9: // [TAB]
@@ -161,6 +206,30 @@ export default Vue.extend({
 					(list.childNodes[i].nextElementSibling || list.childNodes[0]).focus();
 					break;
 			}
+		},
+		async startUser() {
+			const { result: user } = await this.$root.dialog({
+				user: {
+					local: true
+				}
+			});
+			if (user == null) return;
+			this.navigate(user);
+		},
+		async startGroup() {
+			const groups = await this.$root.api('users/groups/joined');
+			const { canceled, result: group } = await this.$root.dialog({
+				type: null,
+				title: this.$t('select-group'),
+				select: {
+					items: groups.map(group => ({
+						value: group, text: group.name
+					}))
+				},
+				showCancelButton: true
+			});
+			if (canceled) return;
+			this.navigateGroup(group);
 		}
 	}
 });
@@ -173,6 +242,9 @@ export default Vue.extend({
 		font-size 0.8em
 
 		> .history
+			> .title
+				padding 8px
+
 			> a
 				&:last-child
 					border-bottom none
@@ -311,6 +383,13 @@ export default Vue.extend({
 						color rgba(#000, 0.3)
 
 	> .history
+		> .title
+			padding 6px 16px
+			margin 0 auto
+			max-width 500px
+			background rgba(0, 0, 0, 0.05)
+			color var(--text)
+			font-size 85%
 
 		> a
 			display block
diff --git a/src/client/app/common/views/components/ui/hr.vue b/src/client/app/common/views/components/ui/hr.vue
new file mode 100644
index 0000000000..38572cfcc3
--- /dev/null
+++ b/src/client/app/common/views/components/ui/hr.vue
@@ -0,0 +1,15 @@
+<template>
+<div class="evrzpitu"></div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({});
+</script>
+
+<style lang="stylus" scoped>
+.evrzpitu
+	margin 16px 0
+	border-bottom solid var(--lineWidth) var(--faceDivider)
+
+</style>
diff --git a/src/client/app/common/views/components/ui/margin.vue b/src/client/app/common/views/components/ui/margin.vue
new file mode 100644
index 0000000000..508116f070
--- /dev/null
+++ b/src/client/app/common/views/components/ui/margin.vue
@@ -0,0 +1,16 @@
+<template>
+<div class="zdcrxcne">
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({});
+</script>
+
+<style lang="stylus" scoped>
+.zdcrxcne
+	margin 16px
+
+</style>
diff --git a/src/client/app/common/views/components/user-lists.vue b/src/client/app/common/views/components/user-lists.vue
deleted file mode 100644
index 699251b313..0000000000
--- a/src/client/app/common/views/components/user-lists.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<template>
-<div class="xkxvokkjlptzyewouewmceqcxhpgzprp">
-	<button class="ui" @click="add">{{ $t('create-list') }}</button>
-	<a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.name }}</a>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-
-export default Vue.extend({
-	i18n: i18n('common/views/components/user-lists.vue'),
-	data() {
-		return {
-			fetching: true,
-			lists: []
-		};
-	},
-	mounted() {
-		this.$root.api('users/lists/list').then(lists => {
-			this.fetching = false;
-			this.lists = lists;
-		});
-	},
-	methods: {
-		add() {
-			this.$root.dialog({
-				title: this.$t('list-name'),
-				input: true
-			}).then(async ({ canceled, result: name }) => {
-				if (canceled) return;
-				const list = await this.$root.api('users/lists/create', {
-					name
-				});
-
-				this.lists.push(list)
-				this.$emit('choosen', list);
-			});
-		},
-		choice(list) {
-			this.$emit('choosen', list);
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.xkxvokkjlptzyewouewmceqcxhpgzprp
-	padding 16px
-	background: var(--bg)
-
-	> button
-		display block
-		margin-bottom 16px
-		color var(--primaryForeground)
-		background var(--primary)
-		width 100%
-		border-radius 38px
-		user-select none
-		cursor pointer
-		padding 0 16px
-		min-width 100px
-		line-height 38px
-		font-size 14px
-		font-weight 700
-
-		&:hover
-			background var(--primaryLighten10)
-
-		&:active
-			background var(--primaryDarken10)
-
-	a
-		display block
-		margin 8px 0
-		padding 8px
-		color var(--text)
-		background var(--face)
-		box-shadow 0 2px 16px var(--reversiListItemShadow)
-		border-radius 6px
-		cursor pointer
-		line-height 32px
-
-		*
-			pointer-events none
-			user-select none
-
-		&:hover
-			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
-
-		&:active
-			box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
-
-</style>
diff --git a/src/client/app/common/views/components/user-menu.vue b/src/client/app/common/views/components/user-menu.vue
index 7cbffa9f9a..532dcf35c2 100644
--- a/src/client/app/common/views/components/user-menu.vue
+++ b/src/client/app/common/views/components/user-menu.vue
@@ -27,7 +27,7 @@ export default Vue.extend({
 			text: this.$t('push-to-list'),
 			action: this.pushList
 		}] as any;
-		
+
 		if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) {
 			menu = menu.concat([null, {
 				icon: this.user.isMuted ? ['fas', 'eye'] : ['far', 'eye-slash'],
diff --git a/src/client/app/common/views/deck/deck.explore-column.vue b/src/client/app/common/views/deck/deck.column-template.vue
similarity index 50%
rename from src/client/app/common/views/deck/deck.explore-column.vue
rename to src/client/app/common/views/deck/deck.column-template.vue
index 53db677b37..09583de4b2 100644
--- a/src/client/app/common/views/deck/deck.explore-column.vue
+++ b/src/client/app/common/views/deck/deck.column-template.vue
@@ -1,34 +1,45 @@
 <template>
 <x-column>
 	<template #header>
-		<fa :icon="faHashtag"/>{{ $t('@.explore') }}
+		<fa :icon="icon"/>{{ title }}
 	</template>
 
 	<div>
-		<x-explore v-bind="$attrs"/>
+		<component :is="component" @init="init" v-bind="$attrs"/>
 	</div>
 </x-column>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
-import i18n from '../../../i18n';
 import XColumn from './deck.column.vue';
-import XExplore from '../../../common/views/pages/explore.vue';
-import { faHashtag } from '@fortawesome/free-solid-svg-icons';
 
 export default Vue.extend({
-	i18n: i18n(),
-
 	components: {
 		XColumn,
-		XExplore,
+	},
+
+	props: {
+		component: {
+			required: true
+		}
 	},
 
 	data() {
 		return {
-			faHashtag
+			title: null,
+			icon: null,
 		};
+	},
+
+	mounted() {
+	},
+
+	methods: {
+		init(v) {
+			this.title = v.title;
+			this.icon = v.icon;
+		}
 	}
 });
 </script>
diff --git a/src/client/app/common/views/pages/explore.vue b/src/client/app/common/views/pages/explore.vue
index d0e98035f8..bf0d7ab574 100644
--- a/src/client/app/common/views/pages/explore.vue
+++ b/src/client/app/common/views/pages/explore.vue
@@ -116,6 +116,10 @@ export default Vue.extend({
 	},
 
 	created() {
+		this.$emit('init', {
+			title: this.$t('@.explore'),
+			icon: faHashtag
+		});
 		this.$root.api('hashtags/list', {
 			sort: '+attachedLocalUsers',
 			attachedToLocalUserOnly: true,
diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/common/views/pages/follow-requests.vue
similarity index 57%
rename from src/client/app/mobile/views/pages/received-follow-requests.vue
rename to src/client/app/common/views/pages/follow-requests.vue
index abf0c33830..860efefd93 100644
--- a/src/client/app/mobile/views/pages/received-follow-requests.vue
+++ b/src/client/app/common/views/pages/follow-requests.vue
@@ -1,27 +1,30 @@
 <template>
-<mk-ui>
-	<template #header><fa :icon="['far', 'envelope']"/>{{ $t('title') }}</template>
-
-	<main>
-		<div v-for="req in requests">
-			<router-link :key="req.id" :to="req.follower | userPage">
-				<mk-user-name :user="req.follower"/>
-			</router-link>
-			<span>
-				<a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a>
-			</span>
+<div>
+	<ui-container :body-togglable="true">
+		<template #header>{{ $t('received-follow-requests') }}</template>
+		<div v-if="!fetching">
+			<sequential-entrance animation="entranceFromTop" delay="25" tag="div" class="mcbzkkaw">
+				<div v-for="req in requests">
+					<router-link :key="req.id" :to="req.follower | userPage">
+						<mk-user-name :user="req.follower"/>
+					</router-link>
+					<span>
+						<a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a>
+					</span>
+				</div>
+			</sequential-entrance>
 		</div>
-	</main>
-</mk-ui>
+	</ui-container>
+</div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import Progress from '../../../common/scripts/loading';
+import Progress from '../../scripts/loading';
 
 export default Vue.extend({
-	i18n: i18n('mobile/views/pages/received-follow-requests.vue'),
+	i18n: i18n('common/views/pages/follow-requests.vue'),
 	data() {
 		return {
 			fetching: true,
@@ -29,14 +32,10 @@ export default Vue.extend({
 		};
 	},
 	mounted() {
-		document.title = this.$t('title');
-
 		Progress.start();
-
 		this.$root.api('following/requests/list').then(requests => {
 			this.fetching = false;
 			this.requests = requests;
-
 			Progress.done();
 		});
 	},
@@ -56,7 +55,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-main
+.mcbzkkaw
 	> div
 		display flex
 		padding 16px
diff --git a/src/client/app/common/views/pages/pages.vue b/src/client/app/common/views/pages/pages.vue
index 751ea72374..d658728a19 100644
--- a/src/client/app/common/views/pages/pages.vue
+++ b/src/client/app/common/views/pages/pages.vue
@@ -50,6 +50,11 @@ export default Vue.extend({
 	},
 	created() {
 		this.fetch();
+
+		this.$emit('init', {
+			title: this.$t('@.pages'),
+			icon: faStickyNote
+		});
 	},
 	methods: {
 		async fetch() {
diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue
new file mode 100644
index 0000000000..c658d0c6ff
--- /dev/null
+++ b/src/client/app/common/views/pages/user-group-editor.vue
@@ -0,0 +1,180 @@
+<template>
+<div class="ivrbakop">
+	<ui-container v-if="group">
+		<template #header><fa :icon="faUsers"/> {{ group.name }}</template>
+
+		<section>
+			<ui-margin>
+				<ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button>
+				<ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
+			</ui-margin>
+		</section>
+	</ui-container>
+
+	<ui-container>
+		<template #header><fa :icon="faUsers"/> {{ $t('users') }}</template>
+
+		<section>
+			<ui-margin>
+				<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button>
+			</ui-margin>
+			<sequential-entrance animation="entranceFromTop" delay="25">
+				<div class="kjlrfbes" v-for="user in users">
+					<div>
+						<a :href="user | userPage">
+							<mk-avatar class="avatar" :user="user" :disable-link="true"/>
+						</a>
+					</div>
+					<div>
+						<header>
+							<b><mk-user-name :user="user"/></b>
+							<span class="username">@{{ user | acct }}</span>
+						</header>
+						<div>
+							<a @click="remove(user)">{{ $t('remove-user') }}</a>
+						</div>
+					</div>
+				</div>
+			</sequential-entrance>
+		</section>
+	</ui-container>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../../i18n';
+import { faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+
+export default Vue.extend({
+	i18n: i18n('common/views/components/user-group-editor.vue'),
+
+	props: {
+		groupId: {
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			group: null,
+			users: [],
+			faICursor, faTrashAlt, faUsers, faPlus
+		};
+	},
+
+	created() {
+		this.$root.api('users/groups/show', {
+			groupId: this.groupId
+		}).then(group => {
+			this.group = group;
+			this.fetchUsers();
+			this.$emit('init', {
+				title: this.group.name,
+				icon: faUsers
+			});
+		});
+	},
+
+	methods: {
+		fetchUsers() {
+			this.$root.api('users/show', {
+				userIds: this.group.userIds
+			}).then(users => {
+				this.users = users;
+			});
+		},
+
+		rename() {
+			this.$root.dialog({
+				title: this.$t('rename'),
+				input: {
+					default: this.group.name
+				}
+			}).then(({ canceled, result: name }) => {
+				if (canceled) return;
+				this.$root.api('users/groups/update', {
+					groupId: this.group.id,
+					name: name
+				});
+			});
+		},
+
+		del() {
+			this.$root.dialog({
+				type: 'warning',
+				text: this.$t('delete-are-you-sure').replace('$1', this.group.name),
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+
+				this.$root.api('users/groups/delete', {
+					groupId: this.group.id
+				}).then(() => {
+					this.$root.dialog({
+						type: 'success',
+						text: this.$t('deleted')
+					});
+				}).catch(e => {
+					this.$root.dialog({
+						type: 'error',
+						text: e
+					});
+				});
+			});
+		},
+
+		remove(user: any) {
+			this.$root.api('users/groups/pull', {
+				groupId: this.group.id,
+				userId: user.id
+			}).then(() => {
+				this.fetchUsers();
+			});
+		},
+
+		async add() {
+			const { result: user } = await this.$root.dialog({
+				user: {
+					local: true
+				}
+			});
+			if (user == null) return;
+			this.$root.api('users/groups/push', {
+				groupId: this.group.id,
+				userId: user.id
+			}).then(() => {
+				this.fetchUsers();
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.ivrbakop
+	.kjlrfbes
+		display flex
+		padding 16px
+		border-top solid 1px var(--faceDivider)
+
+		> div:first-child
+			> a
+				> .avatar
+					width 64px
+					height 64px
+
+		> div:last-child
+			flex 1
+			padding-left 16px
+
+			@media (max-width 500px)
+				font-size 14px
+
+			> header
+				> .username
+					margin-left 8px
+					opacity 0.7
+
+</style>
diff --git a/src/client/app/common/views/pages/user-groups.vue b/src/client/app/common/views/pages/user-groups.vue
new file mode 100644
index 0000000000..336772799b
--- /dev/null
+++ b/src/client/app/common/views/pages/user-groups.vue
@@ -0,0 +1,63 @@
+<template>
+<ui-container>
+	<template #header><fa :icon="faUsers"/> {{ $t('user-groups') }}</template>
+	<ui-margin>
+		<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-group') }}</ui-button>
+	</ui-margin>
+	<div class="hwgkdrbl" v-for="group in groups" :key="group.id">
+		<ui-hr/>
+		<ui-margin>
+			<router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link>
+		</ui-margin>
+	</div>
+</ui-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../../i18n';
+import { faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
+
+export default Vue.extend({
+	i18n: i18n('common/views/components/user-groups.vue'),
+	data() {
+		return {
+			fetching: true,
+			groups: [],
+			faUsers, faPlus
+		};
+	},
+	mounted() {
+		this.$root.api('users/groups/owned').then(groups => {
+			this.fetching = false;
+			this.groups = groups;
+		});
+
+		this.$emit('init', {
+			title: this.$t('user-groups'),
+			icon: faUsers
+		});
+	},
+	methods: {
+		add() {
+			this.$root.dialog({
+				title: this.$t('group-name'),
+				input: true
+			}).then(async ({ canceled, result: name }) => {
+				if (canceled) return;
+				const list = await this.$root.api('users/groups/create', {
+					name
+				});
+
+				this.groups.push(list)
+			});
+		},
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.hwgkdrbl
+	display block
+
+</style>
diff --git a/src/client/app/common/views/components/user-list-editor.vue b/src/client/app/common/views/pages/user-list-editor.vue
similarity index 66%
rename from src/client/app/common/views/components/user-list-editor.vue
rename to src/client/app/common/views/pages/user-list-editor.vue
index 86024c4da3..6b2fd75f85 100644
--- a/src/client/app/common/views/components/user-list-editor.vue
+++ b/src/client/app/common/views/pages/user-list-editor.vue
@@ -1,18 +1,23 @@
 <template>
 <div class="cudqjmnl">
-	<ui-card>
-		<template #title><fa :icon="faList"/> {{ list.name }}</template>
+	<ui-container v-if="list">
+		<template #header><fa :icon="faListUl"/> {{ list.name }}</template>
 
-		<section>
-			<ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button>
-			<ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
+		<section class="fwvevrks">
+			<ui-margin>
+				<ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button>
+				<ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
+			</ui-margin>
 		</section>
-	</ui-card>
+	</ui-container>
 
-	<ui-card>
-		<template #title><fa :icon="faUsers"/> {{ $t('users') }}</template>
+	<ui-container>
+		<template #header><fa :icon="faUsers"/> {{ $t('users') }}</template>
 
 		<section>
+			<ui-margin>
+				<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button>
+			</ui-margin>
 			<sequential-entrance animation="entranceFromTop" delay="25">
 				<div class="phcqulfl" v-for="user in users">
 					<div>
@@ -32,34 +37,44 @@
 				</div>
 			</sequential-entrance>
 		</section>
-	</ui-card>
+	</ui-container>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import { faList, faICursor, faUsers } from '@fortawesome/free-solid-svg-icons';
+import { faListUl, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons';
 import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/user-list-editor.vue'),
 
 	props: {
-		list: {
+		listId: {
 			required: true
 		}
 	},
 
 	data() {
 		return {
+			list: null,
 			users: [],
-			faList, faICursor, faTrashAlt, faUsers
+			faListUl, faICursor, faTrashAlt, faUsers, faPlus
 		};
 	},
 
-	mounted() {
-		this.fetchUsers();
+	created() {
+		this.$root.api('users/lists/show', {
+			listId: this.listId
+		}).then(list => {
+			this.list = list;
+			this.fetchUsers();
+			this.$emit('init', {
+				title: this.list.name,
+				icon: faListUl
+			});
+		});
 	},
 
 	methods: {
@@ -117,6 +132,21 @@ export default Vue.extend({
 			}).then(() => {
 				this.fetchUsers();
 			});
+		},
+
+		async add() {
+			const { result: user } = await this.$root.dialog({
+				user: {
+					local: true
+				}
+			});
+			if (user == null) return;
+			this.$root.api('users/lists/push', {
+				listId: this.list.id,
+				userId: user.id
+			}).then(() => {
+				this.fetchUsers();
+			});
 		}
 	}
 });
@@ -126,7 +156,7 @@ export default Vue.extend({
 .cudqjmnl
 	.phcqulfl
 		display flex
-		padding 16px 0
+		padding 16px
 		border-top solid 1px var(--faceDivider)
 
 		> div:first-child
diff --git a/src/client/app/common/views/pages/user-lists.vue b/src/client/app/common/views/pages/user-lists.vue
new file mode 100644
index 0000000000..4c09eca6ce
--- /dev/null
+++ b/src/client/app/common/views/pages/user-lists.vue
@@ -0,0 +1,63 @@
+<template>
+<ui-container>
+	<template #header><fa :icon="faListUl"/> {{ $t('user-lists') }}</template>
+	<ui-margin>
+		<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-list') }}</ui-button>
+	</ui-margin>
+	<div class="cpqqyrst" v-for="list in lists" :key="list.id">
+		<ui-hr/>
+		<ui-margin>
+			<router-link :to="`/i/lists/${list.id}`">{{ list.name }}</router-link>
+		</ui-margin>
+	</div>
+</ui-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../../i18n';
+import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons';
+
+export default Vue.extend({
+	i18n: i18n('common/views/components/user-lists.vue'),
+	data() {
+		return {
+			fetching: true,
+			lists: [],
+			faListUl, faPlus
+		};
+	},
+	mounted() {
+		this.$root.api('users/lists/list').then(lists => {
+			this.fetching = false;
+			this.lists = lists;
+		});
+
+		this.$emit('init', {
+			title: this.$t('user-lists'),
+			icon: faListUl
+		});
+	},
+	methods: {
+		add() {
+			this.$root.dialog({
+				title: this.$t('list-name'),
+				input: true
+			}).then(async ({ canceled, result: name }) => {
+				if (canceled) return;
+				const list = await this.$root.api('users/lists/create', {
+					name
+				});
+
+				this.lists.push(list)
+			});
+		},
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.cpqqyrst
+	display block
+
+</style>
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index 464f7d3ce9..c6479f477c 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -22,6 +22,7 @@ import MkShare from '../common/views/pages/share.vue';
 import MkFollow from '../common/views/pages/follow.vue';
 import MkNotFound from '../common/views/pages/not-found.vue';
 import MkSettings from './views/pages/settings.vue';
+import DeckColumn from '../common/views/deck/deck.column-template.vue';
 
 import Ctx from './views/components/context-menu.vue';
 import PostFormWindow from './views/components/post-form-window.vue';
@@ -138,9 +139,14 @@ init(async (launch, os) => {
 					{ path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) },
 					{ path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) },
 					{ path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) },
-					{ path: '/explore', name: 'explore', component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
-					{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
-					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) }
+					{ path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
+					{ path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
+					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) },
+					{ path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
+					{ path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
+					{ path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) },
+					{ path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) },
+					{ path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) },
 				]}
 				: { path: '/', component: MkHome, children: [
 					{ path: '', name: 'index', component: MkHomeTimeline },
@@ -157,11 +163,17 @@ init(async (launch, os) => {
 					{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
 					{ path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) },
 					{ path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) },
+					{ path: '/i/lists', component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) },
+					{ path: '/i/lists/:listId', props: true, component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default) },
+					{ path: '/i/groups', component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) },
+					{ path: '/i/groups/:groupId', props: true, component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default) },
+					{ path: '/i/follow-requests', component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) },
 				]},
 			{ path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) },
 			{ path: '/@:user/pages/:pageName/view-source', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
 			{ path: '/i/pages/new', component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
 			{ path: '/i/pages/edit/:pageId', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) },
+			{ path: '/i/messaging/group/:group', component: MkMessagingRoom },
 			{ path: '/i/messaging/:user', component: MkMessagingRoom },
 			{ path: '/i/drive', component: MkDrive },
 			{ path: '/i/drive/folder/:folder', component: MkDrive },
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 00cd423cd2..6c1708b59f 100644
--- a/src/client/app/desktop/views/components/messaging-room-window.vue
+++ b/src/client/app/desktop/views/components/messaging-room-window.vue
@@ -1,7 +1,7 @@
 <template>
 <mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom">
-	<template #header><fa icon="comments"/> {{ $t('@.messaging') }}: <mk-user-name :user="user"/></template>
-	<x-messaging-room :user="user" :class="$style.content"/>
+	<template #header><fa icon="comments"/> {{ $t('@.messaging') }}: <mk-user-name v-if="user" :user="user"/><span v-else>{{ group.name }}</span></template>
+	<x-messaging-room :user="user" :group="group" :class="$style.content"/>
 </mk-window>
 </template>
 
@@ -16,10 +16,14 @@ export default Vue.extend({
 	components: {
 		XMessagingRoom: () => import('../../../common/views/components/messaging-room.vue').then(m => m.default)
 	},
-	props: ['user'],
+	props: ['user', 'group'],
 	computed: {
 		popout(): string {
-			return `${url}/i/messaging/${getAcct(this.user)}`;
+			if (this.user) {
+				return `${url}/i/messaging/${getAcct(this.user)}`;
+			} else if (this.group) {
+				return `${url}/i/messaging/group/${this.group.id}`;
+			}
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue
index 1572c40669..7cec9484d6 100644
--- a/src/client/app/desktop/views/components/messaging-window.vue
+++ b/src/client/app/desktop/views/components/messaging-window.vue
@@ -1,7 +1,7 @@
 <template>
 <mk-window ref="window" width="500px" height="560px" @closed="destroyDom">
 	<template #header :class="$style.header"><fa icon="comments"/>{{ $t('@.messaging') }}</template>
-	<x-messaging :class="$style.content" @navigate="navigate"/>
+	<x-messaging :class="$style.content" @navigate="navigate" @navigateGroup="navigateGroup"/>
 </mk-window>
 </template>
 
@@ -20,6 +20,11 @@ export default Vue.extend({
 			this.$root.new(MkMessagingRoomWindow, {
 				user: user
 			});
+		},
+		navigateGroup(group) {
+			this.$root.new(MkMessagingRoomWindow, {
+				group: group
+			});
 		}
 	}
 });
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
deleted file mode 100644
index f86b6b0d59..0000000000
--- a/src/client/app/desktop/views/components/received-follow-requests-window.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom">
-	<template #header><fa :icon="['far', 'envelope']"/> {{ $t('title') }}</template>
-
-	<div class="slpqaxdoxhvglersgjukmvizkqbmbokc">
-		<div v-for="req in requests">
-			<router-link :key="req.id" :to="req.follower | userPage">
-				<mk-user-name :user="req.follower"/>
-			</router-link>
-			<span>
-				<a @click="accept(req.follower)">{{ $t('accept') }}</a>|<a @click="reject(req.follower)">{{ $t('reject') }}</a>
-			</span>
-		</div>
-	</div>
-</mk-window>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-
-export default Vue.extend({
-	i18n: i18n('desktop/views/components/received-follow-requests-window.vue'),
-	data() {
-		return {
-			fetching: true,
-			requests: []
-		};
-	},
-	mounted() {
-		this.$root.api('following/requests/list').then(requests => {
-			this.fetching = false;
-			this.requests = requests;
-		});
-	},
-	methods: {
-		accept(user) {
-			this.$root.api('following/requests/accept', { userId: user.id }).then(() => {
-				this.requests = this.requests.filter(r => r.follower.id != user.id);
-			});
-		},
-		reject(user) {
-			this.$root.api('following/requests/reject', { userId: user.id }).then(() => {
-				this.requests = this.requests.filter(r => r.follower.id != user.id);
-			});
-		},
-		close() {
-			(this as any).$refs.window.close();
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.slpqaxdoxhvglersgjukmvizkqbmbokc
-	padding 16px
-
-	> button
-		margin-bottom 16px
-
-	> div
-		display flex
-		padding 16px
-		border solid 1px var(--faceDivider)
-		border-radius 4px
-
-		> span
-			margin 0 0 0 auto
-
-</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 9b87e0c29f..c00c6b9c64 100644
--- a/src/client/app/desktop/views/components/ui.header.account.vue
+++ b/src/client/app/desktop/views/components/ui.header.account.vue
@@ -28,12 +28,19 @@
 						<i><fa icon="angle-right"/></i>
 					</router-link>
 				</li>
-				<li @click="list">
-					<p>
+				<li>
+					<router-link to="/i/lists">
 						<i><fa icon="list" fixed-width/></i>
 						<span>{{ $t('lists') }}</span>
 						<i><fa icon="angle-right"/></i>
-					</p>
+					</router-link>
+				</li>
+				<li>
+					<router-link to="/i/groups">
+						<i><fa :icon="faUsers" fixed-width/></i>
+						<span>{{ $t('groups') }}</span>
+						<i><fa icon="angle-right"/></i>
+					</router-link>
 				</li>
 				<li>
 					<router-link to="/i/pages">
@@ -42,12 +49,12 @@
 						<i><fa icon="angle-right"/></i>
 					</router-link>
 				</li>
-				<li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)">
-					<p>
+				<li v-if="($store.state.i.isLocked || $store.state.i.carefulBot)">
+					<router-link to="/i/follow-requests">
 						<i><fa :icon="['far', 'envelope']" fixed-width/></i>
 						<span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span>
 						<i><fa icon="angle-right"/></i>
-					</p>
+					</router-link>
 				</li>
 			</ul>
 			<ul>
@@ -96,12 +103,10 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import MkUserListsWindow from './user-lists-window.vue';
-import MkFollowRequestsWindow from './received-follow-requests-window.vue';
 // import MkSettingsWindow from './settings-window.vue';
 import MkDriveWindow from './drive-window.vue';
 import contains from '../../../common/scripts/contains';
-import { faHome, faColumns } from '@fortawesome/free-solid-svg-icons';
+import { faHome, faColumns, faUsers } from '@fortawesome/free-solid-svg-icons';
 import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
@@ -109,7 +114,7 @@ export default Vue.extend({
 	data() {
 		return {
 			isOpen: false,
-			faHome, faColumns, faMoon, faSun, faStickyNote
+			faHome, faColumns, faMoon, faSun, faStickyNote, faUsers
 		};
 	},
 	computed: {
@@ -147,14 +152,6 @@ export default Vue.extend({
 			this.close();
 			this.$root.new(MkDriveWindow);
 		},
-		list() {
-			this.close();
-			this.$root.new(MkUserListsWindow);
-		},
-		followRequests() {
-			this.close();
-			this.$root.new(MkFollowRequestsWindow);
-		},
 		signout() {
 			this.$root.signout();
 		},
diff --git a/src/client/app/desktop/views/components/ui.sidebar.vue b/src/client/app/desktop/views/components/ui.sidebar.vue
index 1c01f127b9..d1ceec5198 100644
--- a/src/client/app/desktop/views/components/ui.sidebar.vue
+++ b/src/client/app/desktop/views/components/ui.sidebar.vue
@@ -72,8 +72,6 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import MkUserListsWindow from './user-lists-window.vue';
-import MkFollowRequestsWindow from './received-follow-requests-window.vue';
 import MkSettingsWindow from './settings-window.vue';
 import MkDriveWindow from './drive-window.vue';
 import MkMessagingWindow from './messaging-window.vue';
diff --git a/src/client/app/desktop/views/components/user-list-window.vue b/src/client/app/desktop/views/components/user-list-window.vue
deleted file mode 100644
index 6764579b20..0000000000
--- a/src/client/app/desktop/views/components/user-list-window.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<template>
-<mk-window ref="window" width="450px" height="500px" @closed="destroyDom">
-	<template #header><fa icon="list"/> {{ list.name }}</template>
-
-	<x-editor :list="list"/>
-</mk-window>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import XEditor from '../../../common/views/components/user-list-editor.vue';
-
-export default Vue.extend({
-	components: {
-		XEditor
-	},
-
-	props: {
-		list: {
-			required: true
-		}
-	}
-});
-</script>
diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue
deleted file mode 100644
index afea01d4a1..0000000000
--- a/src/client/app/desktop/views/components/user-lists-window.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<template>
-<mk-window ref="window" width="450px" height="500px" @closed="destroyDom">
-	<template #header><fa icon="list"/> {{ $t('title') }}</template>
-	<x-lists :class="$style.content" @choosen="choosen"/>
-</mk-window>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import MkUserListWindow from './user-list-window.vue';
-
-export default Vue.extend({
-	i18n: i18n('desktop/views/components/user-lists-window.vue'),
-	components: {
-		XLists: () => import('../../../common/views/components/user-lists.vue').then(m => m.default)
-	},
-	methods: {
-		close() {
-			(this as any).$refs.window.close();
-		},
-		choosen(list) {
-			this.$root.new(MkUserListWindow, {
-				list
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" module>
-.content
-	height 100%
-	overflow auto
-
-</style>
diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue
index 376b402d30..c725074b7d 100644
--- a/src/client/app/desktop/views/pages/messaging-room.vue
+++ b/src/client/app/desktop/views/pages/messaging-room.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mk-messaging-room-page">
-	<x-messaging-room v-if="user" :user="user" :is-naked="true"/>
+	<x-messaging-room v-if="user || group" :user="user" :group="group" :is-naked="true"/>
 </div>
 </template>
 
@@ -19,7 +19,8 @@ export default Vue.extend({
 	data() {
 		return {
 			fetching: true,
-			user: null
+			user: null,
+			group: null
 		};
 	},
 	watch: {
@@ -47,14 +48,25 @@ export default Vue.extend({
 			Progress.start();
 			this.fetching = true;
 
-			this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
-				this.user = user;
-				this.fetching = false;
+			if (this.$route.params.user) {
+				this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
+					this.user = user;
+					this.fetching = false;
 
-				document.title = this.$t('@.messaging') + ': ' + getUserName(this.user);
+					document.title = this.$t('@.messaging') + ': ' + getUserName(this.user);
 
-				Progress.done();
-			});
+					Progress.done();
+				});
+			} else {
+				this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => {
+					this.group = group;
+					this.fetching = false;
+
+					document.title = this.$t('@.messaging') + ': ' + this.group.name;
+
+					Progress.done();
+				});
+			}
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue
index 1e82ae3d3a..e94e745c19 100644
--- a/src/client/app/desktop/views/widgets/messaging.vue
+++ b/src/client/app/desktop/views/widgets/messaging.vue
@@ -4,7 +4,7 @@
 		<template #header><fa icon="comments"/>{{ $t('@.messaging') }}</template>
 		<template #func><button @click="add"><fa icon="plus"/></button></template>
 
-		<x-messaging ref="index" compact @navigate="navigate"/>
+		<x-messaging ref="index" compact @navigate="navigate" @navigateGroup="navigateGroup"/>
 	</ui-container>
 </div>
 </template>
@@ -31,6 +31,11 @@ export default define({
 				user: user
 			});
 		},
+		navigateGroup(group) {
+			this.$root.new(MkMessagingRoomWindow, {
+				group: group
+			});
+		},
 		add() {
 			this.$root.new(MkMessagingWindow);
 		},
diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts
index 4a79d88773..360da01496 100644
--- a/src/client/app/mobile/script.ts
+++ b/src/client/app/mobile/script.ts
@@ -18,17 +18,16 @@ import MkDrive from './views/pages/drive.vue';
 import MkWidgets from './views/pages/widgets.vue';
 import MkMessaging from './views/pages/messaging.vue';
 import MkMessagingRoom from './views/pages/messaging-room.vue';
-import MkReceivedFollowRequests from './views/pages/received-follow-requests.vue';
 import MkNote from './views/pages/note.vue';
 import MkSearch from './views/pages/search.vue';
 import MkFavorites from './views/pages/favorites.vue';
-import MkUserLists from './views/pages/user-lists.vue';
-import MkUserList from './views/pages/user-list.vue';
+import UI from './views/pages/ui.vue';
 import MkReversi from './views/pages/games/reversi.vue';
 import MkTag from './views/pages/tag.vue';
 import MkShare from '../common/views/pages/share.vue';
 import MkFollow from '../common/views/pages/follow.vue';
 import MkNotFound from '../common/views/pages/not-found.vue';
+import DeckColumn from '../common/views/deck/deck.column-template.vue';
 
 import PostForm from './views/components/post-form-dialog.vue';
 import FileChooser from './views/components/drive-file-chooser.vue';
@@ -125,9 +124,14 @@ init((launch, os) => {
 					{ path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) },
 					{ path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) },
 					{ path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) },
-					{ path: '/explore', name: 'explore', component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
-					{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
-					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) }
+					{ path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
+					{ path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
+					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) },
+					{ path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
+					{ path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
+					{ path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) },
+					{ path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) },
+					{ path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) },
 				]}]
 			: [
 				{ path: '/', name: 'index', component: MkIndex },
@@ -135,12 +139,15 @@ init((launch, os) => {
 			{ path: '/signup', name: 'signup', component: MkSignup },
 			{ path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) },
 			{ path: '/i/favorites', name: 'favorites', component: MkFavorites },
-			{ path: '/i/pages', name: 'pages', component: () => import('./views/pages/pages.vue').then(m => m.default) },
-			{ path: '/i/lists', name: 'user-lists', component: MkUserLists },
-			{ path: '/i/lists/:list', name: 'user-list', component: MkUserList },
-			{ path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests },
+			{ path: '/i/pages', name: 'pages', component: UI, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
+			{ path: '/i/lists', name: 'user-lists', component: UI, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
+			{ path: '/i/lists/:list', component: UI, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.list }) },
+			{ path: '/i/groups', name: 'user-groups', component: UI, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) },
+			{ path: '/i/groups/:group', component: UI, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.group }) },
+			{ path: '/i/follow-requests', name: 'follow-requests', component: UI, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) },
 			{ path: '/i/widgets', name: 'widgets', component: MkWidgets },
 			{ path: '/i/messaging', name: 'messaging', component: MkMessaging },
+			{ path: '/i/messaging/group/:group', component: MkMessagingRoom },
 			{ path: '/i/messaging/:user', component: MkMessagingRoom },
 			{ path: '/i/drive', name: 'drive', component: MkDrive },
 			{ path: '/i/drive/folder/:folder', component: MkDrive },
@@ -151,8 +158,8 @@ init((launch, os) => {
 			{ path: '/search', component: MkSearch },
 			{ path: '/tags/:tag', component: MkTag },
 			{ path: '/featured', name: 'featured', component: () => import('./views/pages/featured.vue').then(m => m.default) },
-			{ path: '/explore', name: 'explore', component: () => import('./views/pages/explore.vue').then(m => m.default) },
-			{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('./views/pages/explore.vue').then(m => m.default) },
+			{ path: '/explore', name: 'explore', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
+			{ path: '/explore/tags/:tag', name: 'explore-tag', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
 			{ path: '/share', component: MkShare },
 			{ path: '/games/reversi/:game?', name: 'reversi', component: MkReversi },
 			{ path: '/@:user', name: 'user', component: () => import('./views/pages/user/index.vue').then(m => m.default), children: [
diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue
index da9bb518ef..29c744d898 100644
--- a/src/client/app/mobile/views/components/ui.nav.vue
+++ b/src/client/app/mobile/views/components/ui.nav.vue
@@ -19,7 +19,7 @@
 						<li><router-link to="/" :data-active="$route.name == 'index'"><i><fa icon="home" fixed-width/></i>{{ $t('timeline') }}<i><fa icon="angle-right"/></i></router-link></li>
 						<li><p @click="showNotifications = true"><i><fa :icon="['far', 'bell']" fixed-width/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></p></li>
 						<li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'"><i><fa :icon="['far', 'comments']" fixed-width/></i>{{ $t('@.messaging') }}<i v-if="hasUnreadMessagingMessage" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li>
-						<li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li>
+						<li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/follow-requests" :data-active="$route.name == 'follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li>
 						<li><router-link to="/featured" :data-active="$route.name == 'featured'"><i><fa :icon="faNewspaper" fixed-width/></i>{{ $t('@.featured-notes') }}<i><fa icon="angle-right"/></i></router-link></li>
 						<li><router-link to="/explore" :data-active="$route.name == 'explore' || $route.name == 'explore-tag'"><i><fa :icon="faHashtag" fixed-width/></i>{{ $t('@.explore') }}<i><fa icon="angle-right"/></i></router-link></li>
 						<li><router-link to="/games/reversi" :data-active="$route.name == 'reversi'"><i><fa icon="gamepad" fixed-width/></i>{{ $t('game') }}<i v-if="hasGameInvitation" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li>
diff --git a/src/client/app/mobile/views/pages/explore.vue b/src/client/app/mobile/views/pages/explore.vue
deleted file mode 100644
index 111721bc8a..0000000000
--- a/src/client/app/mobile/views/pages/explore.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<template>
-<mk-ui>
-	<template #header><span style="margin-right:4px;"><fa :icon="faHashtag"/></span>{{ $t('@.explore') }}</template>
-
-	<main>
-		<x-explore v-bind="$attrs"/>
-	</main>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import { faHashtag } from '@fortawesome/free-solid-svg-icons';
-import XExplore from '../../../common/views/pages/explore.vue';
-
-export default Vue.extend({
-	i18n: i18n(''),
-	components: {
-		XExplore
-	},
-	data() {
-		return {
-			faHashtag
-		};
-	},
-});
-</script>
diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue
index aa00d48699..7872847127 100644
--- a/src/client/app/mobile/views/pages/messaging-room.vue
+++ b/src/client/app/mobile/views/pages/messaging-room.vue
@@ -2,9 +2,10 @@
 <mk-ui>
 	<template #header>
 		<template v-if="user"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span><mk-user-name :user="user"/></template>
+		<template v-else-if="group"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ group.name }}</template>
 		<template v-else><mk-ellipsis/></template>
 	</template>
-	<x-messaging-room v-if="!fetching" :user="user" :is-naked="true"/>
+	<x-messaging-room v-if="!fetching" :user="user" :group="group" :is-naked="true"/>
 </mk-ui>
 </template>
 
@@ -22,6 +23,7 @@ export default Vue.extend({
 		return {
 			fetching: true,
 			user: null,
+			group: null,
 			unwatchDarkmode: null
 		};
 	},
@@ -48,12 +50,21 @@ export default Vue.extend({
 	methods: {
 		fetch() {
 			this.fetching = true;
-			this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
-				this.user = user;
-				this.fetching = false;
+			if (this.$route.params.user) {
+				this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
+					this.user = user;
+					this.fetching = false;
 
-				document.title = `${this.$t('@.messaging')}: ${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`;
-			});
+					document.title = `${this.$t('@.messaging')}: ${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`;
+				});
+			} else {
+				this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => {
+					this.group = group;
+					this.fetching = false;
+
+					document.title = this.$t('@.messaging') + ': ' + this.group.name;
+				});
+			}
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue
index 5ce2f14bbd..ff66ae06e6 100644
--- a/src/client/app/mobile/views/pages/messaging.vue
+++ b/src/client/app/mobile/views/pages/messaging.vue
@@ -1,7 +1,7 @@
 <template>
 <mk-ui>
 	<template #header><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ $t('@.messaging') }}</template>
-	<x-messaging @navigate="navigate" :header-top="48"/>
+	<x-messaging @navigate="navigate" @navigateGroup="navigateGroup" :header-top="48"/>
 </mk-ui>
 </template>
 
@@ -21,6 +21,9 @@ export default Vue.extend({
 	methods: {
 		navigate(user) {
 			(this as any).$router.push(`/i/messaging/${getAcct(user)}`);
+		},
+		navigateGroup(group) {
+			(this as any).$router.push(`/i/messaging/group/${group.id}`);
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue
deleted file mode 100644
index 2fd134fcd2..0000000000
--- a/src/client/app/mobile/views/pages/pages.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<template>
-<mk-ui>
-	<template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template>
-
-	<main>
-		<x-pages v-bind="$attrs"/>
-	</main>
-</mk-ui>
-</template>
-
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import { faHashtag } from '@fortawesome/free-solid-svg-icons';
-import XPages from '../../../common/views/pages/pages.vue';
-
-export default Vue.extend({
-	i18n: i18n(''),
-	components: {
-		XPages
-	},
-	data() {
-		return {
-			faHashtag
-		};
-	},
-});
-</script>
diff --git a/src/client/app/mobile/views/pages/ui.vue b/src/client/app/mobile/views/pages/ui.vue
new file mode 100644
index 0000000000..397ba5df07
--- /dev/null
+++ b/src/client/app/mobile/views/pages/ui.vue
@@ -0,0 +1,38 @@
+<template>
+<mk-ui>
+	<template #header><span style="margin-right:4px;" v-if="icon"><fa :icon="icon"/></span>{{ title }}</template>
+
+	<main>
+		<component :is="component" @init="init" v-bind="$attrs"/>
+	</main>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		component: {
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			title: null,
+			icon: null,
+		};
+	},
+
+	mounted() {
+	},
+
+	methods: {
+		init(v) {
+			this.title = v.title;
+			this.icon = v.icon;
+		}
+	}
+});
+</script>
diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue
deleted file mode 100644
index 68fd0358c4..0000000000
--- a/src/client/app/mobile/views/pages/user-list.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<template>
-<mk-ui>
-	<template #header v-if="!fetching"><fa icon="list"/>{{ list.name }}</template>
-
-	<main v-if="!fetching">
-		<x-editor :list="list"/>
-	</main>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import Progress from '../../../common/scripts/loading';
-import XEditor from '../../../common/views/components/user-list-editor.vue';
-
-export default Vue.extend({
-	components: {
-		XEditor
-	},
-	data() {
-		return {
-			fetching: true,
-			list: null
-		};
-	},
-	watch: {
-		$route: 'fetch'
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			Progress.start();
-			this.fetching = true;
-
-			this.$root.api('users/lists/show', {
-				listId: this.$route.params.list
-			}).then(list => {
-				this.list = list;
-				this.fetching = false;
-
-				Progress.done();
-			});
-		}
-	}
-});
-</script>
diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue
deleted file mode 100644
index a3e9bd78ba..0000000000
--- a/src/client/app/mobile/views/pages/user-lists.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<template>
-<mk-ui>
-	<template #header><fa icon="list"/>{{ $t('title') }}</template>
-	<template #func><button @click="$refs.lists.add()"><fa icon="plus"/></button></template>
-
-	<x-lists ref="lists" @choosen="choosen"/>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-
-export default Vue.extend({
-	i18n: i18n('mobile/views/pages/user-lists.vue'),
-	data() {
-		return {
-			fetching: true,
-			lists: []
-		};
-	},
-	components: {
-		XLists: () => import('../../../common/views/components/user-lists.vue').then(m => m.default)
-	},
-	mounted() {
-		document.title = this.$t('title');
-	},
-	methods: {
-		choosen(list) {
-			if (!list) return;
-			this.$router.push(`/i/lists/${list.id}`);
-		}
-	}
-});
-</script>
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index f488af03ca..40b9ce151b 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -24,6 +24,8 @@ import { SwSubscription } from '../models/entities/sw-subscription';
 import { Blocking } from '../models/entities/blocking';
 import { UserList } from '../models/entities/user-list';
 import { UserListJoining } from '../models/entities/user-list-joining';
+import { UserGroup } from '../models/entities/user-group';
+import { UserGroupJoining } from '../models/entities/user-group-joining';
 import { Hashtag } from '../models/entities/hashtag';
 import { NoteFavorite } from '../models/entities/note-favorite';
 import { AbuseUserReport } from '../models/entities/abuse-user-report';
@@ -106,6 +108,8 @@ export function initDb(justBorrow = false, sync = false, log = false) {
 			UserPublickey,
 			UserList,
 			UserListJoining,
+			UserGroup,
+			UserGroupJoining,
 			UserNotePining,
 			Following,
 			FollowRequest,
diff --git a/src/models/entities/messaging-message.ts b/src/models/entities/messaging-message.ts
index d3c3eab3a2..c18897a37d 100644
--- a/src/models/entities/messaging-message.ts
+++ b/src/models/entities/messaging-message.ts
@@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
 import { User } from './user';
 import { DriveFile } from './drive-file';
 import { id } from '../id';
+import { UserGroup } from './user-group';
 
 @Entity()
 export class MessagingMessage {
@@ -29,10 +30,10 @@ export class MessagingMessage {
 
 	@Index()
 	@Column({
-		...id(),
+		...id(), nullable: true,
 		comment: 'The recipient user ID.'
 	})
-	public recipientId: User['id'];
+	public recipientId: User['id'] | null;
 
 	@ManyToOne(type => User, {
 		onDelete: 'CASCADE'
@@ -40,6 +41,19 @@ export class MessagingMessage {
 	@JoinColumn()
 	public recipient: User | null;
 
+	@Index()
+	@Column({
+		...id(), nullable: true,
+		comment: 'The recipient group ID.'
+	})
+	public groupId: UserGroup['id'] | null;
+
+	@ManyToOne(type => UserGroup, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public group: UserGroup | null;
+
 	@Column('varchar', {
 		length: 4096, nullable: true
 	})
@@ -50,6 +64,12 @@ export class MessagingMessage {
 	})
 	public isRead: boolean;
 
+	@Column({
+		...id(),
+		array: true, default: '{}'
+	})
+	public reads: User['id'][];
+
 	@Column({
 		...id(),
 		nullable: true,
diff --git a/src/models/entities/user-group-joining.ts b/src/models/entities/user-group-joining.ts
new file mode 100644
index 0000000000..17b534f42f
--- /dev/null
+++ b/src/models/entities/user-group-joining.ts
@@ -0,0 +1,41 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { UserGroup } from './user-group';
+import { id } from '../id';
+
+@Entity()
+export class UserGroupJoining {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the UserGroupJoining.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The user ID.'
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The group ID.'
+	})
+	public userGroupId: UserGroup['id'];
+
+	@ManyToOne(type => UserGroup, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public userGroup: UserGroup | null;
+}
diff --git a/src/models/entities/user-group.ts b/src/models/entities/user-group.ts
new file mode 100644
index 0000000000..f4bac03223
--- /dev/null
+++ b/src/models/entities/user-group.ts
@@ -0,0 +1,46 @@
+import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+
+@Entity()
+export class UserGroup {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the UserGroup.'
+	})
+	public createdAt: Date;
+
+	@Column('varchar', {
+		length: 256,
+	})
+	public name: string;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The ID of owner.'
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isPrivate: boolean;
+
+	constructor(data: Partial<UserGroup>) {
+		if (data == null) return;
+
+		for (const [k, v] of Object.entries(data)) {
+			(this as any)[k] = v;
+		}
+	}
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index a63bb2c2b5..c05d7febe5 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -6,7 +6,6 @@ import { PollVote } from './entities/poll-vote';
 import { Meta } from './entities/meta';
 import { SwSubscription } from './entities/sw-subscription';
 import { NoteWatching } from './entities/note-watching';
-import { UserListJoining } from './entities/user-list-joining';
 import { NoteUnread } from './entities/note-unread';
 import { RegistrationTicket } from './entities/registration-tickets';
 import { UserRepository } from './repositories/user';
@@ -20,6 +19,9 @@ import { SigninRepository } from './repositories/signin';
 import { MessagingMessageRepository } from './repositories/messaging-message';
 import { ReversiGameRepository } from './repositories/games/reversi/game';
 import { UserListRepository } from './repositories/user-list';
+import { UserListJoining } from './entities/user-list-joining';
+import { UserGroupRepository } from './repositories/user-group';
+import { UserGroupJoining } from './entities/user-group-joining';
 import { FollowRequestRepository } from './repositories/follow-request';
 import { MutingRepository } from './repositories/muting';
 import { BlockingRepository } from './repositories/blocking';
@@ -52,6 +54,8 @@ export const UserKeypairs = getRepository(UserKeypair);
 export const UserPublickeys = getRepository(UserPublickey);
 export const UserLists = getCustomRepository(UserListRepository);
 export const UserListJoinings = getRepository(UserListJoining);
+export const UserGroups = getCustomRepository(UserGroupRepository);
+export const UserGroupJoinings = getRepository(UserGroupJoining);
 export const UserNotePinings = getRepository(UserNotePining);
 export const Followings = getCustomRepository(FollowingRepository);
 export const FollowRequests = getCustomRepository(FollowRequestRepository);
diff --git a/src/models/repositories/messaging-message.ts b/src/models/repositories/messaging-message.ts
index 33f95bbd5f..a64ed07328 100644
--- a/src/models/repositories/messaging-message.ts
+++ b/src/models/repositories/messaging-message.ts
@@ -1,6 +1,6 @@
 import { EntityRepository, Repository } from 'typeorm';
 import { MessagingMessage } from '../entities/messaging-message';
-import { Users, DriveFiles } from '..';
+import { Users, DriveFiles, UserGroups } from '..';
 import { ensure } from '../../prelude/ensure';
 import { types, bool, SchemaType } from '../../misc/schema';
 
@@ -16,11 +16,13 @@ export class MessagingMessageRepository extends Repository<MessagingMessage> {
 		src: MessagingMessage['id'] | MessagingMessage,
 		me?: any,
 		options?: {
-			populateRecipient: boolean
+			populateRecipient?: boolean,
+			populateGroup?: boolean,
 		}
 	): Promise<PackedMessagingMessage> {
 		const opts = options || {
-			populateRecipient: true
+			populateRecipient: true,
+			populateGroup: true,
 		};
 
 		const message = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
@@ -32,10 +34,13 @@ export class MessagingMessageRepository extends Repository<MessagingMessage> {
 			userId: message.userId,
 			user: await Users.pack(message.user || message.userId, me),
 			recipientId: message.recipientId,
-			recipient: opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined,
+			recipient: message.recipientId && opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined,
+			groupId: message.recipientId,
+			group: message.groupId && opts.populateGroup ? await UserGroups.pack(message.group || message.groupId) : undefined,
 			fileId: message.fileId,
 			file: message.fileId ? await DriveFiles.pack(message.fileId) : null,
-			isRead: message.isRead
+			isRead: message.isRead,
+			reads: message.reads,
 		};
 	}
 }
@@ -83,17 +88,36 @@ export const packedMessagingMessageSchema = {
 		},
 		recipientId: {
 			type: types.string,
-			optional: bool.false, nullable: bool.false,
+			optional: bool.false, nullable: bool.true,
 			format: 'id',
 		},
 		recipient: {
 			type: types.object,
-			optional: bool.true, nullable: bool.false,
+			optional: bool.true, nullable: bool.true,
 			ref: 'User'
 		},
+		groupId: {
+			type: types.string,
+			optional: bool.false, nullable: bool.true,
+			format: 'id',
+		},
+		group: {
+			type: types.object,
+			optional: bool.true, nullable: bool.true,
+			ref: 'UserGroup'
+		},
 		isRead: {
 			type: types.boolean,
 			optional: bool.true, nullable: bool.false,
 		},
+		reads: {
+			type: types.array,
+			optional: bool.true, nullable: bool.false,
+			items: {
+				type: types.string,
+				optional: bool.false, nullable: bool.false,
+				format: 'id'
+			}
+		},
 	},
 };
diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts
new file mode 100644
index 0000000000..8bb1ae8330
--- /dev/null
+++ b/src/models/repositories/user-group.ts
@@ -0,0 +1,61 @@
+import { EntityRepository, Repository } from 'typeorm';
+import { UserGroup } from '../entities/user-group';
+import { ensure } from '../../prelude/ensure';
+import { UserGroupJoinings } from '..';
+import { bool, types, SchemaType } from '../../misc/schema';
+
+export type PackedUserGroup = SchemaType<typeof packedUserGroupSchema>;
+
+@EntityRepository(UserGroup)
+export class UserGroupRepository extends Repository<UserGroup> {
+	public async pack(
+		src: UserGroup['id'] | UserGroup,
+	): Promise<PackedUserGroup> {
+		const userGroup = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
+
+		const users = await UserGroupJoinings.find({
+			userGroupId: userGroup.id
+		});
+
+		return {
+			id: userGroup.id,
+			createdAt: userGroup.createdAt.toISOString(),
+			name: userGroup.name,
+			userIds: users.map(x => x.userId)
+		};
+	}
+}
+
+export const packedUserGroupSchema = {
+	type: types.object,
+	optional: bool.false, nullable: bool.false,
+	properties: {
+		id: {
+			type: types.string,
+			optional: bool.false, nullable: bool.false,
+			format: 'id',
+			description: 'The unique identifier for this UserGroup.',
+			example: 'xxxxxxxxxx',
+		},
+		createdAt: {
+			type: types.string,
+			optional: bool.false, nullable: bool.false,
+			format: 'date-time',
+			description: 'The date that the UserGroup was created.'
+		},
+		name: {
+			type: types.string,
+			optional: bool.false, nullable: bool.false,
+			description: 'The name of the UserGroup.'
+		},
+		userIds: {
+			type: types.array,
+			nullable: bool.false, optional: bool.true,
+			items: {
+				type: types.string,
+				nullable: bool.false, optional: bool.false,
+				format: 'id',
+			}
+		},
+	},
+};
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 330220fb72..f81fa6bc77 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -1,6 +1,6 @@
 import { EntityRepository, Repository, In } from 'typeorm';
 import { User, ILocalUser, IRemoteUser } from '../entities/user';
-import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles } from '..';
+import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..';
 import { ensure } from '../../prelude/ensure';
 import config from '../../config';
 import { SchemaType, bool, types } from '../../misc/schema';
@@ -54,6 +54,31 @@ export class UserRepository extends Repository<User> {
 		};
 	}
 
+	public async getHasUnreadMessagingMessage(userId: User['id']): Promise<boolean> {
+		const joinings = await UserGroupJoinings.find({ userId: userId });
+
+		const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message')
+			.where(`message.groupId = :groupId`, { groupId: j.userGroupId })
+			.andWhere('message.userId != :userId', { userId: userId })
+			.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
+			.andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない
+			.getOne().then(x => x != null)));
+
+		const [withUser, withGroups] = await Promise.all([
+			// TODO: ミュートを考慮
+			MessagingMessages.count({
+				where: {
+					recipientId: userId,
+					isRead: false
+				},
+				take: 1
+			}).then(count => count > 0),
+			groupQs
+		]);
+
+		return withUser || withGroups.some(x => x);
+	}
+
 	public async pack(
 		src: User['id'] | User,
 		me?: User['id'] | User | null | undefined,
@@ -151,13 +176,7 @@ export class UserRepository extends Repository<User> {
 				autoWatch: profile!.autoWatch,
 				alwaysMarkNsfw: profile!.alwaysMarkNsfw,
 				carefulBot: profile!.carefulBot,
-				hasUnreadMessagingMessage: MessagingMessages.count({
-					where: {
-						recipientId: user.id,
-						isRead: false
-					},
-					take: 1
-				}).then(count => count > 0),
+				hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
 				hasUnreadNotification: Notifications.count({
 					where: {
 						notifieeId: user.id,
diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts
index 2cb5a1f87f..544d890197 100644
--- a/src/server/api/common/read-messaging-message.ts
+++ b/src/server/api/common/read-messaging-message.ts
@@ -1,21 +1,33 @@
-import { publishMainStream } from '../../../services/stream';
+import { publishMainStream, publishGroupMessagingStream } from '../../../services/stream';
 import { publishMessagingStream } from '../../../services/stream';
 import { publishMessagingIndexStream } from '../../../services/stream';
 import { User } from '../../../models/entities/user';
 import { MessagingMessage } from '../../../models/entities/messaging-message';
-import { MessagingMessages } from '../../../models';
+import { MessagingMessages, UserGroupJoinings, Users } from '../../../models';
 import { In } from 'typeorm';
+import { IdentifiableError } from '../../../misc/identifiable-error';
+import { UserGroup } from '../../../models/entities/user-group';
 
 /**
  * Mark messages as read
  */
-export default async (
+export async function readUserMessagingMessage(
 	userId: User['id'],
 	otherpartyId: User['id'],
 	messageIds: MessagingMessage['id'][]
-) => {
+) {
 	if (messageIds.length === 0) return;
 
+	const messages = await MessagingMessages.find({
+		id: In(messageIds)
+	});
+
+	for (const message of messages) {
+		if (message.recipientId !== userId) {
+			throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).');
+		}
+	}
+
 	// Update documents
 	await MessagingMessages.update({
 		id: In(messageIds),
@@ -30,14 +42,62 @@ export default async (
 	publishMessagingStream(otherpartyId, userId, 'read', messageIds);
 	publishMessagingIndexStream(userId, 'read', messageIds);
 
-	// Calc count of my unread messages
-	const count = await MessagingMessages.count({
-		recipientId: userId,
-		isRead: false
-	});
-
-	if (count == 0) {
+	if (!Users.getHasUnreadMessagingMessage(userId)) {
 		// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
 		publishMainStream(userId, 'readAllMessagingMessages');
 	}
-};
+}
+
+/**
+ * Mark messages as read
+ */
+export async function readGroupMessagingMessage(
+	userId: User['id'],
+	groupId: UserGroup['id'],
+	messageIds: MessagingMessage['id'][]
+) {
+	if (messageIds.length === 0) return;
+
+	// check joined
+	const joining = await UserGroupJoinings.findOne({
+		userId: userId,
+		userGroupId: groupId
+	});
+
+	if (joining == null) {
+		throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).');
+	}
+
+	const messages = await MessagingMessages.find({
+		id: In(messageIds)
+	});
+
+	const reads = [];
+
+	for (const message of messages) {
+		if (message.userId === userId) continue;
+		if (message.reads.includes(userId)) continue;
+
+		// Update document
+		await MessagingMessages.createQueryBuilder().update()
+			.set({
+				reads: (() => `array_append("reads", '${joining.userId}')`) as any
+			})
+			.where('id = :id', { id: message.id })
+			.execute();
+
+		reads.push(message.id);
+	}
+
+	// Publish event
+	publishGroupMessagingStream(groupId, 'read', {
+		ids: reads,
+		userId: userId
+	});
+	publishMessagingIndexStream(userId, 'read', reads);
+
+	if (!Users.getHasUnreadMessagingMessage(userId)) {
+		// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
+		publishMainStream(userId, 'readAllMessagingMessages');
+	}
+}
diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts
index 27e38bbdec..833ec37e4c 100644
--- a/src/server/api/endpoints/messaging/history.ts
+++ b/src/server/api/endpoints/messaging/history.ts
@@ -1,13 +1,13 @@
 import $ from 'cafy';
 import define from '../../define';
 import { MessagingMessage } from '../../../../models/entities/messaging-message';
-import { MessagingMessages, Mutings } from '../../../../models';
+import { MessagingMessages, Mutings, UserGroupJoinings } from '../../../../models';
 import { Brackets } from 'typeorm';
 import { types, bool } from '../../../../misc/schema';
 
 export const meta = {
 	desc: {
-		'ja-JP': 'Messagingの履歴を取得します。',
+		'ja-JP': 'トークの履歴を取得します。',
 		'en-US': 'Show messaging history.'
 	},
 
@@ -21,6 +21,11 @@ export const meta = {
 		limit: {
 			validator: $.optional.num.range(1, 100),
 			default: 10
+		},
+
+		group: {
+			validator: $.optional.bool,
+			default: false
 		}
 	},
 
@@ -40,26 +45,46 @@ export default define(meta, async (ps, user) => {
 		muterId: user.id,
 	});
 
+	const groups = ps.group ? await UserGroupJoinings.find({
+		userId: user.id,
+	}).then(xs => xs.map(x => x.userGroupId)) : [];
+
+	if (ps.group && groups.length === 0) {
+		return [];
+	}
+
 	const history: MessagingMessage[] = [];
 
 	for (let i = 0; i < ps.limit!; i++) {
-		const found = history.map(m => (m.userId === user.id) ? m.recipientId : m.userId);
+		const found = ps.group
+			? history.map(m => m.groupId!)
+			: history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!);
 
 		const query = MessagingMessages.createQueryBuilder('message')
-			.where(new Brackets(qb => { qb
-				.where(`message.userId = :userId`, { userId: user.id })
-				.orWhere(`message.recipientId = :userId`, { userId: user.id });
-			}))
 			.orderBy('message.createdAt', 'DESC');
 
-		if (found.length > 0) {
-			query.andWhere(`message.userId NOT IN (:...found)`, { found: found });
-			query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found });
-		}
+		if (ps.group) {
+			query.where(`message.groupId IN (:...groups)`, { groups: groups });
 
-		if (mute.length > 0) {
-			query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) });
-			query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) });
+			if (found.length > 0) {
+				query.andWhere(`message.groupId NOT IN (:...found)`, { found: found });
+			}
+		} else {
+			query.where(new Brackets(qb => { qb
+				.where(`message.userId = :userId`, { userId: user.id })
+				.orWhere(`message.recipientId = :userId`, { userId: user.id });
+			}));
+			query.andWhere(`message.groupId IS NULL`);
+
+			if (found.length > 0) {
+				query.andWhere(`message.userId NOT IN (:...found)`, { found: found });
+				query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found });
+			}
+
+			if (mute.length > 0) {
+				query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) });
+				query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) });
+			}
 		}
 
 		const message = await query.getOne();
diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts
index 0d5295bff3..c1e79cd130 100644
--- a/src/server/api/endpoints/messaging/messages.ts
+++ b/src/server/api/endpoints/messaging/messages.ts
@@ -1,16 +1,17 @@
 import $ from 'cafy';
 import { ID } from '../../../../misc/cafy-id';
-import read from '../../common/read-messaging-message';
 import define from '../../define';
 import { ApiError } from '../../error';
 import { getUser } from '../../common/getters';
-import { MessagingMessages } from '../../../../models';
+import { MessagingMessages, UserGroups, UserGroupJoinings } from '../../../../models';
 import { makePaginationQuery } from '../../common/make-pagination-query';
 import { types, bool } from '../../../../misc/schema';
+import { Brackets } from 'typeorm';
+import { readUserMessagingMessage, readGroupMessagingMessage } from '../../common/read-messaging-message';
 
 export const meta = {
 	desc: {
-		'ja-JP': '指定したユーザーとのMessagingのメッセージ一覧を取得します。',
+		'ja-JP': 'トークメッセージ一覧を取得します。',
 		'en-US': 'Get messages of messaging.'
 	},
 
@@ -22,13 +23,21 @@ export const meta = {
 
 	params: {
 		userId: {
-			validator: $.type(ID),
+			validator: $.optional.type(ID),
 			desc: {
 				'ja-JP': '対象のユーザーのID',
 				'en-US': 'Target user ID'
 			}
 		},
 
+		groupId: {
+			validator: $.optional.type(ID),
+			desc: {
+				'ja-JP': '対象のグループのID',
+				'en-US': 'Target group ID'
+			}
+		},
+
 		limit: {
 			validator: $.optional.num.range(1, 100),
 			default: 10
@@ -64,27 +73,85 @@ export const meta = {
 			code: 'NO_SUCH_USER',
 			id: '11795c64-40ea-4198-b06e-3c873ed9039d'
 		},
+
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f'
+		},
+
+		groupAccessDenied: {
+			message: 'You can not read messages of groups that you have not joined.',
+			code: 'GROUP_ACCESS_DENIED',
+			id: 'a053a8dd-a491-4718-8f87-50775aad9284'
+		},
 	}
 };
 
 export default define(meta, async (ps, user) => {
-	// Fetch recipient
-	const recipient = await getUser(ps.userId).catch(e => {
-		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
-		throw e;
-	});
+	if (ps.userId != null) {
+		// Fetch recipient (user)
+		const recipient = await getUser(ps.userId).catch(e => {
+			if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+			throw e;
+		});
 
-	const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId)
-		.andWhere(`(message.userId = :meId AND message.recipientId = :recipientId) OR (message.userId = :recipientId AND message.recipientId = :meId)`, { meId: user.id, recipientId: recipient.id });
+		const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId)
+			.andWhere(new Brackets(qb => { qb
+				.where(new Brackets(qb => { qb
+					.where('message.userId = :meId')
+					.andWhere('message.recipientId = :recipientId');
+				}))
+				.orWhere(new Brackets(qb => { qb
+					.where('message.userId = :recipientId')
+					.andWhere('message.recipientId = :meId');
+				}));
+			}))
+			.setParameter('meId', user.id)
+			.setParameter('recipientId', recipient.id);
 
-	const messages = await query.getMany();
+		const messages = await query.take(ps.limit!).getMany();
 
-	// Mark all as read
-	if (ps.markAsRead) {
-		read(user.id, recipient.id, messages.map(x => x.id));
+		// Mark all as read
+		if (ps.markAsRead) {
+			readUserMessagingMessage(user.id, recipient.id, messages.map(x => x.id));
+		}
+
+		return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, {
+			populateRecipient: false
+		})));
+	} else if (ps.groupId != null) {
+		// Fetch recipient (group)
+		const recipientGroup = await UserGroups.findOne(ps.groupId);
+
+		if (recipientGroup == null) {
+			throw new ApiError(meta.errors.noSuchGroup);
+		}
+
+		// check joined
+		const joining = await UserGroupJoinings.findOne({
+			userId: user.id,
+			userGroupId: recipientGroup.id
+		});
+
+		if (joining == null) {
+			throw new ApiError(meta.errors.groupAccessDenied);
+		}
+
+		const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId)
+			.andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id });
+
+		const messages = await query.take(ps.limit!).getMany();
+
+		// Mark all as read
+		if (ps.markAsRead) {
+			readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id));
+		}
+
+		return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, {
+			populateGroup: false
+		})));
+	} else {
+		throw new Error();
 	}
-
-	return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, {
-		populateRecipient: false
-	})));
 });
diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts
index 388852b9cd..f5d7cf2b38 100644
--- a/src/server/api/endpoints/messaging/messages/create.ts
+++ b/src/server/api/endpoints/messaging/messages/create.ts
@@ -1,19 +1,22 @@
 import $ from 'cafy';
 import { ID } from '../../../../../misc/cafy-id';
-import { publishMainStream } from '../../../../../services/stream';
+import { publishMainStream, publishGroupMessagingStream } from '../../../../../services/stream';
 import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../services/stream';
 import pushSw from '../../../../../services/push-notification';
 import define from '../../../define';
 import { ApiError } from '../../../error';
 import { getUser } from '../../../common/getters';
-import { MessagingMessages, DriveFiles, Mutings } from '../../../../../models';
+import { MessagingMessages, DriveFiles, Mutings, UserGroups, UserGroupJoinings } from '../../../../../models';
 import { MessagingMessage } from '../../../../../models/entities/messaging-message';
 import { genId } from '../../../../../misc/gen-id';
 import { types, bool } from '../../../../../misc/schema';
+import { User } from '../../../../../models/entities/user';
+import { UserGroup } from '../../../../../models/entities/user-group';
+import { Not } from 'typeorm';
 
 export const meta = {
 	desc: {
-		'ja-JP': '指定したユーザーへMessagingのメッセージを送信します。',
+		'ja-JP': 'トークメッセージを送信します。',
 		'en-US': 'Create a message of messaging.'
 	},
 
@@ -25,13 +28,21 @@ export const meta = {
 
 	params: {
 		userId: {
-			validator: $.type(ID),
+			validator: $.optional.type(ID),
 			desc: {
 				'ja-JP': '対象のユーザーのID',
 				'en-US': 'Target user ID'
 			}
 		},
 
+		groupId: {
+			validator: $.optional.type(ID),
+			desc: {
+				'ja-JP': '対象のグループのID',
+				'en-US': 'Target group ID'
+			}
+		},
+
 		text: {
 			validator: $.optional.str.pipe(MessagingMessages.isValidText)
 		},
@@ -60,6 +71,18 @@ export const meta = {
 			id: '11795c64-40ea-4198-b06e-3c873ed9039d'
 		},
 
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537'
+		},
+
+		groupAccessDenied: {
+			message: 'You can not send messages to groups that you have not joined.',
+			code: 'GROUP_ACCESS_DENIED',
+			id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd'
+		},
+
 		noSuchFile: {
 			message: 'No such file.',
 			code: 'NO_SUCH_FILE',
@@ -75,16 +98,38 @@ export const meta = {
 };
 
 export default define(meta, async (ps, user) => {
-	// Myself
-	if (ps.userId === user.id) {
-		throw new ApiError(meta.errors.recipientIsYourself);
-	}
+	let recipientUser: User | undefined;
+	let recipientGroup: UserGroup | undefined;
 
-	// Fetch recipient
-	const recipient = await getUser(ps.userId).catch(e => {
-		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
-		throw e;
-	});
+	if (ps.userId != null) {
+		// Myself
+		if (ps.userId === user.id) {
+			throw new ApiError(meta.errors.recipientIsYourself);
+		}
+
+		// Fetch recipient (user)
+		recipientUser = await getUser(ps.userId).catch(e => {
+			if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+			throw e;
+		});
+	} else if (ps.groupId != null) {
+		// Fetch recipient (group)
+		recipientGroup = await UserGroups.findOne(ps.groupId);
+
+		if (recipientGroup == null) {
+			throw new ApiError(meta.errors.noSuchGroup);
+		}
+
+		// check joined
+		const joining = await UserGroupJoinings.findOne({
+			userId: user.id,
+			userGroupId: recipientGroup.id
+		});
+
+		if (joining == null) {
+			throw new ApiError(meta.errors.groupAccessDenied);
+		}
+	}
 
 	let file = null;
 	if (ps.fileId != null) {
@@ -107,32 +152,49 @@ export default define(meta, async (ps, user) => {
 		id: genId(),
 		createdAt: new Date(),
 		fileId: file ? file.id : null,
-		recipientId: recipient.id,
+		recipientId: recipientUser ? recipientUser.id : null,
+		groupId: recipientGroup ? recipientGroup.id : null,
 		text: ps.text ? ps.text.trim() : null,
 		userId: user.id,
-		isRead: false
+		isRead: false,
+		reads: [] as any[]
 	} as MessagingMessage);
 
 	const messageObj = await MessagingMessages.pack(message);
 
-	// 自分のストリーム
-	publishMessagingStream(message.userId, message.recipientId, 'message', messageObj);
-	publishMessagingIndexStream(message.userId, 'message', messageObj);
-	publishMainStream(message.userId, 'messagingMessage', messageObj);
+	if (recipientUser) {
+		// 自分のストリーム
+		publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj);
+		publishMessagingIndexStream(message.userId, 'message', messageObj);
+		publishMainStream(message.userId, 'messagingMessage', messageObj);
 
-	// 相手のストリーム
-	publishMessagingStream(message.recipientId, message.userId, 'message', messageObj);
-	publishMessagingIndexStream(message.recipientId, 'message', messageObj);
-	publishMainStream(message.recipientId, 'messagingMessage', messageObj);
+		// 相手のストリーム
+		publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj);
+		publishMessagingIndexStream(recipientUser.id, 'message', messageObj);
+		publishMainStream(recipientUser.id, 'messagingMessage', messageObj);
+	} else if (recipientGroup) {
+		// グループのストリーム
+		publishGroupMessagingStream(recipientGroup.id, 'message', messageObj);
+
+		// メンバーのストリーム
+		const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id });
+		for (const joining of joinings) {
+			publishMessagingIndexStream(joining.userId, 'message', messageObj);
+			publishMainStream(joining.userId, 'messagingMessage', messageObj);
+		}
+	}
 
 	// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
 	setTimeout(async () => {
-		const freshMessage = await MessagingMessages.findOne({ id: message.id });
+		const freshMessage = await MessagingMessages.findOne(message.id);
 		if (freshMessage == null) return; // メッセージが削除されている場合もある
-		if (!freshMessage.isRead) {
+
+		if (recipientUser) {
+			if (freshMessage.isRead) return; // 既読
+
 			//#region ただしミュートされているなら発行しない
 			const mute = await Mutings.find({
-				muterId: recipient.id,
+				muterId: recipientUser.id,
 			});
 			const mutedUserIds = mute.map(m => m.muteeId.toString());
 			if (mutedUserIds.indexOf(user.id) != -1) {
@@ -140,8 +202,15 @@ export default define(meta, async (ps, user) => {
 			}
 			//#endregion
 
-			publishMainStream(message.recipientId, 'unreadMessagingMessage', messageObj);
-			pushSw(message.recipientId, 'unreadMessagingMessage', messageObj);
+			publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj);
+			pushSw(recipientUser.id, 'unreadMessagingMessage', messageObj);
+		} else if (recipientGroup) {
+			const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id, userId: Not(user.id) });
+			for (const joining of joinings) {
+				if (freshMessage.reads.includes(joining.userId)) return; // 既読
+				publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj);
+				pushSw(joining.userId, 'unreadMessagingMessage', messageObj);
+			}
 		}
 	}, 2000);
 
diff --git a/src/server/api/endpoints/messaging/messages/delete.ts b/src/server/api/endpoints/messaging/messages/delete.ts
index 6a896cd8d1..fb1bb42a56 100644
--- a/src/server/api/endpoints/messaging/messages/delete.ts
+++ b/src/server/api/endpoints/messaging/messages/delete.ts
@@ -1,7 +1,7 @@
 import $ from 'cafy';
 import { ID } from '../../../../../misc/cafy-id';
 import define from '../../../define';
-import { publishMessagingStream } from '../../../../../services/stream';
+import { publishMessagingStream, publishGroupMessagingStream } from '../../../../../services/stream';
 import * as ms from 'ms';
 import { ApiError } from '../../../error';
 import { MessagingMessages } from '../../../../../models';
@@ -10,7 +10,7 @@ export const meta = {
 	stability: 'stable',
 
 	desc: {
-		'ja-JP': '指定したメッセージを削除します。',
+		'ja-JP': '指定したトークメッセージを削除します。',
 		'en-US': 'Delete a message.'
 	},
 
@@ -57,6 +57,10 @@ export default define(meta, async (ps, user) => {
 
 	await MessagingMessages.delete(message.id);
 
-	publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
-	publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
+	if (message.recipientId) {
+		publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
+		publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
+	} else if (message.groupId) {
+		publishGroupMessagingStream(message.groupId, 'deleted', message.id);
+	}
 });
diff --git a/src/server/api/endpoints/messaging/messages/read.ts b/src/server/api/endpoints/messaging/messages/read.ts
index 50b7f39870..dd3449af15 100644
--- a/src/server/api/endpoints/messaging/messages/read.ts
+++ b/src/server/api/endpoints/messaging/messages/read.ts
@@ -1,13 +1,13 @@
 import $ from 'cafy';
 import { ID } from '../../../../../misc/cafy-id';
-import read from '../../../common/read-messaging-message';
 import define from '../../../define';
 import { ApiError } from '../../../error';
 import { MessagingMessages } from '../../../../../models';
+import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message';
 
 export const meta = {
 	desc: {
-		'ja-JP': '指定した自分宛てのメッセージを既読にします。',
+		'ja-JP': '指定した自分宛てのトークメッセージを既読にします。',
 		'en-US': 'Mark as read a message of messaging.'
 	},
 
@@ -39,12 +39,21 @@ export const meta = {
 export default define(meta, async (ps, user) => {
 	const message = await MessagingMessages.findOne({
 		id: ps.messageId,
-		recipientId: user.id
 	});
 
 	if (message == null) {
 		throw new ApiError(meta.errors.noSuchMessage);
 	}
 
-	read(user.id, message.userId, [message.id]);
+	if (message.recipientId) {
+		await readUserMessagingMessage(user.id, message.recipientId, [message.id]).catch(e => {
+			if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage);
+			throw e;
+		});
+	} else if (message.groupId) {
+		await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => {
+			if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage);
+			throw e;
+		});
+	}
 });
diff --git a/src/server/api/endpoints/users/groups/create.ts b/src/server/api/endpoints/users/groups/create.ts
new file mode 100644
index 0000000000..ee6cade8d0
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/create.ts
@@ -0,0 +1,51 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { UserGroups, UserGroupJoinings } from '../../../../../models';
+import { genId } from '../../../../../misc/gen-id';
+import { UserGroup } from '../../../../../models/entities/user-group';
+import { types, bool } from '../../../../../misc/schema';
+import { UserGroupJoining } from '../../../../../models/entities/user-group-joining';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'ユーザーグループを作成します。',
+		'en-US': 'Create a user group.'
+	},
+
+	tags: ['groups'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		name: {
+			validator: $.str.range(1, 100)
+		}
+	},
+
+	res: {
+		type: types.object,
+		optional: bool.false, nullable: bool.false,
+		ref: 'UserGroup',
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	const userGroup = await UserGroups.save({
+		id: genId(),
+		createdAt: new Date(),
+		userId: user.id,
+		name: ps.name,
+	} as UserGroup);
+
+	// Push the owner
+	await UserGroupJoinings.save({
+		id: genId(),
+		createdAt: new Date(),
+		userId: user.id,
+		userGroupId: userGroup.id
+	} as UserGroupJoining);
+
+	return await UserGroups.pack(userGroup);
+});
diff --git a/src/server/api/endpoints/users/groups/delete.ts b/src/server/api/endpoints/users/groups/delete.ts
new file mode 100644
index 0000000000..4f89c324a1
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/delete.ts
@@ -0,0 +1,49 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups } from '../../../../../models';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループを削除します。',
+		'en-US': 'Delete a user group'
+	},
+
+	tags: ['groups'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '対象となるユーザーグループのID',
+				'en-US': 'ID of target user group'
+			}
+		}
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: '63dbd64c-cd77-413f-8e08-61781e210b38'
+		}
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: user.id
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	await UserGroups.delete(userGroup.id);
+});
diff --git a/src/server/api/endpoints/users/groups/joined.ts b/src/server/api/endpoints/users/groups/joined.ts
new file mode 100644
index 0000000000..14561fce05
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/joined.ts
@@ -0,0 +1,33 @@
+import define from '../../../define';
+import { UserGroups, UserGroupJoinings } from '../../../../../models';
+import { types, bool } from '../../../../../misc/schema';
+
+export const meta = {
+	desc: {
+		'ja-JP': '自分の所属するユーザーグループ一覧を取得します。'
+	},
+
+	tags: ['groups', 'account'],
+
+	requireCredential: true,
+
+	kind: 'read:user-groups',
+
+	res: {
+		type: types.array,
+		optional: bool.false, nullable: bool.false,
+		items: {
+			type: types.object,
+			optional: bool.false, nullable: bool.false,
+			ref: 'UserGroup',
+		}
+	},
+};
+
+export default define(meta, async (ps, me) => {
+	const joinings = await UserGroupJoinings.find({
+		userId: me.id,
+	});
+
+	return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId)));
+});
diff --git a/src/server/api/endpoints/users/groups/owned.ts b/src/server/api/endpoints/users/groups/owned.ts
new file mode 100644
index 0000000000..6cf39a142b
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/owned.ts
@@ -0,0 +1,33 @@
+import define from '../../../define';
+import { UserGroups } from '../../../../../models';
+import { types, bool } from '../../../../../misc/schema';
+
+export const meta = {
+	desc: {
+		'ja-JP': '自分の作成したユーザーグループ一覧を取得します。'
+	},
+
+	tags: ['groups', 'account'],
+
+	requireCredential: true,
+
+	kind: 'read:user-groups',
+
+	res: {
+		type: types.array,
+		optional: bool.false, nullable: bool.false,
+		items: {
+			type: types.object,
+			optional: bool.false, nullable: bool.false,
+			ref: 'UserGroup',
+		}
+	},
+};
+
+export default define(meta, async (ps, me) => {
+	const userGroups = await UserGroups.find({
+		userId: me.id,
+	});
+
+	return await Promise.all(userGroups.map(x => UserGroups.pack(x)));
+});
diff --git a/src/server/api/endpoints/users/groups/pull.ts b/src/server/api/endpoints/users/groups/pull.ts
new file mode 100644
index 0000000000..5fc0c2fa5e
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/pull.ts
@@ -0,0 +1,68 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings } from '../../../../../models';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループから指定したユーザーを削除します。',
+		'en-US': 'Remove a user to a user group.'
+	},
+
+	tags: ['groups', 'users'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+		},
+
+		userId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '対象のユーザーのID',
+				'en-US': 'Target user ID'
+			}
+		},
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: '4662487c-05b1-4b78-86e5-fd46998aba74'
+		},
+
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '0b5cc374-3681-41da-861e-8bc1146f7a55'
+		}
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	// Fetch the group
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: me.id,
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	// Fetch the user
+	const user = await getUser(ps.userId).catch(e => {
+		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+		throw e;
+	});
+
+	// Pull the user
+	await UserGroupJoinings.delete({ userId: user.id });
+});
diff --git a/src/server/api/endpoints/users/groups/push.ts b/src/server/api/endpoints/users/groups/push.ts
new file mode 100644
index 0000000000..5371580db0
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/push.ts
@@ -0,0 +1,90 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings } from '../../../../../models';
+import { genId } from '../../../../../misc/gen-id';
+import { UserGroupJoining } from '../../../../../models/entities/user-group-joining';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループに指定したユーザーを追加します。',
+		'en-US': 'Add a user to a user group.'
+	},
+
+	tags: ['groups', 'users'],
+
+	requireCredential: true,
+
+	kind: 'write:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+		},
+
+		userId: {
+			validator: $.type(ID),
+			desc: {
+				'ja-JP': '対象のユーザーのID',
+				'en-US': 'Target user ID'
+			}
+		},
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: '583f8bc0-8eee-4b78-9299-1e14fc91e409'
+		},
+
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: 'da52de61-002c-475b-90e1-ba64f9cf13a8'
+		},
+
+		alreadyAdded: {
+			message: 'That user has already been added to that group.',
+			code: 'ALREADY_ADDED',
+			id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c'
+		}
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	// Fetch the group
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: me.id,
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	// Fetch the user
+	const user = await getUser(ps.userId).catch(e => {
+		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+		throw e;
+	});
+
+	const exist = await UserGroupJoinings.findOne({
+		userGroupId: userGroup.id,
+		userId: user.id
+	});
+
+	if (exist) {
+		throw new ApiError(meta.errors.alreadyAdded);
+	}
+
+	// Push the user
+	await UserGroupJoinings.save({
+		id: genId(),
+		createdAt: new Date(),
+		userId: user.id,
+		userGroupId: userGroup.id
+	} as UserGroupJoining);
+});
diff --git a/src/server/api/endpoints/users/groups/show.ts b/src/server/api/endpoints/users/groups/show.ts
new file mode 100644
index 0000000000..5f2c839881
--- /dev/null
+++ b/src/server/api/endpoints/users/groups/show.ts
@@ -0,0 +1,53 @@
+import $ from 'cafy';
+import { ID } from '../../../../../misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups } from '../../../../../models';
+import { types, bool } from '../../../../../misc/schema';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーグループの情報を取得します。',
+		'en-US': 'Show a user group.'
+	},
+
+	tags: ['groups', 'account'],
+
+	requireCredential: true,
+
+	kind: 'read:user-groups',
+
+	params: {
+		groupId: {
+			validator: $.type(ID),
+		},
+	},
+
+	res: {
+		type: types.object,
+		optional: bool.false, nullable: bool.false,
+		ref: 'UserGroup',
+	},
+
+	errors: {
+		noSuchGroup: {
+			message: 'No such group.',
+			code: 'NO_SUCH_GROUP',
+			id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	// Fetch the group
+	const userGroup = await UserGroups.findOne({
+		id: ps.groupId,
+		userId: me.id,
+	});
+
+	if (userGroup == null) {
+		throw new ApiError(meta.errors.noSuchGroup);
+	}
+
+	return await UserGroups.pack(userGroup);
+});
diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts
index 2763b3a19c..bdc8403083 100644
--- a/src/server/api/endpoints/users/lists/push.ts
+++ b/src/server/api/endpoints/users/lists/push.ts
@@ -80,5 +80,5 @@ export default define(meta, async (ps, me) => {
 	}
 
 	// Push the user
-	pushUserToUserList(user, userList);
+	await pushUserToUserList(user, userList);
 });
diff --git a/src/server/api/kinds.ts b/src/server/api/kinds.ts
index 76d5a8a61a..be3c30f7d9 100644
--- a/src/server/api/kinds.ts
+++ b/src/server/api/kinds.ts
@@ -23,4 +23,6 @@ export const kinds = [
 	'write:pages',
 	'write:page-likes',
 	'read:page-likes',
+	'read:user-groups',
+	'write:user-groups',
 ];
diff --git a/src/server/api/openapi/schemas.ts b/src/server/api/openapi/schemas.ts
index 628bba511f..32f69bdef3 100644
--- a/src/server/api/openapi/schemas.ts
+++ b/src/server/api/openapi/schemas.ts
@@ -13,6 +13,7 @@ import { packedBlockingSchema } from '../../../models/repositories/blocking';
 import { packedNoteReactionSchema } from '../../../models/repositories/note-reaction';
 import { packedHashtagSchema } from '../../../models/repositories/hashtag';
 import { packedPageSchema } from '../../../models/repositories/page';
+import { packedUserGroupSchema } from '../../../models/repositories/user-group';
 
 export function convertSchemaToOpenApiSchema(schema: Schema) {
 	const res: any = schema;
@@ -66,6 +67,7 @@ export const schemas = {
 
 	User: convertSchemaToOpenApiSchema(packedUserSchema),
 	UserList: convertSchemaToOpenApiSchema(packedUserListSchema),
+	UserGroup: convertSchemaToOpenApiSchema(packedUserGroupSchema),
 	App: convertSchemaToOpenApiSchema(packedAppSchema),
 	MessagingMessage: convertSchemaToOpenApiSchema(packedMessagingMessageSchema),
 	Note: convertSchemaToOpenApiSchema(packedNoteSchema),
diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts
index ce766e28e9..1e5e94c1c8 100644
--- a/src/server/api/stream/channels/messaging.ts
+++ b/src/server/api/stream/channels/messaging.ts
@@ -1,20 +1,39 @@
 import autobind from 'autobind-decorator';
-import read from '../../common/read-messaging-message';
+import { readUserMessagingMessage, readGroupMessagingMessage } from '../../common/read-messaging-message';
 import Channel from '../channel';
+import { UserGroupJoinings } from '../../../../models';
 
 export default class extends Channel {
 	public readonly chName = 'messaging';
 	public static shouldShare = false;
 	public static requireCredential = true;
 
-	private otherpartyId: string;
+	private otherpartyId: string | null;
+	private groupId: string | null;
 
 	@autobind
 	public async init(params: any) {
 		this.otherpartyId = params.otherparty as string;
+		this.groupId = params.group as string;
+
+		// Check joining
+		if (this.groupId) {
+			const joining = await UserGroupJoinings.findOne({
+				userId: this.user!.id,
+				userGroupId: this.groupId
+			});
+
+			if (joining == null) {
+				return;
+			}
+		}
+
+		const subCh = this.otherpartyId
+			? `messagingStream:${this.user!.id}-${this.otherpartyId}`
+			: `messagingStream:${this.groupId}`;
 
 		// Subscribe messaging stream
-		this.subscriber.on(`messagingStream:${this.user!.id}-${this.otherpartyId}`, data => {
+		this.subscriber.on(subCh, data => {
 			this.send(data);
 		});
 	}
@@ -23,7 +42,11 @@ export default class extends Channel {
 	public onMessage(type: string, body: any) {
 		switch (type) {
 			case 'read':
-				read(this.user!.id, this.otherpartyId, [body.id]);
+				if (this.otherpartyId) {
+					readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]);
+				} else if (this.groupId) {
+					readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]);
+				}
 				break;
 		}
 	}
diff --git a/src/services/stream.ts b/src/services/stream.ts
index 28cb2057e2..a47798eefd 100644
--- a/src/services/stream.ts
+++ b/src/services/stream.ts
@@ -3,6 +3,7 @@ import { User } from '../models/entities/user';
 import { Note } from '../models/entities/note';
 import { UserList } from '../models/entities/user-list';
 import { ReversiGame } from '../models/entities/games/reversi/game';
+import { UserGroup } from '../models/entities/user-group';
 
 class Publisher {
 	private publish = (channel: string, type: string | null, value?: any): void => {
@@ -39,6 +40,10 @@ class Publisher {
 		this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	public publishGroupMessagingStream = (groupId: UserGroup['id'], type: string, value?: any): void => {
+		this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	public publishMessagingIndexStream = (userId: User['id'], type: string, value?: any): void => {
 		this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
 	}
@@ -74,6 +79,7 @@ export const publishNoteStream = publisher.publishNoteStream;
 export const publishNotesStream = publisher.publishNotesStream;
 export const publishUserListStream = publisher.publishUserListStream;
 export const publishMessagingStream = publisher.publishMessagingStream;
+export const publishGroupMessagingStream = publisher.publishGroupMessagingStream;
 export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
 export const publishReversiStream = publisher.publishReversiStream;
 export const publishReversiGameStream = publisher.publishReversiGameStream;