parent
8bb586c1fd
commit
2442592ef1
14 changed files with 108 additions and 10 deletions
|
@ -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
|
||||||
|
|
|
@ -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: "グループに招待された"
|
||||||
|
|
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 - グループに招待された
|
||||||
|
|
|
@ -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!),
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -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');
|
||||||
|
|
|
@ -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'];
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in a new issue