mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-26 19:14:12 +00:00 
			
		
		
		
	Enhance poll (#4409)
* 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
This commit is contained in:
		
							parent
							
								
									f74a32ed9b
								
							
						
					
					
						commit
						725600da8f
					
				
					 34 changed files with 505 additions and 86 deletions
				
			
		|  | @ -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" | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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é" | ||||
|  |  | |||
|  | @ -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: "リアクションを選択" | ||||
|  |  | |||
|  | @ -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: "投票済みや" | ||||
|  |  | |||
|  | @ -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: "투표함" | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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: "已投票" | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -366,6 +366,9 @@ root(fill) | |||
| 			&[type='file'] | ||||
| 				display none | ||||
| 
 | ||||
| 			&[type='number'] | ||||
| 				text-align right | ||||
| 
 | ||||
| 		> .prefix | ||||
| 		> .suffix | ||||
| 			display block | ||||
|  |  | |||
|  | @ -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(); | ||||
| 		}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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; | ||||
| 				} | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
| 	}; | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| 	}; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
							
								
								
									
										22
									
								
								src/remote/activitypub/renderer/vote.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/remote/activitypub/renderer/vote.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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'); | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										91
									
								
								test/api.ts
									
										
									
									
									
								
							
							
						
						
									
										91
									
								
								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' }); | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue