upd: add notification for failures, add reasons for failure, apply suggestions

This commit is contained in:
Marie 2024-11-03 17:59:50 +01:00
parent f08d1ec38f
commit fc9d777dc3
No known key found for this signature in database
GPG key ID: 7ADF6C9CD9A28555
17 changed files with 110 additions and 28 deletions

4
locales/index.d.ts vendored
View file

@ -9558,6 +9558,10 @@ export interface Locale extends ILocale {
* Note got edited * Note got edited
*/ */
"edited": string; "edited": string;
/**
* Posting scheduled note failed
*/
"scheduledNoteFailed": string;
}; };
"_deck": { "_deck": {
/** /**

View file

@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]); const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNoteFailed'] as (typeof groupedNotificationTypes[number])[]);
@Injectable() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {
@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity, exportedEntity: notification.exportedEntity,
fileId: notification.fileId, fileId: notification.fileId,
} : {}), } : {}),
...(notification.type === 'scheduledNoteFailed' ? {
reason: notification.reason,
} : {}),
...(notification.type === 'app' ? { ...(notification.type === 'app' ? {
body: notification.customBody, body: notification.customBody,
header: notification.customHeader, header: notification.customHeader,

View file

@ -18,8 +18,6 @@ type MinimumUser = {
}; };
export type MiScheduleNoteType={ export type MiScheduleNoteType={
/** Date.toISOString() */
createdAt: string;
visibility: 'public' | 'home' | 'followers' | 'specified'; visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUsers: MinimumUser[]; visibleUsers: MinimumUser[];
channel?: MiChannel['id']; channel?: MiChannel['id'];

View file

@ -122,6 +122,11 @@ export type MiNotification = {
createdAt: string; createdAt: string;
notifierId: MiUser['id']; notifierId: MiUser['id'];
noteId: MiNote['id']; noteId: MiNote['id'];
} | {
type: 'scheduledNoteFailed';
id: string;
createdAt: string;
reason: string;
}; };
export type MiGroupedNotification = MiNotification | { export type MiGroupedNotification = MiNotification | {

View file

@ -369,6 +369,20 @@ export const packedNotificationSchema = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['scheduledNoteFailed'],
},
reason: {
type: 'string',
optional: false, nullable: false,
},
},
}, { }, {
type: 'object', type: 'object',
properties: { properties: {

View file

@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { ScheduleNotePostJobData } from '../types.js'; import type { ScheduleNotePostJobData } from '../types.js';
@ -32,6 +33,7 @@ export class ScheduleNotePostProcessorService {
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post'); this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
} }
@ -50,8 +52,9 @@ export class ScheduleNotePostProcessorService {
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined; const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined; const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
let files: MiDriveFile[] = []; let files: MiDriveFile[] = [];
const fileIds = note.files ?? null; const fileIds = note.files;
if (fileIds != null && fileIds.length > 0 && me) {
if (fileIds.length > 0 && me) {
files = await this.driveFilesRepository.createQueryBuilder('file') files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', { .where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id, userId: me.id,
@ -61,22 +64,52 @@ export class ScheduleNotePostProcessorService {
.setParameters({ fileIds }) .setParameters({ fileIds })
.getMany(); .getMany();
} }
if (
!data.userId || if (!data.userId || !me) {
!me || this.logger.warn('Schedule Note Failed Reason: User Not Found');
(note.reply && !reply) ||
(note.renote && !renote) ||
(note.channel && !channel) ||
(note.files.length !== files.length)
) {
//キューに積んだときは有った物が消滅してたら予約投稿をキャンセルする
this.logger.warn('cancel schedule note');
await this.noteScheduleRepository.remove(data); await this.noteScheduleRepository.remove(data);
return; return;
} }
if (note.files.length !== files.length) {
this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: 'Some attached files on your scheduled note no longer exist',
});
await this.noteScheduleRepository.remove(data);
return;
}
if (note.reply && !reply) {
this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: 'Replied to note on your scheduled note no longer exists',
});
await this.noteScheduleRepository.remove(data);
return;
}
if (note.renote && !renote) {
this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: 'A quoted note from one of your scheduled notes no longer exists',
});
await this.noteScheduleRepository.remove(data);
return;
}
if (note.channel && !channel) {
this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
reason: 'An attached channel on your scheduled note no longer exists',
});
await this.noteScheduleRepository.remove(data);
return;
}
await this.noteCreateService.create(me, { await this.noteCreateService.create(me, {
...note, ...note,
createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す createdAt: new Date(),
files, files,
poll: note.poll ? { poll: note.poll ? {
choices: note.poll.choices, choices: note.poll.choices,

View file

@ -292,7 +292,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Check blocking // Check blocking
if (reply.userId !== me.id) { if (reply.userId !== me.id) {
const blockExist = await this.blockingsRepository.exist({ const blockExist = await this.blockingsRepository.exists({
where: { where: {
blockerId: reply.userId, blockerId: reply.userId,
blockeeId: me.id, blockeeId: me.id,
@ -324,8 +324,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else { } else {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
} }
const note:MiScheduleNoteType = { const note: MiScheduleNoteType = {
createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(),
files: files.map(f => f.id), files: files.map(f => f.id),
poll: ps.poll ? { poll: ps.poll ? {
choices: ps.poll.choices, choices: ps.poll.choices,

View file

@ -35,6 +35,7 @@ export const notificationTypes = [
'roleAssigned', 'roleAssigned',
'achievementEarned', 'achievementEarned',
'exportCompleted', 'exportCompleted',
'scheduledNoteFailed',
'app', 'app',
'test', 'test',
] as const; ] as const;

View file

@ -131,6 +131,7 @@ export const notificationTypes = [
'test', 'test',
'app', 'app',
'edited', 'edited',
'scheduledNoteFailed',
] as const; ] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.head"> <div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
[$style.t_pollEnded]: notification.type === 'edited', [$style.t_pollEnded]: notification.type === 'edited',
[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
}]" }]"
> <!-- we re-use t_pollEnded for "edited" instead of making an identical style --> > <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i> <i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
@ -46,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ti ti-badges"></i> <i v-else class="ti ti-badges"></i>
</template> </template>
<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i> <i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon <MkReactionIcon
v-else-if="notification.type === 'reaction'" v-else-if="notification.type === 'reaction'"
@ -70,6 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span> <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span> <span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span> <span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header> </header>
<div> <div>
@ -109,6 +112,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
{{ i18n.ts.showFile }} {{ i18n.ts.showFile }}
</MkA> </MkA>
<div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text">
{{ notification.reason }}
</div>
<template v-else-if="notification.type === 'follow'"> <template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template> </template>

View file

@ -1046,8 +1046,9 @@ function openAccountMenu(ev: MouseEvent) {
} }
function toggleScheduleNote() { function toggleScheduleNote() {
if (scheduleNote.value) scheduleNote.value = null; if (scheduleNote.value) {
else { scheduleNote.value = null;
} else {
scheduleNote.value = { scheduleNote.value = {
scheduledAt: null, scheduledAt: null,
}; };

View file

@ -48,6 +48,7 @@ const cancel = () => {
emit('cancel'); emit('cancel');
dialogEl.value.close(); dialogEl.value.close();
}; };
const paginationEl = ref(); const paginationEl = ref();
const pagination: Paging = { const pagination: Paging = {
endpoint: 'notes/schedule/list', endpoint: 'notes/schedule/list',

View file

@ -2890,7 +2890,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited"]; export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed"];
// @public (undocumented) // @public (undocumented)
export function nyaize(text: string): string; export function nyaize(text: string): string;

View file

@ -4517,6 +4517,14 @@ export type components = {
/** Format: id */ /** Format: id */
userId: string; userId: string;
note: components['schemas']['Note']; note: components['schemas']['Note'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNoteFailed';
reason: string;
} | { } | {
/** Format: id */ /** Format: id */
id: string; id: string;
@ -19984,8 +19992,8 @@ export type operations = {
untilId?: string; untilId?: string;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };
@ -20052,8 +20060,8 @@ export type operations = {
untilId?: string; untilId?: string;
/** @default true */ /** @default true */
markAsRead?: boolean; markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
}; };
}; };
}; };

View file

@ -16,7 +16,7 @@ import type {
UserLite, UserLite,
} from './autogen/models.js'; } from './autogen/models.js';
export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited'] as const; export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View file

@ -258,6 +258,13 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
data, data,
}]; }];
case 'scheduledNoteFailed':
return [i18n.ts._notification.scheduledNoteFailed, {
body: data.body.reason,
badge: iconUrl('bell'),
data,
}];
default: default:
return null; return null;
} }

View file

@ -277,6 +277,7 @@ _notification:
youRenoted: "Boost from {name}" youRenoted: "Boost from {name}"
renotedBySomeUsers: "Boosted by {n} users" renotedBySomeUsers: "Boosted by {n} users"
edited: "Note got edited" edited: "Note got edited"
scheduledNoteFailed: "Posting scheduled note failed"
_types: _types:
renote: "Boosts" renote: "Boosts"
edited: "Edits" edited: "Edits"