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:
|
common/views/components/poll.vue:
|
||||||
vote-to: "Stimme für '{}'"
|
vote-to: "Stimme für '{}'"
|
||||||
vote-count: "{} Stimmen"
|
vote-count: "{} Stimmen"
|
||||||
total-users: "{} Nutzer haben abgestimmt"
|
total-votes: "{} Nutzer haben abgestimmt"
|
||||||
vote: "Abstimmen"
|
vote: "Abstimmen"
|
||||||
show-result: "Zeige Ergebnis"
|
show-result: "Zeige Ergebnis"
|
||||||
voted: "Abgestimmt"
|
voted: "Abgestimmt"
|
||||||
|
|
|
@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "Vote for '{}'"
|
vote-to: "Vote for '{}'"
|
||||||
vote-count: "{} votes"
|
vote-count: "{} votes"
|
||||||
total-users: "{} users voted"
|
total-votes: "{} users voted"
|
||||||
vote: "Vote"
|
vote: "Vote"
|
||||||
show-result: "Show results"
|
show-result: "Show results"
|
||||||
voted: "Voted"
|
voted: "Voted"
|
||||||
|
|
|
@ -303,7 +303,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "'{}' para votar"
|
vote-to: "'{}' para votar"
|
||||||
vote-count: "{} votos"
|
vote-count: "{} votos"
|
||||||
total-users: "{} usuario(s) que ha(n) votado"
|
total-votes: "{} usuario(s) que ha(n) votado"
|
||||||
vote: "Vota"
|
vote: "Vota"
|
||||||
show-result: "Mostrar resultados"
|
show-result: "Mostrar resultados"
|
||||||
voted: "Votado"
|
voted: "Votado"
|
||||||
|
|
|
@ -383,7 +383,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "Voter pour '{}'"
|
vote-to: "Voter pour '{}'"
|
||||||
vote-count: "{} votes"
|
vote-count: "{} votes"
|
||||||
total-users: "{} utilisateur·rice·s ont voté"
|
total-votes: "{} utilisateur·rice·s ont voté"
|
||||||
vote: "Vote"
|
vote: "Vote"
|
||||||
show-result: "Montrer les résultats"
|
show-result: "Montrer les résultats"
|
||||||
voted: "Voté"
|
voted: "Voté"
|
||||||
|
|
|
@ -527,10 +527,15 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "「{}」に投票する"
|
vote-to: "「{}」に投票する"
|
||||||
vote-count: "{}票"
|
vote-count: "{}票"
|
||||||
total-users: "{}人が投票"
|
total-votes: "計{}票"
|
||||||
vote: "投票する"
|
vote: "投票する"
|
||||||
show-result: "結果を見る"
|
show-result: "結果を見る"
|
||||||
voted: "投票済み"
|
voted: "投票済み"
|
||||||
|
closed: "終了済み"
|
||||||
|
remaining-days: "終了まであと{d}日{h}時間"
|
||||||
|
remaining-hours: "終了まであと{h}時間{m}分"
|
||||||
|
remaining-minutes: "終了まであと{m}分{s}秒"
|
||||||
|
remaining-seconds: "終了まであと{s}秒"
|
||||||
|
|
||||||
common/views/components/poll-editor.vue:
|
common/views/components/poll-editor.vue:
|
||||||
no-only-one-choice: "アンケートには、選択肢が最低2つ必要です"
|
no-only-one-choice: "アンケートには、選択肢が最低2つ必要です"
|
||||||
|
@ -538,6 +543,20 @@ common/views/components/poll-editor.vue:
|
||||||
remove: "この選択肢を削除"
|
remove: "この選択肢を削除"
|
||||||
add: "+選択肢を追加"
|
add: "+選択肢を追加"
|
||||||
destroy: "アンケートを破棄"
|
destroy: "アンケートを破棄"
|
||||||
|
multiple: "複数回答可"
|
||||||
|
expiration: "期限"
|
||||||
|
infinite: "無期限"
|
||||||
|
at: "日時指定"
|
||||||
|
after: "経過指定"
|
||||||
|
no-more: "これ以上追加できません"
|
||||||
|
deadline-date: "期日"
|
||||||
|
deadline-time: "時間"
|
||||||
|
interval: "期間"
|
||||||
|
unit: "単位"
|
||||||
|
second: "秒"
|
||||||
|
minute: "分"
|
||||||
|
hour: "時間"
|
||||||
|
day: "日"
|
||||||
|
|
||||||
common/views/components/reaction-picker.vue:
|
common/views/components/reaction-picker.vue:
|
||||||
choose-reaction: "リアクションを選択"
|
choose-reaction: "リアクションを選択"
|
||||||
|
|
|
@ -344,7 +344,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "「{}」に投票や!"
|
vote-to: "「{}」に投票や!"
|
||||||
vote-count: "{}票"
|
vote-count: "{}票"
|
||||||
total-users: "{}人が投票"
|
total-votes: "{}人が投票"
|
||||||
vote: "投票するで"
|
vote: "投票するで"
|
||||||
show-result: "結果を見よか"
|
show-result: "結果を見よか"
|
||||||
voted: "投票済みや"
|
voted: "投票済みや"
|
||||||
|
|
|
@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "\"{}\"에 투표하기"
|
vote-to: "\"{}\"에 투표하기"
|
||||||
vote-count: "{}표"
|
vote-count: "{}표"
|
||||||
total-users: "{}명이 투표"
|
total-votes: "{}명이 투표"
|
||||||
vote: "투표하기"
|
vote: "투표하기"
|
||||||
show-result: "결과 보기"
|
show-result: "결과 보기"
|
||||||
voted: "투표함"
|
voted: "투표함"
|
||||||
|
|
|
@ -131,7 +131,7 @@ common/views/components/note-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "Stemmen op '{}'"
|
vote-to: "Stemmen op '{}'"
|
||||||
vote-count: "{} stemmen"
|
vote-count: "{} stemmen"
|
||||||
total-users: "{} gebruikers hebben gestemd"
|
total-votes: "{} gebruikers hebben gestemd"
|
||||||
vote: "Stemmen"
|
vote: "Stemmen"
|
||||||
show-result: "Resultaten tonen"
|
show-result: "Resultaten tonen"
|
||||||
voted: "Gestemd"
|
voted: "Gestemd"
|
||||||
|
|
|
@ -346,7 +346,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "Zagłosuj na '{}'"
|
vote-to: "Zagłosuj na '{}'"
|
||||||
vote-count: "{} głosów"
|
vote-count: "{} głosów"
|
||||||
total-users: "{} głosujących"
|
total-votes: "{} głosujących"
|
||||||
vote: "Zagłosuj"
|
vote: "Zagłosuj"
|
||||||
show-result: "Pokaż wyniki"
|
show-result: "Pokaż wyniki"
|
||||||
voted: "Zagłosowano"
|
voted: "Zagłosowano"
|
||||||
|
|
|
@ -489,7 +489,7 @@ common/views/components/user-menu.vue:
|
||||||
common/views/components/poll.vue:
|
common/views/components/poll.vue:
|
||||||
vote-to: "为\"{}\"投票"
|
vote-to: "为\"{}\"投票"
|
||||||
vote-count: "{}票"
|
vote-count: "{}票"
|
||||||
total-users: "{} 人投票"
|
total-votes: "{} 人投票"
|
||||||
vote: "投票"
|
vote: "投票"
|
||||||
show-result: "显示结果"
|
show-result: "显示结果"
|
||||||
voted: "已投票"
|
voted: "已投票"
|
||||||
|
|
|
@ -12,21 +12,54 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</button>
|
<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')">
|
<button class="destroy" @click="destroy" :title="$t('destroy')">
|
||||||
<fa icon="times"/>
|
<fa icon="times"/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
|
import * as moment from 'moment';
|
||||||
import i18n from '../../../i18n';
|
import i18n from '../../../i18n';
|
||||||
import { erase } from '../../../../../prelude/array';
|
import { erase } from '../../../../../prelude/array';
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
i18n: i18n('common/views/components/poll-editor.vue'),
|
i18n: i18n('common/views/components/poll-editor.vue'),
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
choices: ['', '']
|
choices: ['', ''],
|
||||||
|
multiple: false,
|
||||||
|
expiration: 'infinite',
|
||||||
|
atDate: moment().add(1, 'day').toISOString().split('T')[0],
|
||||||
|
atTime: '00:00',
|
||||||
|
after: 0,
|
||||||
|
unit: 'second'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -55,15 +88,46 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
get() {
|
get() {
|
||||||
return {
|
const at = () => {
|
||||||
choices: erase('', this.choices)
|
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),
|
||||||
|
multiple: this.multiple,
|
||||||
|
...(
|
||||||
|
this.expiration === 'at' ? { expiresAt: at() } :
|
||||||
|
this.expiration === 'after' ? { expiredAfter: after() } : {})
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
set(data) {
|
set(data) {
|
||||||
if (data.choices.length == 0) return;
|
if (data.choices.length == 0) return;
|
||||||
this.choices = data.choices;
|
this.choices = data.choices;
|
||||||
if (data.choices.length == 1) this.choices = this.choices.concat('');
|
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
|
margin 8px 0 0 0
|
||||||
vertical-align top
|
vertical-align top
|
||||||
color var(--primary)
|
color var(--primary)
|
||||||
|
z-index 1
|
||||||
|
|
||||||
> .destroy
|
> .destroy
|
||||||
position absolute
|
position absolute
|
||||||
|
@ -142,4 +207,23 @@ export default Vue.extend({
|
||||||
&:active
|
&:active
|
||||||
color var(--primaryDarken30)
|
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>
|
</style>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mk-poll" :data-is-voted="isVoted">
|
<div class="mk-poll" :data-done="closed || isVoted">
|
||||||
<ul>
|
<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) : ''">
|
<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>
|
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
|
||||||
<span>
|
<span>
|
||||||
<template v-if="choice.isVoted"><fa icon="check"/></template>
|
<template v-if="choice.isVoted"><fa icon="check"/></template>
|
||||||
<mfm :text="choice.text" :should-break="false" :plain-text="true" :custom-emojis="note.emojis"/>
|
<mfm :text="choice.text" :should-break="false" :plain-text="true" :custom-emojis="note.emojis"/>
|
||||||
|
@ -10,11 +10,13 @@
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="total > 0">
|
<p>
|
||||||
<span>{{ $t('total-users').replace('{}', total) }}</span>
|
<span>{{ $t('total-votes').replace('{}', total) }}</span>
|
||||||
<span>・</span>
|
<span> · </span>
|
||||||
<a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a>
|
<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a>
|
||||||
<span v-if="isVoted">{{ $t('voted') }}</span>
|
<span v-if="isVoted">{{ $t('voted') }}</span>
|
||||||
|
<span v-else-if="closed">{{ $t('closed') }}</span>
|
||||||
|
<span v-if="remaining > 0"> · {{ timer }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -28,6 +30,7 @@ export default Vue.extend({
|
||||||
props: ['note'],
|
props: ['note'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
remaining: -1,
|
||||||
showResult: false
|
showResult: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -38,19 +41,43 @@ export default Vue.extend({
|
||||||
total(): number {
|
total(): number {
|
||||||
return sum(this.poll.choices.map(x => x.votes));
|
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 {
|
isVoted(): boolean {
|
||||||
return this.poll.choices.some(c => c.isVoted);
|
return !this.poll.multiple && this.poll.choices.some(c => c.isVoted);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.showResult = this.isVoted;
|
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: {
|
methods: {
|
||||||
toggleShowResult() {
|
toggleShowResult() {
|
||||||
this.showResult = !this.showResult;
|
this.showResult = !this.showResult;
|
||||||
},
|
},
|
||||||
vote(id) {
|
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', {
|
this.$root.api('notes/polls/vote', {
|
||||||
noteId: this.note.id,
|
noteId: this.note.id,
|
||||||
choice: id
|
choice: id
|
||||||
|
@ -61,7 +88,7 @@ export default Vue.extend({
|
||||||
Vue.set(c, 'isVoted', true);
|
Vue.set(c, 'isVoted', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.showResult = true;
|
this.showResult = !this.poll.multiple;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -114,7 +141,7 @@ export default Vue.extend({
|
||||||
a
|
a
|
||||||
color inherit
|
color inherit
|
||||||
|
|
||||||
&[data-is-voted]
|
&[data-done]
|
||||||
> ul > li
|
> ul > li
|
||||||
cursor default
|
cursor default
|
||||||
|
|
||||||
|
|
|
@ -366,6 +366,9 @@ root(fill)
|
||||||
&[type='file']
|
&[type='file']
|
||||||
display none
|
display none
|
||||||
|
|
||||||
|
&[type='number']
|
||||||
|
text-align right
|
||||||
|
|
||||||
> .prefix
|
> .prefix
|
||||||
> .suffix
|
> .suffix
|
||||||
display block
|
display block
|
||||||
|
|
|
@ -115,6 +115,8 @@ export default Vue.extend({
|
||||||
uploadings: [],
|
uploadings: [],
|
||||||
poll: false,
|
poll: false,
|
||||||
pollChoices: [],
|
pollChoices: [],
|
||||||
|
pollMultiple: false,
|
||||||
|
pollExpiration: [],
|
||||||
useCw: false,
|
useCw: false,
|
||||||
cw: null,
|
cw: null,
|
||||||
geo: null,
|
geo: null,
|
||||||
|
@ -295,7 +297,10 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
onPollUpdate() {
|
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();
|
this.saveDraft();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,7 @@ export default Vue.extend({
|
||||||
files: [],
|
files: [],
|
||||||
poll: false,
|
poll: false,
|
||||||
pollChoices: [],
|
pollChoices: [],
|
||||||
|
pollMultiple: false,
|
||||||
geo: null,
|
geo: null,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
visibleUsers: [],
|
visibleUsers: [],
|
||||||
|
@ -273,7 +274,9 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
onPollUpdate() {
|
onPollUpdate() {
|
||||||
this.pollChoices = this.$refs.poll.get().choices;
|
const got = this.$refs.poll.get();
|
||||||
|
this.pollChoices = got.choices;
|
||||||
|
this.pollMultiple = got.multiple;
|
||||||
},
|
},
|
||||||
|
|
||||||
upload(file) {
|
upload(file) {
|
||||||
|
|
|
@ -99,7 +99,9 @@ export type INote = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IPoll = {
|
export type IPoll = {
|
||||||
choices: IChoice[]
|
choices: IChoice[];
|
||||||
|
multiple?: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IChoice = {
|
export type IChoice = {
|
||||||
|
@ -313,15 +315,31 @@ export const pack = async (
|
||||||
// Poll
|
// Poll
|
||||||
if (meId && _note.poll) {
|
if (meId && _note.poll) {
|
||||||
_note.poll = (async 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
|
const vote = await PollVote
|
||||||
.findOne({
|
.findOne({
|
||||||
userId: meId,
|
userId: meId,
|
||||||
noteId: id
|
noteId: id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (vote != null) {
|
if (vote) {
|
||||||
const myChoice = poll.choices
|
const myChoice = (poll.choices as IChoice[])
|
||||||
.filter((c: any) => c.id == vote.choice)[0];
|
.filter(x => x.id == vote.choice)[0] as any;
|
||||||
|
|
||||||
myChoice.isVoted = true;
|
myChoice.isVoted = true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,10 @@ import * as mongo from 'mongodb';
|
||||||
import db from '../db/mongodb';
|
import db from '../db/mongodb';
|
||||||
|
|
||||||
const PollVote = db.get<IPollVote>('pollVotes');
|
const PollVote = db.get<IPollVote>('pollVotes');
|
||||||
|
PollVote.dropIndex(['userId', 'noteId'], { unique: true }).catch(() => {});
|
||||||
PollVote.createIndex('userId');
|
PollVote.createIndex('userId');
|
||||||
PollVote.createIndex('noteId');
|
PollVote.createIndex('noteId');
|
||||||
PollVote.createIndex(['userId', 'noteId'], { unique: true });
|
PollVote.createIndex(['userId', 'noteId', 'choice'], { unique: true });
|
||||||
export default PollVote;
|
export default PollVote;
|
||||||
|
|
||||||
export interface IPollVote {
|
export interface IPollVote {
|
||||||
|
|
|
@ -6,10 +6,15 @@ import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-
|
||||||
import Instance from '../../../models/instance';
|
import Instance from '../../../models/instance';
|
||||||
import instanceChart from '../../../services/chart/instance';
|
import instanceChart from '../../../services/chart/instance';
|
||||||
|
|
||||||
|
let latest: string = null;
|
||||||
|
|
||||||
export default async (job: bq.Job, done: any): Promise<void> => {
|
export default async (job: bq.Job, done: any): Promise<void> => {
|
||||||
const { host } = new URL(job.data.to);
|
const { host } = new URL(job.data.to);
|
||||||
|
|
||||||
try {
|
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);
|
await request(job.data.user, job.data.to, job.data.content);
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
|
|
|
@ -27,6 +27,10 @@ export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> =>
|
||||||
announceNote(resolver, actor, activity, object as INote);
|
announceNote(resolver, actor, activity, object as INote);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'Question':
|
||||||
|
announceNote(resolver, actor, activity, object as INote);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn(`Unknown announce type: ${object.type}`);
|
logger.warn(`Unknown announce type: ${object.type}`);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Resolver from '../../resolver';
|
import Resolver from '../../resolver';
|
||||||
import { IRemoteUser } from '../../../../models/user';
|
import { IRemoteUser } from '../../../../models/user';
|
||||||
import createNote from './note';
|
|
||||||
import createImage from './image';
|
import createImage from './image';
|
||||||
|
import createNote from './note';
|
||||||
import { ICreate } from '../../type';
|
import { ICreate } from '../../type';
|
||||||
import { apLogger } from '../../logger';
|
import { apLogger } from '../../logger';
|
||||||
|
|
||||||
|
@ -32,6 +32,10 @@ export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => {
|
||||||
createNote(resolver, actor, object);
|
createNote(resolver, actor, object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'Question':
|
||||||
|
createNote(resolver, actor, object);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn(`Unknown type: ${object.type}`);
|
logger.warn(`Unknown type: ${object.type}`);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -24,6 +24,10 @@ export default async (actor: IRemoteUser, activity: IDelete): Promise<void> => {
|
||||||
deleteNote(actor, uri);
|
deleteNote(actor, uri);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'Question':
|
||||||
|
deleteNote(actor, uri);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'Tombstone':
|
case 'Tombstone':
|
||||||
const note = await Note.findOne({ uri });
|
const note = await Note.findOne({ uri });
|
||||||
if (note != null) {
|
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> {
|
export async function createNote(value: any, resolver?: Resolver, silent = false): Promise<INote> {
|
||||||
if (resolver == null) resolver = new Resolver();
|
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}`, {
|
logger.error(`invalid note: ${value}`, {
|
||||||
resolver: {
|
resolver: {
|
||||||
history: resolver.getHistory()
|
history: resolver.getHistory()
|
||||||
|
@ -67,6 +67,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
|
||||||
|
|
||||||
const note: INoteActivityStreamsObject = object;
|
const note: INoteActivityStreamsObject = object;
|
||||||
|
|
||||||
|
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
logger.info(`Creating the Note: ${note.id}`);
|
logger.info(`Creating the Note: ${note.id}`);
|
||||||
|
|
||||||
// 投稿者をフェッチ
|
// 投稿者をフェッチ
|
||||||
|
@ -78,6 +80,9 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region Visibility
|
//#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 visibility = 'public';
|
||||||
let visibleUsers: IUser[] = [];
|
let visibleUsers: IUser[] = [];
|
||||||
if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) {
|
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';
|
visibility = 'specified';
|
||||||
visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver)));
|
visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//#endergion
|
//#endergion
|
||||||
|
|
||||||
const apMentions = await extractMentionedUsers(actor, note.to, note.cc, resolver);
|
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は必ずしも配列ではない
|
// TODO: attachmentは必ずしも配列ではない
|
||||||
// Noteがsensitiveなら添付もsensitiveにする
|
// Noteがsensitiveなら添付もsensitiveにする
|
||||||
const limit = promiseLimit(2);
|
const limit = promiseLimit(2);
|
||||||
|
|
||||||
|
note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
|
||||||
const files = note.attachment
|
const files = note.attachment
|
||||||
.map(attach => attach.sensitive = note.sensitive)
|
.map(attach => attach.sensitive = note.sensitive)
|
||||||
? await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<IDriveFile>))
|
? 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 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
|
// vote
|
||||||
if (reply && reply.poll && text != null) {
|
if (reply && reply.poll) {
|
||||||
const m = text.match(/([0-9])$/);
|
const tryCreateVote = async (name: string, index: number): Promise<null> => {
|
||||||
if (m) {
|
if (reply.poll.expiresAt && Date.now() > new Date(reply.poll.expiresAt).getTime()) {
|
||||||
logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${m[0]}`);
|
logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
|
||||||
await vote(actor, reply, Number(m[1]));
|
} 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;
|
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 apEmojis = emojis.map(emoji => emoji.name);
|
||||||
|
|
||||||
const questionUri = note._misskey_question;
|
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) {
|
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, {
|
return await post(actor, {
|
||||||
createdAt: new Date(note.published),
|
createdAt: new Date(note.published),
|
||||||
files: files,
|
files,
|
||||||
reply,
|
reply,
|
||||||
renote: quote,
|
renote: quote,
|
||||||
cw: cw,
|
cw,
|
||||||
text: text,
|
text,
|
||||||
viaMobile: false,
|
viaMobile: false,
|
||||||
localOnly: false,
|
localOnly: false,
|
||||||
geo: undefined,
|
geo: undefined,
|
||||||
|
|
|
@ -1,19 +1,38 @@
|
||||||
import { IChoice, IPoll } from '../../../models/note';
|
import { IChoice, IPoll } from '../../../models/note';
|
||||||
import Resolver from '../resolver';
|
import Resolver from '../resolver';
|
||||||
|
import { ICollection } from '../type';
|
||||||
|
|
||||||
export async function extractPollFromQuestion(questionUri: string): Promise<IPoll> {
|
interface IQuestionChoice {
|
||||||
const resolver = new Resolver();
|
name?: string;
|
||||||
const question = await resolver.resolve(questionUri) as any;
|
replies?: ICollection;
|
||||||
|
_misskey_votes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const choices: IChoice[] = question.oneOf.map((x: any, i: number) => {
|
interface IQuestion {
|
||||||
return {
|
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,
|
id: i,
|
||||||
text: x.name,
|
text: x.name,
|
||||||
votes: x._misskey_votes || 0,
|
votes: x.replies && x.replies.totalItems || x._misskey_votes || 0,
|
||||||
} as IChoice;
|
} as IChoice));
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
choices
|
choices,
|
||||||
|
multiple,
|
||||||
|
expiresAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,9 +15,10 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
|
||||||
: Promise.resolve([]);
|
: Promise.resolve([]);
|
||||||
|
|
||||||
let inReplyTo;
|
let inReplyTo;
|
||||||
|
let inReplyToNote: INote;
|
||||||
|
|
||||||
if (note.replyId) {
|
if (note.replyId) {
|
||||||
const inReplyToNote = await Note.findOne({
|
inReplyToNote = await Note.findOne({
|
||||||
_id: note.replyId,
|
_id: note.replyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -134,6 +135,29 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
|
||||||
...apemojis,
|
...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 {
|
return {
|
||||||
id: `${config.url}/notes/${note._id}`,
|
id: `${config.url}/notes/${note._id}`,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
|
@ -149,7 +173,8 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
attachment: files.map(renderDocument),
|
attachment: files.map(renderDocument),
|
||||||
sensitive: files.some(file => file.metadata.isSensitive),
|
sensitive: files.some(file => file.metadata.isSensitive),
|
||||||
tag
|
tag,
|
||||||
|
...asPoll
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,13 +7,15 @@ export default async function renderQuestion(user: ILocalUser, note: INote) {
|
||||||
type: 'Question',
|
type: 'Question',
|
||||||
id: `${config.url}/questions/${note._id}`,
|
id: `${config.url}/questions/${note._id}`,
|
||||||
actor: `${config.url}/users/${user._id}`,
|
actor: `${config.url}/users/${user._id}`,
|
||||||
content: note.text != null ? note.text : '',
|
content: note.text || '',
|
||||||
oneOf: note.poll.choices.map(c => {
|
[note.poll.multiple ? 'anyOf' : 'oneOf']: note.poll.choices.map(c => ({
|
||||||
return {
|
|
||||||
name: c.text,
|
name: c.text,
|
||||||
_misskey_votes: c.votes,
|
_misskey_votes: c.votes,
|
||||||
};
|
replies: {
|
||||||
}),
|
type: 'Collection',
|
||||||
|
totalItems: c.votes
|
||||||
|
}
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
return question;
|
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;
|
attributedTo: string;
|
||||||
attachment?: any[];
|
attachment?: any[];
|
||||||
inReplyTo?: any;
|
inReplyTo?: any;
|
||||||
|
replies?: ICollection;
|
||||||
content: string;
|
content: string;
|
||||||
|
name?: string;
|
||||||
|
startTime?: Date;
|
||||||
|
endTime?: Date;
|
||||||
icon?: any;
|
icon?: any;
|
||||||
image?: any;
|
image?: any;
|
||||||
url?: string;
|
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);
|
const note = await createNote(object.id);
|
||||||
return {
|
return {
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
|
|
|
@ -165,7 +165,10 @@ export const meta = {
|
||||||
choices: $.arr($.str)
|
choices: $.arr($.str)
|
||||||
.unique()
|
.unique()
|
||||||
.range(2, 10)
|
.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(),
|
}).strict(),
|
||||||
desc: {
|
desc: {
|
||||||
'ja-JP': 'アンケート'
|
'ja-JP': 'アンケート'
|
||||||
|
@ -214,6 +217,12 @@ export const meta = {
|
||||||
code: 'CONTENT_REQUIRED',
|
code: 'CONTENT_REQUIRED',
|
||||||
id: '6f57e42b-c348-439b-bc45-993995cc515a'
|
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(),
|
text: choice.trim(),
|
||||||
votes: 0
|
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も無いかつ投票も無かったらエラー
|
// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
|
||||||
|
@ -291,7 +307,11 @@ export default define(meta, async (ps, user, app) => {
|
||||||
const note = await create(user, {
|
const note = await create(user, {
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
files: files,
|
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,
|
text: ps.text,
|
||||||
reply,
|
reply,
|
||||||
renote,
|
renote,
|
||||||
|
|
|
@ -7,10 +7,13 @@ import watch from '../../../../../services/note/watch';
|
||||||
import { publishNoteStream } from '../../../../../services/stream';
|
import { publishNoteStream } from '../../../../../services/stream';
|
||||||
import notify from '../../../../../services/create-notification';
|
import notify from '../../../../../services/create-notification';
|
||||||
import define from '../../../define';
|
import define from '../../../define';
|
||||||
import createNote from '../../../../../services/note/create';
|
import User, { IRemoteUser } from '../../../../../models/user';
|
||||||
import User from '../../../../../models/user';
|
|
||||||
import { ApiError } from '../../../error';
|
import { ApiError } from '../../../error';
|
||||||
import { getNote } from '../../../common/getters';
|
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 = {
|
export const meta = {
|
||||||
desc: {
|
desc: {
|
||||||
|
@ -63,10 +66,18 @@ export const meta = {
|
||||||
code: 'ALREADY_VOTED',
|
code: 'ALREADY_VOTED',
|
||||||
id: '0963fc77-efac-419b-9424-b391608dc6d8'
|
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) => {
|
export default define(meta, async (ps, user) => {
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
// Get votee
|
// Get votee
|
||||||
const note = await getNote(ps.noteId).catch(e => {
|
const note = await getNote(ps.noteId).catch(e => {
|
||||||
if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
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);
|
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)) {
|
if (!note.poll.choices.some(x => x.id == ps.choice)) {
|
||||||
throw new ApiError(meta.errors.invalidChoice);
|
throw new ApiError(meta.errors.invalidChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if already voted
|
// if already voted
|
||||||
const exist = await Vote.findOne({
|
const exist = await Vote.find({
|
||||||
noteId: note._id,
|
noteId: note._id,
|
||||||
userId: user._id
|
userId: user._id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exist !== null) {
|
if (exist.length) {
|
||||||
|
if (note.poll.multiple) {
|
||||||
|
if (exist.some(x => x.choice == ps.choice))
|
||||||
throw new ApiError(meta.errors.alreadyVoted);
|
throw new ApiError(meta.errors.alreadyVoted);
|
||||||
|
} else {
|
||||||
|
throw new ApiError(meta.errors.alreadyVoted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create vote
|
// Create vote
|
||||||
await Vote.insert({
|
const vote = await Vote.insert({
|
||||||
createdAt: new Date(),
|
createdAt,
|
||||||
noteId: note._id,
|
noteId: note._id,
|
||||||
userId: user._id,
|
userId: user._id,
|
||||||
choice: ps.choice
|
choice: ps.choice
|
||||||
|
@ -146,17 +166,11 @@ export default define(meta, async (ps, user) => {
|
||||||
|
|
||||||
// リモート投票の場合リプライ送信
|
// リモート投票の場合リプライ送信
|
||||||
if (note._user.host != null) {
|
if (note._user.host != null) {
|
||||||
const pollOwner = await User.findOne({
|
const pollOwner: IRemoteUser = await User.findOne({
|
||||||
_id: note.userId
|
_id: note.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
createNote(user, {
|
deliver(user, renderActivity(await renderVote(user, vote, note, pollOwner)), pollOwner.inbox);
|
||||||
createdAt: new Date(),
|
|
||||||
text: ps.choice.toString(),
|
|
||||||
reply: note,
|
|
||||||
visibility: 'specified',
|
|
||||||
visibleUsers: [ pollOwner ],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -25,6 +25,7 @@ import notesChart from '../../services/chart/notes';
|
||||||
import perUserNotesChart from '../../services/chart/per-user-notes';
|
import perUserNotesChart from '../../services/chart/per-user-notes';
|
||||||
import activeUsersChart from '../../services/chart/active-users';
|
import activeUsersChart from '../../services/chart/active-users';
|
||||||
import instanceChart from '../../services/chart/instance';
|
import instanceChart from '../../services/chart/instance';
|
||||||
|
import * as deepcopy from 'deepcopy';
|
||||||
|
|
||||||
import { erase, concat } from '../../prelude/array';
|
import { erase, concat } from '../../prelude/array';
|
||||||
import insertNoteUnread from './unread';
|
import insertNoteUnread from './unread';
|
||||||
|
@ -596,6 +597,22 @@ async function publishToFollowers(note: INote, user: IUser, noteActivity: any) {
|
||||||
for (const inbox of queue) {
|
for (const inbox of queue) {
|
||||||
deliver(user as any, noteActivity, inbox);
|
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) {
|
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 (!note.poll.choices.some(x => x.id == choice)) return rej('invalid choice param');
|
||||||
|
|
||||||
// if already voted
|
// if already voted
|
||||||
const exist = await Vote.findOne({
|
const exist = await Vote.find({
|
||||||
noteId: note._id,
|
noteId: note._id,
|
||||||
userId: user._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');
|
return rej('already voted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
91
test/api.ts
91
test/api.ts
|
@ -450,6 +450,97 @@ describe('API', () => {
|
||||||
expect(res).have.status(400);
|
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 () => {
|
it('同じユーザーに複数メンションしても内部的にまとめられる', async(async () => {
|
||||||
const alice = await signup({ username: 'alice' });
|
const alice = await signup({ username: 'alice' });
|
||||||
const bob = await signup({ username: 'bob' });
|
const bob = await signup({ username: 'bob' });
|
||||||
|
|
|
@ -24,12 +24,14 @@
|
||||||
"triple-equals": [false],
|
"triple-equals": [false],
|
||||||
"no-shadowed-variable": false,
|
"no-shadowed-variable": false,
|
||||||
"no-string-literal": false,
|
"no-string-literal": false,
|
||||||
|
"no-conditional-assignment": false,
|
||||||
"variable-name": [false],
|
"variable-name": [false],
|
||||||
"comment-format": [false],
|
"comment-format": [false],
|
||||||
"interface-over-type-literal": false,
|
"interface-over-type-literal": false,
|
||||||
"max-line-length": [false],
|
"max-line-length": [false],
|
||||||
"max-classes-per-file": false,
|
"max-classes-per-file": false,
|
||||||
"member-ordering": [false],
|
"member-ordering": [false],
|
||||||
|
"radix": false,
|
||||||
"ban-types": [
|
"ban-types": [
|
||||||
true,
|
true,
|
||||||
"Object"
|
"Object"
|
||||||
|
|
Loading…
Reference in a new issue