From 725600da8f92a223f10a4a9a1ff874c5eff1534f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?=
 <root@acid-chicken.com>
Date: Wed, 6 Mar 2019 22:55:47 +0900
Subject: [PATCH] Enhance poll (#4409)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Start working

* WIP: Enhance poll

* Fix bug

* Use `name` in voting note
refs: https://github.com/syuilo/misskey/issues/4407#issuecomment-469057296

* Fix style

* Refactor
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>

* WIP: Update poll editor

* Fix bug

* Fix bug
refs: https://github.com/syuilo/misskey/pull/4409#discussion_r

* Fix typo

* Better design

* Beautify poll editor

* Fix UI

* Fix bug
refs: https://github.com/syuilo/misskey/pull/4409#discussion_r262217524

* Add debug logging

* Fix bug

* Log deliver

* fix vote

* Update ap/show
refs: https://github.com/syuilo/misskey/pull/4409#issuecomment-469652386

* Update poll view

* Maybe done

* Add tests

* Fix path

* Fix test

* Fix test

* Fix test

* Fix expired check on AP

* Update note.ts

* Squashed commit of the following:

commit d9a4beabf851893b8992a0f4568265eb9d4f0b8e
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 05:16:14 2019 +0900

    tune

commit 83ff421a6e978243f80ba9ec820189bc897e6e3b
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 05:01:14 2019 +0900

    fallback

commit 0b566af973b115ade9e75ea4b8094ee2b329dabc
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 04:40:12 2019 +0900

    Note

commit cc0296dd6127580ac584c40398db3f762a311f8b
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 04:33:58 2019 +0900

    createで送る

* Squashed commit of the following:

commit ae696b1ed12568b27c27367ac5a77035c97c9a1f
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 06:11:17 2019 +0900

    fix

commit b735e354e7a9e64534c4f17d04ecbc65fb735c21
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 06:08:33 2019 +0900

    messge

commit d9a4beabf851893b8992a0f4568265eb9d4f0b8e
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 05:16:14 2019 +0900

    tune

commit 83ff421a6e978243f80ba9ec820189bc897e6e3b
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 05:01:14 2019 +0900

    fallback

commit 0b566af973b115ade9e75ea4b8094ee2b329dabc
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 04:40:12 2019 +0900

    Note

commit cc0296dd6127580ac584c40398db3f762a311f8b
Author: mei23 <m@m544.net>
Date:   Wed Mar 6 04:33:58 2019 +0900

    createで送る

* Fix typo

* Update vote.ts

* Update vote.ts

* Update poll-editor.vue

* Update tslint.json

* Fix layout

* Add note

* Fix bug

* Rename text key

* 投票するときに投稿として扱わないように (#4425)

* wip

* 形式をMastodonと合わせた

* Bye something

* Use - instead of ~

* Redundancy

* Yes!

* Refactor

* Use moment instead of Date

* Fix indent

* Refactor

if (votes.length)
は必要なさそう

* Clean up

* Bye Date

* Clean

* Fix timer is not displayed

* Fix リモートから無期限pollにvoteできない

* Fix vote actor
---
 locales/de-DE.yml                             |  2 +-
 locales/en-US.yml                             |  2 +-
 locales/es-ES.yml                             |  2 +-
 locales/fr-FR.yml                             |  2 +-
 locales/ja-JP.yml                             | 21 ++++-
 locales/ja-KS.yml                             |  2 +-
 locales/ko-KR.yml                             |  2 +-
 locales/nl-NL.yml                             |  2 +-
 locales/pl-PL.yml                             |  2 +-
 locales/zh-CN.yml                             |  2 +-
 .../common/views/components/poll-editor.vue   | 90 +++++++++++++++++-
 .../app/common/views/components/poll.vue      | 49 +++++++---
 .../app/common/views/components/ui/input.vue  |  3 +
 .../desktop/views/components/post-form.vue    |  7 +-
 .../app/mobile/views/components/post-form.vue |  5 +-
 src/models/note.ts                            | 26 +++++-
 src/models/poll-vote.ts                       |  3 +-
 src/queue/processors/http/deliver.ts          |  5 +
 .../activitypub/kernel/announce/index.ts      |  4 +
 src/remote/activitypub/kernel/create/index.ts |  6 +-
 src/remote/activitypub/kernel/delete/index.ts |  4 +
 src/remote/activitypub/models/note.ts         | 49 +++++++---
 src/remote/activitypub/models/question.ts     | 41 ++++++---
 src/remote/activitypub/renderer/note.ts       | 29 +++++-
 src/remote/activitypub/renderer/question.ts   | 18 ++--
 src/remote/activitypub/renderer/vote.ts       | 22 +++++
 src/remote/activitypub/type.ts                |  4 +
 src/server/api/endpoints/ap/show.ts           |  2 +-
 src/server/api/endpoints/notes/create.ts      | 24 ++++-
 src/server/api/endpoints/notes/polls/vote.ts  | 44 ++++++---
 src/services/note/create.ts                   | 17 ++++
 src/services/note/polls/vote.ts               |  7 +-
 test/api.ts                                   | 91 +++++++++++++++++++
 tslint.json                                   |  2 +
 34 files changed, 505 insertions(+), 86 deletions(-)
 create mode 100644 src/remote/activitypub/renderer/vote.ts

diff --git a/locales/de-DE.yml b/locales/de-DE.yml
index 353ff56fdf..807c68be61 100644
--- a/locales/de-DE.yml
+++ b/locales/de-DE.yml
@@ -270,7 +270,7 @@ common/views/components/note-menu.vue:
 common/views/components/poll.vue:
   vote-to: "Stimme für '{}'"
   vote-count: "{} Stimmen"
-  total-users: "{} Nutzer haben abgestimmt"
+  total-votes: "{} Nutzer haben abgestimmt"
   vote: "Abstimmen"
   show-result: "Zeige Ergebnis"
   voted: "Abgestimmt"
diff --git a/locales/en-US.yml b/locales/en-US.yml
index e6c081ff0e..a00fc8dcfb 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "Vote for '{}'"
   vote-count: "{} votes"
-  total-users: "{} users voted"
+  total-votes: "{} users voted"
   vote: "Vote"
   show-result: "Show results"
   voted: "Voted"
diff --git a/locales/es-ES.yml b/locales/es-ES.yml
index 0dd1ea7738..3d39752841 100644
--- a/locales/es-ES.yml
+++ b/locales/es-ES.yml
@@ -303,7 +303,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "'{}' para votar"
   vote-count: "{} votos"
-  total-users: "{} usuario(s) que ha(n) votado"
+  total-votes: "{} usuario(s) que ha(n) votado"
   vote: "Vota"
   show-result: "Mostrar resultados"
   voted: "Votado"
diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml
index 8ce34dfd89..542684d286 100644
--- a/locales/fr-FR.yml
+++ b/locales/fr-FR.yml
@@ -383,7 +383,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "Voter pour '{}'"
   vote-count: "{} votes"
-  total-users: "{} utilisateur·rice·s ont voté"
+  total-votes: "{} utilisateur·rice·s ont voté"
   vote: "Vote"
   show-result: "Montrer les résultats"
   voted: "Voté"
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2a63d142a3..83441af8f5 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -527,10 +527,15 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "「{}」に投票する"
   vote-count: "{}票"
-  total-users: "{}人が投票"
+  total-votes: "計{}票"
   vote: "投票する"
   show-result: "結果を見る"
   voted: "投票済み"
+  closed: "終了済み"
+  remaining-days: "終了まであと{d}日{h}時間"
+  remaining-hours: "終了まであと{h}時間{m}分"
+  remaining-minutes: "終了まであと{m}分{s}秒"
+  remaining-seconds: "終了まであと{s}秒"
 
 common/views/components/poll-editor.vue:
   no-only-one-choice: "アンケートには、選択肢が最低2つ必要です"
@@ -538,6 +543,20 @@ common/views/components/poll-editor.vue:
   remove: "この選択肢を削除"
   add: "+選択肢を追加"
   destroy: "アンケートを破棄"
+  multiple: "複数回答可"
+  expiration: "期限"
+  infinite: "無期限"
+  at: "日時指定"
+  after: "経過指定"
+  no-more: "これ以上追加できません"
+  deadline-date: "期日"
+  deadline-time: "時間"
+  interval: "期間"
+  unit: "単位"
+  second: "秒"
+  minute: "分"
+  hour: "時間"
+  day: "日"
 
 common/views/components/reaction-picker.vue:
   choose-reaction: "リアクションを選択"
diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml
index dbf8629c90..214c4657f0 100644
--- a/locales/ja-KS.yml
+++ b/locales/ja-KS.yml
@@ -344,7 +344,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "「{}」に投票や!"
   vote-count: "{}票"
-  total-users: "{}人が投票"
+  total-votes: "{}人が投票"
   vote: "投票するで"
   show-result: "結果を見よか"
   voted: "投票済みや"
diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml
index 6627e664f6..b976945e4c 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "\"{}\"에 투표하기"
   vote-count: "{}표"
-  total-users: "{}명이 투표"
+  total-votes: "{}명이 투표"
   vote: "투표하기"
   show-result: "결과 보기"
   voted: "투표함"
diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml
index bd64792f60..c39a4d66a1 100644
--- a/locales/nl-NL.yml
+++ b/locales/nl-NL.yml
@@ -131,7 +131,7 @@ common/views/components/note-menu.vue:
 common/views/components/poll.vue:
   vote-to: "Stemmen op '{}'"
   vote-count: "{} stemmen"
-  total-users: "{} gebruikers hebben gestemd"
+  total-votes: "{} gebruikers hebben gestemd"
   vote: "Stemmen"
   show-result: "Resultaten tonen"
   voted: "Gestemd"
diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml
index af1a801336..affe08d0da 100644
--- a/locales/pl-PL.yml
+++ b/locales/pl-PL.yml
@@ -346,7 +346,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "Zagłosuj na '{}'"
   vote-count: "{} głosów"
-  total-users: "{} głosujących"
+  total-votes: "{} głosujących"
   vote: "Zagłosuj"
   show-result: "Pokaż wyniki"
   voted: "Zagłosowano"
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 9f04b51d36..9a199b1c10 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
 common/views/components/poll.vue:
   vote-to: "为\"{}\"投票"
   vote-count: "{}票"
-  total-users: "{} 人投票"
+  total-votes: "{} 人投票"
   vote: "投票"
   show-result: "显示结果"
   voted: "已投票"
diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue
index d6f30f380d..88d7311f5c 100644
--- a/src/client/app/common/views/components/poll-editor.vue
+++ b/src/client/app/common/views/components/poll-editor.vue
@@ -12,21 +12,54 @@
 		</li>
 	</ul>
 	<button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</button>
+	<button class="add" v-else disabled>{{ $t('no-more') }}</button>
 	<button class="destroy" @click="destroy" :title="$t('destroy')">
 		<fa icon="times"/>
 	</button>
+	<section>
+		<ui-switch v-model="multiple">{{ $t('multiple') }}</ui-switch>
+		<div>
+			<ui-select v-model="expiration">
+				<template #label>{{ $t('expiration') }}</template>
+				<option value="infinite">{{ $t('infinite') }}</option>
+				<option value="at">{{ $t('at') }}</option>
+				<option value="after">{{ $t('after') }}</option>
+			</ui-select>
+			<section v-if="expiration === 'at'">
+				<ui-input v-model="atDate" type="date">{{ $t('deadline-date') }}</ui-input>
+				<ui-input v-model="atTime" type="time">{{ $t('deadline-time') }}</ui-input>
+			</section>
+			<section v-if="expiration === 'after'">
+				<ui-input v-model="after" type="number">{{ $t('interval') }}</ui-input>
+				<ui-select v-model="unit">
+					<template #label>{{ $t('unit') }}</template>
+					<option value="second">{{ $t('second') }}</option>
+					<option value="minute">{{ $t('minute') }}</option>
+					<option value="hour">{{ $t('hour') }}</option>
+					<option value="day">{{ $t('day') }}</option>
+				</ui-select>
+			</section>
+		</div>
+	</section>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import * as moment from 'moment';
 import i18n from '../../../i18n';
 import { erase } from '../../../../../prelude/array';
 export default Vue.extend({
 	i18n: i18n('common/views/components/poll-editor.vue'),
 	data() {
 		return {
-			choices: ['', '']
+			choices: ['', ''],
+			multiple: false,
+			expiration: 'infinite',
+			atDate: moment().add(1, 'day').toISOString().split('T')[0],
+			atTime: '00:00',
+			after: 0,
+			unit: 'second'
 		};
 	},
 	watch: {
@@ -55,15 +88,46 @@ export default Vue.extend({
 		},
 
 		get() {
+			const at = () => {
+				const [date] = moment(this.atDate).toISOString().split('T');
+				const [hour, minute] = this.atTime.split(':');
+				return moment(`${date}T${hour}:${minute}Z`).valueOf();
+			};
+
+			const after = () => {
+				let base = parseInt(this.after);
+				switch (this.unit) {
+					case 'day': base *= 24;
+					case 'hour': base *= 60;
+					case 'minute': base *= 60;
+					case 'second': return base *= 1000;
+					default: return null;
+				}
+			};
+
 			return {
-				choices: erase('', this.choices)
-			}
+				choices: erase('', this.choices),
+				multiple: this.multiple,
+				...(
+					this.expiration === 'at' ? { expiresAt: at() } :
+					this.expiration === 'after' ? { expiredAfter: after() } : {})
+			};
 		},
 
 		set(data) {
 			if (data.choices.length == 0) return;
 			this.choices = data.choices;
 			if (data.choices.length == 1) this.choices = this.choices.concat('');
+			this.multiple = data.multiple;
+			if (data.expiresAt) {
+				this.expiration = 'at';
+				this.atDate = this.atTime = data.expiresAt;
+			} else if (typeof data.expiredAfter === 'number') {
+				this.expiration = 'after';
+				this.after = data.expiredAfter;
+			} else {
+				this.expiration = 'infinite';
+			}
 		}
 	}
 });
@@ -128,6 +192,7 @@ export default Vue.extend({
 		margin 8px 0 0 0
 		vertical-align top
 		color var(--primary)
+		z-index 1
 
 	> .destroy
 		position absolute
@@ -142,4 +207,23 @@ export default Vue.extend({
 		&:active
 			color var(--primaryDarken30)
 
+	> section
+		margin 16px 0 -16px 0
+
+		> div
+			margin 0 8px
+
+			&:last-child
+				flex 1 0 auto
+
+				> section
+					align-items center
+					display flex
+					margin -32px 0 0
+
+					> :first-child
+						margin-right 16px
+
+					> .ui-input
+						flex 1 0 auto
 </style>
diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue
index 6a9bc786d6..8a0fb54a64 100644
--- a/src/client/app/common/views/components/poll.vue
+++ b/src/client/app/common/views/components/poll.vue
@@ -1,8 +1,8 @@
 <template>
-<div class="mk-poll" :data-is-voted="isVoted">
+<div class="mk-poll" :data-done="closed || isVoted">
 	<ul>
-		<li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? $t('vote-to').replace('{}', choice.text) : ''">
-			<div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div>
+		<li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!closed && !isVoted ? $t('vote-to').replace('{}', choice.text) : ''">
+			<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
 			<span>
 				<template v-if="choice.isVoted"><fa icon="check"/></template>
 				<mfm :text="choice.text" :should-break="false" :plain-text="true" :custom-emojis="note.emojis"/>
@@ -10,11 +10,13 @@
 			</span>
 		</li>
 	</ul>
-	<p v-if="total > 0">
-		<span>{{ $t('total-users').replace('{}', total) }}</span>
-		<span>・</span>
-		<a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a>
+	<p>
+		<span>{{ $t('total-votes').replace('{}', total) }}</span>
+		<span> · </span>
+		<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a>
 		<span v-if="isVoted">{{ $t('voted') }}</span>
+		<span v-else-if="closed">{{ $t('closed') }}</span>
+		<span v-if="remaining > 0"> · {{ timer }}</span>
 	</p>
 </div>
 </template>
@@ -28,6 +30,7 @@ export default Vue.extend({
 	props: ['note'],
 	data() {
 		return {
+			remaining: -1,
 			showResult: false
 		};
 	},
@@ -38,19 +41,43 @@ export default Vue.extend({
 		total(): number {
 			return sum(this.poll.choices.map(x => x.votes));
 		},
+		closed(): boolean {
+			return !this.remaining;
+		},
+		timer(): string {
+			return this.$t(
+				this.remaining > 86400 ? 'remaining-days' :
+				this.remaining > 3600 ? 'remaining-hours' :
+				this.remaining > 60 ? 'remaining-minutes' : 'remaining-seconds')
+				.replace('{s}', Math.floor(this.remaining % 60))
+				.replace('{m}', Math.floor(this.remaining / 60) % 60)
+				.replace('{h}', Math.floor(this.remaining / 3600) % 24)
+				.replace('{d}', Math.floor(this.remaining / 86400));
+		},
 		isVoted(): boolean {
-			return this.poll.choices.some(c => c.isVoted);
+			return !this.poll.multiple && this.poll.choices.some(c => c.isVoted);
 		}
 	},
 	created() {
 		this.showResult = this.isVoted;
+
+		if (this.note.poll.expiresAt) {
+			const update = () => {
+				if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000))
+					requestAnimationFrame(update);
+				else
+					this.showResult = true;
+			};
+
+			update();
+		}
 	},
 	methods: {
 		toggleShowResult() {
 			this.showResult = !this.showResult;
 		},
 		vote(id) {
-			if (this.poll.choices.some(c => c.isVoted)) return;
+			if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return;
 			this.$root.api('notes/polls/vote', {
 				noteId: this.note.id,
 				choice: id
@@ -61,7 +88,7 @@ export default Vue.extend({
 						Vue.set(c, 'isVoted', true);
 					}
 				}
-				this.showResult = true;
+				this.showResult = !this.poll.multiple;
 			});
 		}
 	}
@@ -114,7 +141,7 @@ export default Vue.extend({
 		a
 			color inherit
 
-	&[data-is-voted]
+	&[data-done]
 		> ul > li
 			cursor default
 
diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue
index ae9ce249de..5a6a32db36 100644
--- a/src/client/app/common/views/components/ui/input.vue
+++ b/src/client/app/common/views/components/ui/input.vue
@@ -366,6 +366,9 @@ root(fill)
 			&[type='file']
 				display none
 
+			&[type='number']
+				text-align right
+
 		> .prefix
 		> .suffix
 			display block
diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue
index b26187d71b..178b7ad7a7 100644
--- a/src/client/app/desktop/views/components/post-form.vue
+++ b/src/client/app/desktop/views/components/post-form.vue
@@ -115,6 +115,8 @@ export default Vue.extend({
 			uploadings: [],
 			poll: false,
 			pollChoices: [],
+			pollMultiple: false,
+			pollExpiration: [],
 			useCw: false,
 			cw: null,
 			geo: null,
@@ -295,7 +297,10 @@ export default Vue.extend({
 		},
 
 		onPollUpdate() {
-			this.pollChoices = this.$refs.poll.get().choices;
+			const got = this.$refs.poll.get();
+			this.pollChoices = got.choices;
+			this.pollMultiple = got.multiple;
+			this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter];
 			this.saveDraft();
 		},
 
diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue
index 1c4ddd60a9..97391893a3 100644
--- a/src/client/app/mobile/views/components/post-form.vue
+++ b/src/client/app/mobile/views/components/post-form.vue
@@ -105,6 +105,7 @@ export default Vue.extend({
 			files: [],
 			poll: false,
 			pollChoices: [],
+			pollMultiple: false,
 			geo: null,
 			visibility: 'public',
 			visibleUsers: [],
@@ -273,7 +274,9 @@ export default Vue.extend({
 		},
 
 		onPollUpdate() {
-			this.pollChoices = this.$refs.poll.get().choices;
+			const got = this.$refs.poll.get();
+			this.pollChoices = got.choices;
+			this.pollMultiple = got.multiple;
 		},
 
 		upload(file) {
diff --git a/src/models/note.ts b/src/models/note.ts
index c3413164be..369838ae74 100644
--- a/src/models/note.ts
+++ b/src/models/note.ts
@@ -99,7 +99,9 @@ export type INote = {
 };
 
 export type IPoll = {
-	choices: IChoice[]
+	choices: IChoice[];
+	multiple?: boolean;
+	expiresAt?: Date;
 };
 
 export type IChoice = {
@@ -313,15 +315,31 @@ export const pack = async (
 		// Poll
 		if (meId && _note.poll) {
 			_note.poll = (async poll => {
+				if (poll.multiple) {
+					const votes = await PollVote.find({
+						userId: meId,
+						noteId: id
+					});
+
+					const myChoices = (poll.choices as IChoice[]).filter(x => votes.some(y => x.id == y.choice));
+					for (const myChoice of myChoices) {
+						(myChoice as any).isVoted = true;
+					}
+
+					return poll;
+				} else {
+					poll.multiple = false;
+				}
+
 				const vote = await PollVote
 					.findOne({
 						userId: meId,
 						noteId: id
 					});
 
-				if (vote != null) {
-					const myChoice = poll.choices
-						.filter((c: any) => c.id == vote.choice)[0];
+				if (vote) {
+					const myChoice = (poll.choices as IChoice[])
+						.filter(x => x.id == vote.choice)[0] as any;
 
 					myChoice.isVoted = true;
 				}
diff --git a/src/models/poll-vote.ts b/src/models/poll-vote.ts
index b8aceae3b0..e6178cbc26 100644
--- a/src/models/poll-vote.ts
+++ b/src/models/poll-vote.ts
@@ -2,9 +2,10 @@ import * as mongo from 'mongodb';
 import db from '../db/mongodb';
 
 const PollVote = db.get<IPollVote>('pollVotes');
+PollVote.dropIndex(['userId', 'noteId'], { unique: true }).catch(() => {});
 PollVote.createIndex('userId');
 PollVote.createIndex('noteId');
-PollVote.createIndex(['userId', 'noteId'], { unique: true });
+PollVote.createIndex(['userId', 'noteId', 'choice'], { unique: true });
 export default PollVote;
 
 export interface IPollVote {
diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts
index 1ba582a284..96f6cc07ce 100644
--- a/src/queue/processors/http/deliver.ts
+++ b/src/queue/processors/http/deliver.ts
@@ -6,10 +6,15 @@ import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-
 import Instance from '../../../models/instance';
 import instanceChart from '../../../services/chart/instance';
 
+let latest: string = null;
+
 export default async (job: bq.Job, done: any): Promise<void> => {
 	const { host } = new URL(job.data.to);
 
 	try {
+		if (latest !== (latest = JSON.stringify(job.data.content, null, 2)))
+			queueLogger.debug(`delivering ${latest}`);
+
 		await request(job.data.user, job.data.to, job.data.content);
 
 		// Update stats
diff --git a/src/remote/activitypub/kernel/announce/index.ts b/src/remote/activitypub/kernel/announce/index.ts
index 80875b90da..3b2eeb7aa2 100644
--- a/src/remote/activitypub/kernel/announce/index.ts
+++ b/src/remote/activitypub/kernel/announce/index.ts
@@ -27,6 +27,10 @@ export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> =>
 		announceNote(resolver, actor, activity, object as INote);
 		break;
 
+	case 'Question':
+		announceNote(resolver, actor, activity, object as INote);
+		break;
+
 	default:
 		logger.warn(`Unknown announce type: ${object.type}`);
 		break;
diff --git a/src/remote/activitypub/kernel/create/index.ts b/src/remote/activitypub/kernel/create/index.ts
index c633d95487..2afdc01377 100644
--- a/src/remote/activitypub/kernel/create/index.ts
+++ b/src/remote/activitypub/kernel/create/index.ts
@@ -1,7 +1,7 @@
 import Resolver from '../../resolver';
 import { IRemoteUser } from '../../../../models/user';
-import createNote from './note';
 import createImage from './image';
+import createNote from './note';
 import { ICreate } from '../../type';
 import { apLogger } from '../../logger';
 
@@ -32,6 +32,10 @@ export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => {
 		createNote(resolver, actor, object);
 		break;
 
+	case 'Question':
+		createNote(resolver, actor, object);
+		break;
+
 	default:
 		logger.warn(`Unknown type: ${object.type}`);
 		break;
diff --git a/src/remote/activitypub/kernel/delete/index.ts b/src/remote/activitypub/kernel/delete/index.ts
index eead34785c..864c9f5f7d 100644
--- a/src/remote/activitypub/kernel/delete/index.ts
+++ b/src/remote/activitypub/kernel/delete/index.ts
@@ -24,6 +24,10 @@ export default async (actor: IRemoteUser, activity: IDelete): Promise<void> => {
 		deleteNote(actor, uri);
 		break;
 
+	case 'Question':
+		deleteNote(actor, uri);
+		break;
+
 	case 'Tombstone':
 		const note = await Note.findOne({ uri });
 		if (note != null) {
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index 76b66a07c3..5932d3d90e 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -52,9 +52,9 @@ export async function fetchNote(value: string | IObject, resolver?: Resolver): P
 export async function createNote(value: any, resolver?: Resolver, silent = false): Promise<INote> {
 	if (resolver == null) resolver = new Resolver();
 
-	const object = await resolver.resolve(value) as any;
+	const object: any = await resolver.resolve(value);
 
-	if (object == null || object.type !== 'Note') {
+	if (!object || !['Note', 'Question'].includes(object.type)) {
 		logger.error(`invalid note: ${value}`, {
 			resolver: {
 				history: resolver.getHistory()
@@ -67,6 +67,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 
 	const note: INoteActivityStreamsObject = object;
 
+	logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
+
 	logger.info(`Creating the Note: ${note.id}`);
 
 	// 投稿者をフェッチ
@@ -78,6 +80,9 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	}
 
 	//#region Visibility
+	note.to = note.to == null ? [] : typeof note.to == 'string' ? [note.to] : note.to;
+	note.cc = note.cc == null ? [] : typeof note.cc == 'string' ? [note.cc] : note.cc;
+
 	let visibility = 'public';
 	let visibleUsers: IUser[] = [];
 	if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) {
@@ -89,7 +94,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 			visibility = 'specified';
 			visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver)));
 		}
-	}
+}
 	//#endergion
 
 	const apMentions = await extractMentionedUsers(actor, note.to, note.cc, resolver);
@@ -101,6 +106,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	// TODO: attachmentは必ずしも配列ではない
 	// Noteがsensitiveなら添付もsensitiveにする
 	const limit = promiseLimit(2);
+
+	note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
 	const files = note.attachment
 		.map(attach => attach.sensitive = note.sensitive)
 		? await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<IDriveFile>))
@@ -119,15 +126,31 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	const cw = note.summary === '' ? null : note.summary;
 
 	// テキストのパース
-	const text = note._misskey_content ? note._misskey_content : fromHtml(note.content);
+	const text = note._misskey_content || fromHtml(note.content);
 
 	// vote
-	if (reply && reply.poll && text != null) {
-		const m = text.match(/([0-9])$/);
-		if (m) {
-			logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${m[0]}`);
-			await vote(actor, reply, Number(m[1]));
+	if (reply && reply.poll) {
+		const tryCreateVote = async (name: string, index: number): Promise<null> => {
+			if (reply.poll.expiresAt && Date.now() > new Date(reply.poll.expiresAt).getTime()) {
+				logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
+			} else if (index >= 0) {
+				logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
+				await vote(actor, reply, index);
+			}
 			return null;
+		};
+
+		if (note.name) {
+			return await tryCreateVote(note.name, reply.poll.choices.findIndex(x => x.text === note.name));
+		}
+
+		// 後方互換性のため
+		if (text) {
+			const m = text.match(/(\d+)$/);
+
+			if (m) {
+				return await tryCreateVote(m[0], Number(m[1]));
+			}
 		}
 	}
 
@@ -139,7 +162,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	const apEmojis = emojis.map(emoji => emoji.name);
 
 	const questionUri = note._misskey_question;
-	const poll = questionUri ? await extractPollFromQuestion(questionUri).catch(() => undefined) : undefined;
+	const poll = await extractPollFromQuestion(note._misskey_question || note).catch(() => undefined);
 
 	// ユーザーの情報が古かったらついでに更新しておく
 	if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
@@ -148,11 +171,11 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 
 	return await post(actor, {
 		createdAt: new Date(note.published),
-		files: files,
+		files,
 		reply,
 		renote: quote,
-		cw: cw,
-		text: text,
+		cw,
+		text,
 		viaMobile: false,
 		localOnly: false,
 		geo: undefined,
diff --git a/src/remote/activitypub/models/question.ts b/src/remote/activitypub/models/question.ts
index 53892a409e..edfd8701b4 100644
--- a/src/remote/activitypub/models/question.ts
+++ b/src/remote/activitypub/models/question.ts
@@ -1,19 +1,38 @@
 import { IChoice, IPoll } from '../../../models/note';
 import Resolver from '../resolver';
+import { ICollection } from '../type';
 
-export async function extractPollFromQuestion(questionUri: string): Promise<IPoll> {
-	const resolver = new Resolver();
-	const question = await resolver.resolve(questionUri) as any;
+interface IQuestionChoice {
+	name?: string;
+	replies?: ICollection;
+	_misskey_votes?: number;
+}
 
-	const choices: IChoice[] = question.oneOf.map((x: any, i: number) => {
-			return {
-				id: i,
-				text: x.name,
-				votes: x._misskey_votes || 0,
-			} as IChoice;
-	});
+interface IQuestion {
+	oneOf?: IQuestionChoice[];
+	anyOf?: IQuestionChoice[];
+	endTime?: Date;
+}
+
+export async function extractPollFromQuestion(source: string | IQuestion): Promise<IPoll> {
+	const question = typeof source === 'string' ? await new Resolver().resolve(source) as IQuestion : source;
+	const multiple = !question.oneOf;
+	const expiresAt = question.endTime ? new Date(question.endTime) : null;
+
+	if (multiple && !question.anyOf) {
+		throw 'invalid question';
+	}
+
+	const choices = question[multiple ? 'anyOf' : 'oneOf']
+		.map((x, i) => ({
+			id: i,
+			text: x.name,
+			votes: x.replies && x.replies.totalItems || x._misskey_votes || 0,
+		} as IChoice));
 
 	return {
-		choices
+		choices,
+		multiple,
+		expiresAt
 	};
 }
diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts
index 910e4dba76..8b349526e1 100644
--- a/src/remote/activitypub/renderer/note.ts
+++ b/src/remote/activitypub/renderer/note.ts
@@ -15,9 +15,10 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 		: Promise.resolve([]);
 
 	let inReplyTo;
+	let inReplyToNote: INote;
 
 	if (note.replyId) {
-		const inReplyToNote = await Note.findOne({
+		inReplyToNote = await Note.findOne({
 			_id: note.replyId,
 		});
 
@@ -134,6 +135,29 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 		...apemojis,
 	];
 
+	const {
+		choices = [],
+		expiresAt = null,
+		multiple = false
+	} = note.poll || {};
+
+	const asPoll = note.poll ? {
+		type: 'Question',
+		content: toHtml(Object.assign({}, note, {
+			text: text
+		})),
+		_misskey_fallback_content: content,
+		[expiresAt && expiresAt < new Date() ? 'closed' : 'endTime']: expiresAt,
+		[multiple ? 'anyOf' : 'oneOf']: choices.map(({ text, votes }) => ({
+			type: 'Note',
+			name: text,
+			replies: {
+				type: 'Collection',
+				totalItems: votes
+			}
+		}))
+	} : {};
+
 	return {
 		id: `${config.url}/notes/${note._id}`,
 		type: 'Note',
@@ -149,7 +173,8 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 		inReplyTo,
 		attachment: files.map(renderDocument),
 		sensitive: files.some(file => file.metadata.isSensitive),
-		tag
+		tag,
+		...asPoll
 	};
 }
 
diff --git a/src/remote/activitypub/renderer/question.ts b/src/remote/activitypub/renderer/question.ts
index 9df4daca3b..cf0bf387c8 100644
--- a/src/remote/activitypub/renderer/question.ts
+++ b/src/remote/activitypub/renderer/question.ts
@@ -3,17 +3,19 @@ import { ILocalUser } from '../../../models/user';
 import { INote } from '../../../models/note';
 
 export default async function renderQuestion(user: ILocalUser, note: INote) {
-	const question =  {
+	const question = {
 		type: 'Question',
 		id: `${config.url}/questions/${note._id}`,
 		actor: `${config.url}/users/${user._id}`,
-		content:  note.text != null ? note.text : '',
-		oneOf: note.poll.choices.map(c => {
-			return {
-				name: c.text,
-				_misskey_votes: c.votes,
-			};
-		}),
+		content:  note.text || '',
+		[note.poll.multiple ? 'anyOf' : 'oneOf']: note.poll.choices.map(c => ({
+			name: c.text,
+			_misskey_votes: c.votes,
+			replies: {
+				type: 'Collection',
+				totalItems: c.votes
+			}
+		}))
 	};
 
 	return question;
diff --git a/src/remote/activitypub/renderer/vote.ts b/src/remote/activitypub/renderer/vote.ts
new file mode 100644
index 0000000000..014b76765b
--- /dev/null
+++ b/src/remote/activitypub/renderer/vote.ts
@@ -0,0 +1,22 @@
+import config from '../../../config';
+import { INote } from '../../../models/note';
+import { IRemoteUser, ILocalUser } from '../../../models/user';
+import { IPollVote } from '../../../models/poll-vote';
+
+export default async function renderVote(user: ILocalUser, vote: IPollVote, pollNote: INote, pollOwner: IRemoteUser): Promise<any> {
+	return {
+		id: `${config.url}/users/${user._id}#votes/${vote._id}/activity`,
+		actor: `${config.url}/users/${user._id}`,
+		type: 'Create',
+		to: [pollOwner.uri],
+		published: new Date().toISOString(),
+		object: {
+			id: `${config.url}/users/${user._id}#votes/${vote._id}`,
+			type: 'Note',
+			attributedTo: `${config.url}/users/${user._id}`,
+			to: [pollOwner.uri],
+			inReplyTo: pollNote.uri,
+			name: pollNote.poll.choices.find(x => x.id === vote.choice).text
+		}
+	};
+}
diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts
index b902abea23..c8a00f3591 100644
--- a/src/remote/activitypub/type.ts
+++ b/src/remote/activitypub/type.ts
@@ -11,7 +11,11 @@ export interface IObject {
 	attributedTo: string;
 	attachment?: any[];
 	inReplyTo?: any;
+	replies?: ICollection;
 	content: string;
+	name?: string;
+	startTime?: Date;
+	endTime?: Date;
 	icon?: any;
 	image?: any;
 	url?: string;
diff --git a/src/server/api/endpoints/ap/show.ts b/src/server/api/endpoints/ap/show.ts
index 72c8537905..db02ecb8ea 100644
--- a/src/server/api/endpoints/ap/show.ts
+++ b/src/server/api/endpoints/ap/show.ts
@@ -97,7 +97,7 @@ async function fetchAny(uri: string) {
 		};
 	}
 
-	if (object.type === 'Note') {
+	if (['Note', 'Question'].includes(object.type)) {
 		const note = await createNote(object.id);
 		return {
 			type: 'Note',
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index bb0d8f94f5..8cc5e4b815 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -165,7 +165,10 @@ export const meta = {
 				choices: $.arr($.str)
 					.unique()
 					.range(2, 10)
-					.each(c => c.length > 0 && c.length < 50)
+					.each(c => c.length > 0 && c.length < 50),
+				multiple: $.optional.bool,
+				expiresAt: $.optional.nullable.num.int(),
+				expiredAfter: $.optional.nullable.num.int().min(1)
 			}).strict(),
 			desc: {
 				'ja-JP': 'アンケート'
@@ -214,6 +217,12 @@ export const meta = {
 			code: 'CONTENT_REQUIRED',
 			id: '6f57e42b-c348-439b-bc45-993995cc515a'
 		},
+
+		cannotCreateAlreadyExpiredPoll: {
+			message: 'Poll is already expired.',
+			code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
+			id: '04da457d-b083-4055-9082-955525eda5a5'
+		}
 	}
 };
 
@@ -275,6 +284,13 @@ export default define(meta, async (ps, user, app) => {
 			text: choice.trim(),
 			votes: 0
 		}));
+
+		if (typeof ps.poll.expiresAt === 'number') {
+			if (ps.poll.expiresAt < Date.now())
+				throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+		} else if (typeof ps.poll.expiredAfter === 'number') {
+			ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
+		}
 	}
 
 	// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
@@ -291,7 +307,11 @@ export default define(meta, async (ps, user, app) => {
 	const note = await create(user, {
 		createdAt: new Date(),
 		files: files,
-		poll: ps.poll,
+		poll: ps.poll ? {
+			choices: ps.poll.choices,
+			multiple: ps.poll.multiple || false,
+			expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null
+		} : undefined,
 		text: ps.text,
 		reply,
 		renote,
diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts
index 60007db136..115e483db9 100644
--- a/src/server/api/endpoints/notes/polls/vote.ts
+++ b/src/server/api/endpoints/notes/polls/vote.ts
@@ -7,10 +7,13 @@ import watch from '../../../../../services/note/watch';
 import { publishNoteStream } from '../../../../../services/stream';
 import notify from '../../../../../services/create-notification';
 import define from '../../../define';
-import createNote from '../../../../../services/note/create';
-import User from '../../../../../models/user';
+import User, { IRemoteUser } from '../../../../../models/user';
 import { ApiError } from '../../../error';
 import { getNote } from '../../../common/getters';
+import { deliver } from '../../../../../queue';
+import { renderActivity } from '../../../../../remote/activitypub/renderer';
+import renderCreate from '../../../../../remote/activitypub/renderer/create';
+import renderVote from '../../../../../remote/activitypub/renderer/vote';
 
 export const meta = {
 	desc: {
@@ -63,10 +66,18 @@ export const meta = {
 			code: 'ALREADY_VOTED',
 			id: '0963fc77-efac-419b-9424-b391608dc6d8'
 		},
+
+		alreadyExpired: {
+			message: 'The poll is already expired.',
+			code: 'ALREADY_EXPIRED',
+			id: '1022a357-b085-4054-9083-8f8de358337e'
+		},
 	}
 };
 
 export default define(meta, async (ps, user) => {
+	const createdAt = new Date();
+
 	// Get votee
 	const note = await getNote(ps.noteId).catch(e => {
 		if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
@@ -77,23 +88,32 @@ export default define(meta, async (ps, user) => {
 		throw new ApiError(meta.errors.noPoll);
 	}
 
+	if (note.poll.expiresAt && note.poll.expiresAt < createdAt) {
+		throw new ApiError(meta.errors.alreadyExpired);
+	}
+
 	if (!note.poll.choices.some(x => x.id == ps.choice)) {
 		throw new ApiError(meta.errors.invalidChoice);
 	}
 
 	// if already voted
-	const exist = await Vote.findOne({
+	const exist = await Vote.find({
 		noteId: note._id,
 		userId: user._id
 	});
 
-	if (exist !== null) {
-		throw new ApiError(meta.errors.alreadyVoted);
+	if (exist.length) {
+		if (note.poll.multiple) {
+			if (exist.some(x => x.choice == ps.choice))
+				throw new ApiError(meta.errors.alreadyVoted);
+		} else {
+			throw new ApiError(meta.errors.alreadyVoted);
+		}
 	}
 
 	// Create vote
-	await Vote.insert({
-		createdAt: new Date(),
+	const vote = await Vote.insert({
+		createdAt,
 		noteId: note._id,
 		userId: user._id,
 		choice: ps.choice
@@ -146,17 +166,11 @@ export default define(meta, async (ps, user) => {
 
 	// リモート投票の場合リプライ送信
 	if (note._user.host != null) {
-		const pollOwner = await User.findOne({
+		const pollOwner: IRemoteUser = await User.findOne({
 			_id: note.userId
 		});
 
-		createNote(user, {
-			createdAt: new Date(),
-			text: ps.choice.toString(),
-			reply: note,
-			visibility: 'specified',
-			visibleUsers: [ pollOwner ],
-		});
+		deliver(user, renderActivity(await renderVote(user, vote, note, pollOwner)), pollOwner.inbox);
 	}
 
 	return;
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 2a25df1553..88598e1db5 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -25,6 +25,7 @@ import notesChart from '../../services/chart/notes';
 import perUserNotesChart from '../../services/chart/per-user-notes';
 import activeUsersChart from '../../services/chart/active-users';
 import instanceChart from '../../services/chart/instance';
+import * as deepcopy from 'deepcopy';
 
 import { erase, concat } from '../../prelude/array';
 import insertNoteUnread from './unread';
@@ -596,6 +597,22 @@ async function publishToFollowers(note: INote, user: IUser, noteActivity: any) {
 	for (const inbox of queue) {
 		deliver(user as any, noteActivity, inbox);
 	}
+
+	// 後方互換製のため、Questionは時間差でNoteでも送る
+	// Questionに対応してないインスタンスは、2つめのNoteだけを採用する
+	// Questionに対応しているインスタンスは、同IDで採番されている2つめのNoteを無視する
+	setTimeout(() => {
+		if (noteActivity.object.type === 'Question') {
+			const asNote = deepcopy(noteActivity);
+
+			asNote.object.type = 'Note';
+			asNote.object.content = asNote.object._misskey_fallback_content;
+
+			for (const inbox of queue) {
+				deliver(user as any, asNote, inbox);
+			}
+		}
+	}, 10 * 1000);
 }
 
 function deliverNoteToMentionedRemoteUsers(mentionedUsers: IUser[], user: ILocalUser, noteActivity: any) {
diff --git a/src/services/note/polls/vote.ts b/src/services/note/polls/vote.ts
index e154f33b7b..a23cdc1cb4 100644
--- a/src/services/note/polls/vote.ts
+++ b/src/services/note/polls/vote.ts
@@ -10,12 +10,15 @@ export default (user: IUser, note: INote, choice: number) => new Promise(async (
 	if (!note.poll.choices.some(x => x.id == choice)) return rej('invalid choice param');
 
 	// if already voted
-	const exist = await Vote.findOne({
+	const exist = await Vote.find({
 		noteId: note._id,
 		userId: user._id
 	});
 
-	if (exist !== null) {
+	if (note.poll.multiple) {
+		if (exist.some(x => x.choice === choice))
+			return rej('already voted');
+	} else if (exist.length) {
 		return rej('already voted');
 	}
 
diff --git a/test/api.ts b/test/api.ts
index 77eb2d741b..85f3767930 100644
--- a/test/api.ts
+++ b/test/api.ts
@@ -450,6 +450,97 @@ describe('API', () => {
 			expect(res).have.status(400);
 		}));
 
+		it('投票できる', async(async () => {
+			const me = await signup();
+
+			const { body } = await request('/notes/create', {
+				text: 'test',
+				poll: {
+					choices: ['sakura', 'izumi', 'ako']
+				}
+			}, me);
+
+			const res = await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 1
+			}, me);
+
+			expect(res).have.status(204);
+		}));
+
+		it('複数投票できない', async(async () => {
+			const me = await signup();
+
+			const { body } = await request('/notes/create', {
+				text: 'test',
+				poll: {
+					choices: ['sakura', 'izumi', 'ako']
+				}
+			}, me);
+
+			await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 0
+			}, me);
+
+			const res = await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 2
+			}, me);
+
+			expect(res).have.status(400);
+		}));
+
+		it('許可されている場合は複数投票できる', async(async () => {
+			const me = await signup();
+
+			const { body } = await request('/notes/create', {
+				text: 'test',
+				poll: {
+					choices: ['sakura', 'izumi', 'ako'],
+					multiple: true
+				}
+			}, me);
+
+			await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 0
+			}, me);
+
+			await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 1
+			}, me);
+
+			const res = await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 2
+			}, me);
+
+			expect(res).have.status(204);
+		}));
+
+		it('締め切られている場合は投票できない', async(async () => {
+			const me = await signup();
+
+			const { body } = await request('/notes/create', {
+				text: 'test',
+				poll: {
+					choices: ['sakura', 'izumi', 'ako'],
+					expiredAfter: 1
+				}
+			}, me);
+
+			await new Promise(x => setTimeout(x, 2));
+
+			const res = await request('/notes/polls/vote', {
+				noteId: body.createdNote.id,
+				choice: 1
+			}, me);
+
+			expect(res).have.status(400);
+		}));
+
 		it('同じユーザーに複数メンションしても内部的にまとめられる', async(async () => {
 			const alice = await signup({ username: 'alice' });
 			const bob = await signup({ username: 'bob' });
diff --git a/tslint.json b/tslint.json
index d24523ae5c..9715b09e8a 100644
--- a/tslint.json
+++ b/tslint.json
@@ -24,12 +24,14 @@
 		"triple-equals": [false],
 		"no-shadowed-variable": false,
 		"no-string-literal": false,
+		"no-conditional-assignment": false,
 		"variable-name": [false],
 		"comment-format": [false],
 		"interface-over-type-literal": false,
 		"max-line-length": [false],
 		"max-classes-per-file": false,
 		"member-ordering": [false],
+		"radix": false,
 		"ban-types": [
 			true,
 			"Object"