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 | ||||
| - インスタンスデフォルトテーマを設定できるように @syuilo | ||||
| - ミュートに期限を設定できるように @syuilo | ||||
| - アンケートが終了したときに通知が作成されるように @syuilo | ||||
| - プロフィールの追加情報を最大16まで保存できるように @syuilo | ||||
| - 連合チャートにPub&Subを追加 @syuilo | ||||
| - デフォルトで10秒以上時間がかかるデータベースへのクエリは中断されるように @syuilo | ||||
|  |  | |||
|  | @ -1667,6 +1667,7 @@ _notification: | |||
|   youReceivedFollowRequest: "フォローリクエストが来ました" | ||||
|   yourFollowRequestAccepted: "フォローリクエストが承認されました" | ||||
|   youWereInvitedToGroup: "グループに招待されました" | ||||
|   pollEnded: "アンケートの結果が出ました" | ||||
| 
 | ||||
|   _types: | ||||
|     all: "すべて" | ||||
|  | @ -1677,6 +1678,7 @@ _notification: | |||
|     quote: "引用" | ||||
|     reaction: "リアクション" | ||||
|     pollVote: "アンケートに投票された" | ||||
|     pollEnded: "アンケートが終了" | ||||
|     receiveFollowRequest: "フォロー申請を受け取った" | ||||
|     followRequestAccepted: "フォローが受理された" | ||||
|     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された | ||||
| 	 * quote - (自分または自分がWatchしている)投稿が引用Renoteされた | ||||
| 	 * reaction - (自分または自分がWatchしている)投稿にリアクションされた | ||||
| 	 * pollVote - (自分または自分がWatchしている)投稿の投票に投票された | ||||
| 	 * pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された | ||||
| 	 * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した | ||||
| 	 * receiveFollowRequest - フォローリクエストされた | ||||
| 	 * followRequestAccepted - 自分の送ったフォローリクエストが承認された | ||||
| 	 * groupInvited - グループに招待された | ||||
|  |  | |||
|  | @ -67,6 +67,12 @@ export class NotificationRepository extends Repository<Notification> { | |||
| 				}), | ||||
| 				choice: notification.choice, | ||||
| 			} : {}), | ||||
| 			...(notification.type === 'pollEnded' ? { | ||||
| 				note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { | ||||
| 					detail: true, | ||||
| 					_hint_: options._hintForEachNotes_, | ||||
| 				}), | ||||
| 			} : {}), | ||||
| 			...(notification.type === 'groupInvited' ? { | ||||
| 				invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), | ||||
| 			} : {}), | ||||
|  |  | |||
|  | @ -8,10 +8,11 @@ import processInbox from './processors/inbox.js'; | |||
| import processDb from './processors/db/index.js'; | ||||
| import processObjectStorage from './processors/object-storage/index.js'; | ||||
| import processSystemQueue from './processors/system/index.js'; | ||||
| import { endedPollNotification } from './processors/ended-poll-notification.js'; | ||||
| import { queueLogger } from './logger.js'; | ||||
| import { DriveFile } from '@/models/entities/drive-file.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 { IActivity } from '@/remote/activitypub/type.js'; | ||||
| 
 | ||||
|  | @ -255,6 +256,7 @@ export default function() { | |||
| 
 | ||||
| 	deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); | ||||
| 	inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); | ||||
| 	endedPollNotificationQueue.process(endedPollNotification); | ||||
| 	processDb(dbQueue); | ||||
| 	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 { 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 endedPollNotificationQueue = initializeQueue<EndedPollNotificationJobData>('endedPollNotification'); | ||||
| export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128); | ||||
| export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16); | ||||
| export const dbQueue = initializeQueue<DbJobData>('db'); | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import { DriveFile } from '@/models/entities/drive-file.js'; | ||||
| import { Note } from '@/models/entities/note'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { IActivity } from '@/remote/activitypub/type.js'; | ||||
| import httpSignature from 'http-signature'; | ||||
|  | @ -41,6 +42,10 @@ export type ObjectStorageFileJobData = { | |||
| 	key: string; | ||||
| }; | ||||
| 
 | ||||
| export type EndedPollNotificationJobData = { | ||||
| 	noteId: Note['id']; | ||||
| }; | ||||
| 
 | ||||
| export type ThinUser = { | ||||
| 	id: User['id']; | ||||
| }; | ||||
|  |  | |||
|  | @ -34,6 +34,7 @@ import { deliverToRelays } from '../relay.js'; | |||
| import { Channel } from '@/models/entities/channel.js'; | ||||
| import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | ||||
| import { getAntennas } from '@/misc/antenna-cache.js'; | ||||
| import { endedPollNotificationQueue } from '@/queue/queues.js'; | ||||
| 
 | ||||
| type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; | ||||
| 
 | ||||
|  | @ -296,6 +297,15 @@ export default async (user: { id: User['id']; username: User['username']; host: | |||
| 		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 (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; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,7 +1,8 @@ | |||
| <template> | ||||
| <div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type"> | ||||
| 	<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=""/> | ||||
| 		<div class="sub-icon" :class="notification.type"> | ||||
| 			<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 === '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 === 'pollEnded'" class="fas fa-poll-h"></i> | ||||
| 			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> | ||||
| 			<XReactionIcon v-else-if="notification.type === 'reaction'" | ||||
| 				ref="reactionRef" | ||||
|  | @ -24,7 +26,8 @@ | |||
| 	</div> | ||||
| 	<div class="tail"> | ||||
| 		<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> | ||||
| 			<MkTime v-if="withTime" :time="notification.createdAt" class="time"/> | ||||
| 		</header> | ||||
|  | @ -52,6 +55,11 @@ | |||
| 			<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> | ||||
| 			<i class="fas fa-quote-right"></i> | ||||
| 		</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 === '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> | ||||
|  | @ -169,6 +177,7 @@ export default defineComponent({ | |||
| 			rejectGroupInvitation, | ||||
| 			elRef, | ||||
| 			reactionRef, | ||||
| 			i18n, | ||||
| 		}; | ||||
| 	}, | ||||
| }); | ||||
|  | @ -274,6 +283,12 @@ export default defineComponent({ | |||
| 				background: #88a6b7; | ||||
| 				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 allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x))); | ||||
| 
 | ||||
| const pagination: Paging = { | ||||
| 	endpoint: 'i/notifications' as const, | ||||
| 	limit: 10, | ||||
| 	params: computed(() => ({ | ||||
| 		includeTypes: allIncludeTypes.value || undefined, | ||||
| 		includeTypes: props.includeTypes ?? undefined, | ||||
| 		excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, | ||||
| 		unreadOnly: props.unreadOnly, | ||||
| 	})), | ||||
| }; | ||||
| 
 | ||||
| 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') { | ||||
| 		stream.send('readNotification', { | ||||
| 			id: notification.id | ||||
|  |  | |||
|  | @ -59,6 +59,11 @@ export default async function(type, data, i18n): Promise<[string, NotificationOp | |||
| 						icon: data.user.avatarUrl | ||||
| 					}]; | ||||
| 
 | ||||
| 				case 'pollEnded': | ||||
| 					return [i18n.t('_notification.pollEnded'), { | ||||
| 						body: data.note.text, | ||||
| 					}]; | ||||
| 
 | ||||
| 				case 'follow': | ||||
| 					return [i18n.t('_notification.youWereFollowed'), { | ||||
| 						body: getUserName(data.user), | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue