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
*/
"edited": string;
/**
* Posting scheduled note failed
*/
"scheduledNoteFailed": string;
};
"_deck": {
/**

View file

@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.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()
export class NotificationEntityService implements OnModuleInit {
@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity,
fileId: notification.fileId,
} : {}),
...(notification.type === 'scheduledNoteFailed' ? {
reason: notification.reason,
} : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader,

View file

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

View file

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

View file

@ -369,6 +369,20 @@ export const packedNotificationSchema = {
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',
properties: {

View file

@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { ScheduleNotePostJobData } from '../types.js';
@ -32,6 +33,7 @@ export class ScheduleNotePostProcessorService {
private noteCreateService: NoteCreateService,
private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) {
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 channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
let files: MiDriveFile[] = [];
const fileIds = note.files ?? null;
if (fileIds != null && fileIds.length > 0 && me) {
const fileIds = note.files;
if (fileIds.length > 0 && me) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
@ -61,22 +64,52 @@ export class ScheduleNotePostProcessorService {
.setParameters({ fileIds })
.getMany();
}
if (
!data.userId ||
!me ||
(note.reply && !reply) ||
(note.renote && !renote) ||
(note.channel && !channel) ||
(note.files.length !== files.length)
) {
//キューに積んだときは有った物が消滅してたら予約投稿をキャンセルする
this.logger.warn('cancel schedule note');
if (!data.userId || !me) {
this.logger.warn('Schedule Note Failed Reason: User Not Found');
await this.noteScheduleRepository.remove(data);
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, {
...note,
createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す
createdAt: new Date(),
files,
poll: note.poll ? {
choices: note.poll.choices,

View file

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

View file

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

View file

@ -131,6 +131,7 @@ export const notificationTypes = [
'test',
'app',
'edited',
'scheduledNoteFailed',
] 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.head">
<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'" :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>
@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
[$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 -->
<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>
</template>
<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使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon
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 === 'app'">{{ notification.header }}</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"/>
</header>
<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}`">
{{ i18n.ts.showFile }}
</MkA>
<div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text">
{{ notification.reason }}
</div>
<template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template>

View file

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

View file

@ -48,6 +48,7 @@ const cancel = () => {
emit('cancel');
dialogEl.value.close();
};
const paginationEl = ref();
const pagination: Paging = {
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'];
// @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)
export function nyaize(text: string): string;

View file

@ -4517,6 +4517,14 @@ export type components = {
/** Format: id */
userId: string;
note: components['schemas']['Note'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'scheduledNoteFailed';
reason: string;
} | {
/** Format: id */
id: string;
@ -19984,8 +19992,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('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' | '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' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@ -20052,8 +20060,8 @@ export type operations = {
untilId?: string;
/** @default true */
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')[];
excludeTypes?: ('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' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
};
};
};

View file

@ -16,7 +16,7 @@ import type {
UserLite,
} 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;

View file

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

View file

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