0
0

feat: アンケート終了通知

Resolve #4664
This commit is contained in:
syuilo 2022-03-06 16:06:27 +09:00
parent 8bb586c1fd
commit 2442592ef1
14 changed files with 108 additions and 10 deletions

View File

@ -21,6 +21,7 @@ You should also include the user name that made the change.
### Improvements ### Improvements
- インスタンスデフォルトテーマを設定できるように @syuilo - インスタンスデフォルトテーマを設定できるように @syuilo
- ミュートに期限を設定できるように @syuilo - ミュートに期限を設定できるように @syuilo
- アンケートが終了したときに通知が作成されるように @syuilo
- プロフィールの追加情報を最大16まで保存できるように @syuilo - プロフィールの追加情報を最大16まで保存できるように @syuilo
- 連合チャートにPub&Subを追加 @syuilo - 連合チャートにPub&Subを追加 @syuilo
- デフォルトで10秒以上時間がかかるデータベースへのクエリは中断されるように @syuilo - デフォルトで10秒以上時間がかかるデータベースへのクエリは中断されるように @syuilo

View File

@ -1667,6 +1667,7 @@ _notification:
youReceivedFollowRequest: "フォローリクエストが来ました" youReceivedFollowRequest: "フォローリクエストが来ました"
yourFollowRequestAccepted: "フォローリクエストが承認されました" yourFollowRequestAccepted: "フォローリクエストが承認されました"
youWereInvitedToGroup: "グループに招待されました" youWereInvitedToGroup: "グループに招待されました"
pollEnded: "アンケートの結果が出ました"
_types: _types:
all: "すべて" all: "すべて"
@ -1677,6 +1678,7 @@ _notification:
quote: "引用" quote: "引用"
reaction: "リアクション" reaction: "リアクション"
pollVote: "アンケートに投票された" pollVote: "アンケートに投票された"
pollEnded: "アンケートが終了"
receiveFollowRequest: "フォロー申請を受け取った" receiveFollowRequest: "フォロー申請を受け取った"
followRequestAccepted: "フォローが受理された" followRequestAccepted: "フォローが受理された"
groupInvited: "グループに招待された" groupInvited: "グループに招待された"

View File

@ -0,0 +1,18 @@
export class pollEndedNotification1646549089451 {
name = 'pollEndedNotification1646549089451'
async up(queryRunner) {
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
}
}

View File

@ -59,7 +59,8 @@ export class Notification {
* renote - (Watchしている)稿Renoteされた * renote - (Watchしている)稿Renoteされた
* quote - (Watchしている)稿Renoteされた * quote - (Watchしている)稿Renoteされた
* reaction - (Watchしている)稿 * reaction - (Watchしている)稿
* pollVote - (Watchしている)稿 * pollVote - (Watchしている)稿
* pollEnded -
* receiveFollowRequest - * receiveFollowRequest -
* followRequestAccepted - * followRequestAccepted -
* groupInvited - * groupInvited -

View File

@ -67,6 +67,12 @@ export class NotificationRepository extends Repository<Notification> {
}), }),
choice: notification.choice, choice: notification.choice,
} : {}), } : {}),
...(notification.type === 'pollEnded' ? {
note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'groupInvited' ? { ...(notification.type === 'groupInvited' ? {
invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!),
} : {}), } : {}),

View File

@ -8,10 +8,11 @@ import processInbox from './processors/inbox.js';
import processDb from './processors/db/index.js'; import processDb from './processors/db/index.js';
import processObjectStorage from './processors/object-storage/index.js'; import processObjectStorage from './processors/object-storage/index.js';
import processSystemQueue from './processors/system/index.js'; import processSystemQueue from './processors/system/index.js';
import { endedPollNotification } from './processors/ended-poll-notification.js';
import { queueLogger } from './logger.js'; import { queueLogger } from './logger.js';
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { getJobInfo } from './get-job-info.js'; import { getJobInfo } from './get-job-info.js';
import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue } from './queues.js'; import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue } from './queues.js';
import { ThinUser } from './types.js'; import { ThinUser } from './types.js';
import { IActivity } from '@/remote/activitypub/type.js'; import { IActivity } from '@/remote/activitypub/type.js';
@ -255,6 +256,7 @@ export default function() {
deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver);
inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); inboxQueue.process(config.inboxJobConcurrency || 16, processInbox);
endedPollNotificationQueue.process(endedPollNotification);
processDb(dbQueue); processDb(dbQueue);
processObjectStorage(objectStorageQueue); processObjectStorage(objectStorageQueue);

View File

@ -0,0 +1,33 @@
import Bull from 'bull';
import { In } from 'typeorm';
import { Notes, Polls, PollVotes } from '@/models/index.js';
import { queueLogger } from '../logger.js';
import { EndedPollNotificationJobData } from '@/queue/types.js';
import { createNotification } from '@/services/create-notification.js';
const logger = queueLogger.createSubLogger('ended-poll-notification');
export async function endedPollNotification(job: Bull.Job<EndedPollNotificationJobData>, done: any): Promise<void> {
const note = await Notes.findOne(job.data.noteId);
if (note == null || !note.hasPoll) {
done();
return;
}
const votes = await PollVotes.createQueryBuilder('vote')
.select('vote.userId')
.where('vote.noteId = :noteId', { noteId: note.id })
.innerJoinAndSelect('vote.user', 'user')
.andWhere('user.host IS NULL')
.getMany();
const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])];
for (const userId of userIds) {
createNotification(userId, 'pollEnded', {
noteId: note.id,
});
}
done();
}

