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