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:
Acid Chicken (硫酸鶏) 2019-03-06 22:55:47 +09:00 committed by syuilo
parent f74a32ed9b
commit 725600da8f
34 changed files with 505 additions and 86 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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é"

View File

@ -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: "リアクションを選択"

View File

@ -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: "投票済みや"

View File

@ -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: "투표함"

View File

@ -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"

View File

@ -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"

View File

@ -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: "已投票"

View File

@ -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() {
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 { return {
choices: erase('', this.choices) 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>

View File

@ -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

View File

@ -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

View File

@ -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();
}, },

View File

@ -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) {

View 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;
} }

View File

@ -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 {

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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,

View File

@ -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[];
id: i, anyOf?: IQuestionChoice[];
text: x.name, endTime?: Date;
votes: x._misskey_votes || 0, }
} as IChoice;
}); 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 { return {
choices choices,
multiple,
expiresAt
}; };
} }

View File

@ -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
}; };
} }

View File

@ -3,17 +3,19 @@ import { ILocalUser } from '../../../models/user';
import { INote } from '../../../models/note'; import { INote } from '../../../models/note';
export default async function renderQuestion(user: ILocalUser, note: INote) { export default async function renderQuestion(user: ILocalUser, note: INote) {
const question = { const question = {
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;

View 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
}
};
}

View File

@ -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;

View File

@ -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',

View File

@ -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,

View File

@ -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) {
throw new ApiError(meta.errors.alreadyVoted); 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 // 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;

View File

@ -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) {

View File

@ -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');
} }

View File

@ -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' });

View File

@ -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"