View File

@ -1,8 +1,9 @@
import config from '@/config/index.js'; import config from '@/config/index.js';
import { initialize as initializeQueue } from './initialize.js'; import { initialize as initializeQueue } from './initialize.js';
import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData } from './types.js'; import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData } from './types.js';
export const systemQueue = initializeQueue<Record<string, unknown>>('system'); export const systemQueue = initializeQueue<Record<string, unknown>>('system');
export const endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification');
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128); export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128);
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16); export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
export const dbQueue = initializeQueue<DbJobData>('db'); export const dbQueue = initializeQueue<DbJobData>('db');

View File

@ -1,4 +1,5 @@
import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFile } from '@/models/entities/drive-file.js';
import { Note } from '@/models/entities/note';
import { User } from '@/models/entities/user.js'; import { User } from '@/models/entities/user.js';
import { IActivity } from '@/remote/activitypub/type.js'; import { IActivity } from '@/remote/activitypub/type.js';
import httpSignature from 'http-signature'; import httpSignature from 'http-signature';
@ -41,6 +42,10 @@ export type ObjectStorageFileJobData = {
key: string; key: string;
}; };
export type EndedPollNotificationJobData = {
noteId: Note['id'];
};
export type ThinUser = { export type ThinUser = {
id: User['id']; id: User['id'];
}; };

View File

@ -34,6 +34,7 @@ import { deliverToRelays } from '../relay.js';
import { Channel } from '@/models/entities/channel.js'; import { Channel } from '@/models/entities/channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { getAntennas } from '@/misc/antenna-cache.js'; import { getAntennas } from '@/misc/antenna-cache.js';
import { endedPollNotificationQueue } from '@/queue/queues.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -296,6 +297,15 @@ export default async (user: { id: User['id']; username: User['username']; host:
incRenoteCount(data.renote); incRenoteCount(data.renote);
} }
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
endedPollNotificationQueue.add({
noteId: note.id,
}, {
delay
});
}
if (!silent) { if (!silent) {
if (Users.isLocalUser(user)) activeUsersChart.write(user); if (Users.isLocalUser(user)) activeUsersChart.write(user);

View File

@ -1,4 +1,4 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View File

@ -1,7 +1,8 @@
<template> <template>
<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type"> <div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type">
<div class="head"> <div class="head">
<MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> <MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/>
<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
<div class="sub-icon" :class="notification.type"> <div class="sub-icon" :class="notification.type">
<i v-if="notification.type === 'follow'" class="fas fa-plus"></i> <i v-if="notification.type === 'follow'" class="fas fa-plus"></i>
@ -13,6 +14,7 @@
<i v-else-if="notification.type === 'mention'" class="fas fa-at"></i> <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i>
<i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i> <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
<i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
<i v-else-if="notification.type === 'pollEnded'" class="fas fa-poll-h"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<XReactionIcon v-else-if="notification.type === 'reaction'" <XReactionIcon v-else-if="notification.type === 'reaction'"
ref="reactionRef" ref="reactionRef"
@ -24,7 +26,8 @@
</div> </div>
<div class="tail"> <div class="tail">
<header> <header>
<MkA v-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else>{{ notification.header }}</span> <span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" class="time"/> <MkTime v-if="withTime" :time="notification.createdAt" class="time"/>
</header> </header>
@ -52,6 +55,11 @@
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="fas fa-quote-right"></i> <i class="fas fa-quote-right"></i>
</MkA> </MkA>
<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="fas fa-quote-left"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="fas fa-quote-right"></i>
</MkA>
<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span> <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span>
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span> <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span>
@ -169,6 +177,7 @@ export default defineComponent({
rejectGroupInvitation, rejectGroupInvitation,
elRef, elRef,
reactionRef, reactionRef,
i18n,
}; };
}, },
}); });
@ -274,6 +283,12 @@ export default defineComponent({
background: #88a6b7; background: #88a6b7;
pointer-events: none; pointer-events: none;
} }
&.pollEnded {
padding: 3px;
background: #88a6b7;
pointer-events: none;
}
} }
} }

View File

@ -35,19 +35,18 @@ const props = defineProps<{
const pagingComponent = ref<InstanceType<typeof MkPagination>>(); const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
const pagination: Paging = { const pagination: Paging = {
endpoint: 'i/notifications' as const, endpoint: 'i/notifications' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
includeTypes: allIncludeTypes.value || undefined, includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
unreadOnly: props.unreadOnly, unreadOnly: props.unreadOnly,
})), })),
}; };
const onNotification = (notification) => { const onNotification = (notification) => {
const isMuted = !allIncludeTypes.value.includes(notification.type); const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') { if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', { stream.send('readNotification', {
id: notification.id id: notification.id

View File

@ -59,6 +59,11 @@ export default async function(type, data, i18n): Promise<[string, NotificationOp
icon: data.user.avatarUrl icon: data.user.avatarUrl
}]; }];
case 'pollEnded':
return [i18n.t('_notification.pollEnded'), {
body: data.note.text,
}];
case 'follow': case 'follow':
return [i18n.t('_notification.youWereFollowed'), { return [i18n.t('_notification.youWereFollowed'), {
body: getUserName(data.user), body: getUserName(data.user),