Add importCompleted notification. Send importCompleted when antenna/customEmoji/muting/userList is imported

The only userImportableEntities that don't notify
are blocking and following because they fork off a batch of single

Closes #891
This commit is contained in:
наб 2025-07-14 18:52:13 +02:00
parent 69f3c8a58e
commit a00a3c6841
No known key found for this signature in database
GPG key ID: BCFD0B018D2658F1
14 changed files with 104 additions and 7 deletions

8
locales/index.d.ts vendored
View file

@ -10378,6 +10378,10 @@ export interface Locale extends ILocale {
* Scheduled Note was posted * Scheduled Note was posted
*/ */
"scheduledNotePosted": string; "scheduledNotePosted": string;
/**
* Import of {x} has been completed
*/
"importOfXCompleted": ParameterizedString<"x">;
}; };
"_deck": { "_deck": {
/** /**
@ -13346,6 +13350,10 @@ export interface Locale extends ILocale {
* Don't delete files used as avatars&c * Don't delete files used as avatars&c
*/ */
"keepFilesInUse": string; "keepFilesInUse": string;
/**
* this option requires more complicated database queries, you may need to increase the value of db.extra.statement_timeout in the configuration file
*/
"keepFilesInUseDescription": string;
}; };
} }
declare const locales: { declare const locales: {

View file

@ -187,6 +187,10 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity, exportedEntity: notification.exportedEntity,
fileId: notification.fileId, fileId: notification.fileId,
} : {}), } : {}),
...(notification.type === 'importCompleted' ? {
importedEntity: notification.importedEntity,
fileId: notification.fileId,
} : {}),
...(notification.type === 'scheduledNoteFailed' ? { ...(notification.type === 'scheduledNoteFailed' ? {
reason: notification.reason, reason: notification.reason,
} : {}), } : {}),

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { userExportableEntities } from '@/types.js'; import { userExportableEntities, userImportableEntities } from '@/types.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiNote } from './Note.js'; import { MiNote } from './Note.js';
import { MiAccessToken } from './AccessToken.js'; import { MiAccessToken } from './AccessToken.js';
@ -92,6 +92,12 @@ export type MiNotification = {
createdAt: string; createdAt: string;
exportedEntity: typeof userExportableEntities[number]; exportedEntity: typeof userExportableEntities[number];
fileId: MiDriveFile['id']; fileId: MiDriveFile['id'];
} | {
type: 'importCompleted';
id: string;
createdAt: string;
importedEntity: typeof userImportableEntities[number];
fileId?: MiDriveFile['id'];
} | { } | {
type: 'login'; type: 'login';
id: string; id: string;

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { notificationTypes, userExportableEntities } from '@/types.js'; import { notificationTypes, userExportableEntities, userImportableEntities } from '@/types.js';
const baseSchema = { const baseSchema = {
type: 'object', type: 'object',
@ -334,6 +334,26 @@ export const packedNotificationSchema = {
format: 'id', format: 'id',
}, },
}, },
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['importCompleted'],
},
importedEntity: {
type: 'string',
optional: false, nullable: false,
enum: userImportableEntities,
},
fileId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
},
}, { }, {
type: 'object', type: 'object',
properties: { properties: {

View file

@ -12,6 +12,7 @@ import type { AntennasRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DBAntennaImportJobData } from '../types.js'; import { DBAntennaImportJobData } from '../types.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@ -65,6 +66,7 @@ export class ImportAntennasProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private idService: IdService, private idService: IdService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private notificationService: NotificationService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas'); this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas');
} }
@ -106,6 +108,10 @@ export class ImportAntennasProcessorService {
this.logger.debug('Antenna created: ' + result.id); this.logger.debug('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result); this.globalEventService.publishInternalEvent('antennaCreated', result);
} }
this.notificationService.createNotification(job.data.user.id, 'importCompleted', {
importedEntity: 'antenna',
});
} catch (err: any) { } catch (err: any) {
this.logger.error('Error importing antennas:', err); this.logger.error('Error importing antennas:', err);
} }

View file

@ -18,6 +18,7 @@ import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData } from '../types.js';
@ -40,6 +41,7 @@ export class ImportCustomEmojisProcessorService {
private driveService: DriveService, private driveService: DriveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-custom-emojis'); this.logger = this.queueLoggerService.logger.createSubLogger('import-custom-emojis');
} }
@ -127,7 +129,12 @@ export class ImportCustomEmojisProcessorService {
cleanup(); cleanup();
this.logger.debug('Imported'); this.notificationService.createNotification(job.data.user.id, 'importCompleted', {
importedEntity: 'customEmoji',
fileId: file.id,
});
this.logger.debug('Imported', file.name);
} catch (e) { } catch (e) {
this.logger.error('Error importing custom emojis:', e as Error); this.logger.error('Error importing custom emojis:', e as Error);
cleanup(); cleanup();

View file

@ -16,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData } from '../types.js';
@ -35,6 +36,7 @@ export class ImportMutingProcessorService {
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-muting'); this.logger = this.queueLoggerService.logger.createSubLogger('import-muting');
} }
@ -99,6 +101,11 @@ export class ImportMutingProcessorService {
} }
} }
this.notificationService.createNotification(job.data.user.id, 'importCompleted', {
importedEntity: 'muting',
fileId: file.id,
});
this.logger.debug('Imported'); this.logger.debug('Imported');
} }
} }

View file

@ -17,6 +17,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData } from '../types.js';
@ -43,6 +44,7 @@ export class ImportUserListsProcessorService {
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private notificationService: NotificationService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-user-lists'); this.logger = this.queueLoggerService.logger.createSubLogger('import-user-lists');
} }
@ -109,6 +111,11 @@ export class ImportUserListsProcessorService {
} }
} }
this.notificationService.createNotification(job.data.user.id, 'importCompleted', {
importedEntity: 'userList',
fileId: file.id,
});
this.logger.debug('Imported'); this.logger.debug('Imported');
} }
} }

View file

@ -39,6 +39,7 @@ export const notificationTypes = [
'chatRoomInvitationReceived', 'chatRoomInvitationReceived',
'achievementEarned', 'achievementEarned',
'exportCompleted', 'exportCompleted',
'importCompleted',
'login', 'login',
'createToken', 'createToken',
'scheduledNoteFailed', 'scheduledNoteFailed',

View file

@ -129,6 +129,7 @@ export const notificationTypes = [
'chatRoomInvitationReceived', 'chatRoomInvitationReceived',
'achievementEarned', 'achievementEarned',
'exportCompleted', 'exportCompleted',
'importCompleted',
'login', 'login',
'createToken', 'createToken',
'test', 'test',

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', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="['pollEnded', 'note', 'edited', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'importCompleted', 'login', 'createToken', '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_reactionGroupHeart]"><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_reactionGroupHeart]"><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>
@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_importCompleted]: notification.type === 'importCompleted',
[$style.t_login]: notification.type === 'login', [$style.t_login]: notification.type === 'login',
[$style.t_createToken]: notification.type === 'createToken', [$style.t_createToken]: notification.type === 'createToken',
[$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived',
@ -44,6 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'importCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
<i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i> <i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i>
<i v-else-if="notification.type === 'chatRoomInvitationReceived'" class="ti ti-messages"></i> <i v-else-if="notification.type === 'chatRoomInvitationReceived'" class="ti ti-messages"></i>
@ -75,6 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span> <span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span> <span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
<span v-else-if="notification.type === 'importCompleted'">{{ i18n.tsx._notification.importOfXCompleted({ x: importEntityName[notification.importedEntity] }) }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span> <span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span> <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
@ -122,7 +125,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }} {{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA> </MkA>
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> <MkA v-else-if="notification.type === 'exportCompleted' || (notification.type === 'importCompleted' && notification.fileId)" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
{{ i18n.ts.showFile }} {{ i18n.ts.showFile }}
</MkA> </MkA>
<MkA v-else-if="notification.type === 'createToken'" :class="$style.text" to="/settings/apps"> <MkA v-else-if="notification.type === 'createToken'" :class="$style.text" to="/settings/apps">
@ -219,6 +222,7 @@ const props = withDefaults(defineProps<{
}); });
type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' }; type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' };
type ImportCompletedNotification = Misskey.entities.Notification & { type: 'importCompleted' };
const exportEntityName = { const exportEntityName = {
antenna: i18n.ts.antennas, antenna: i18n.ts.antennas,
@ -232,6 +236,15 @@ const exportEntityName = {
userList: i18n.ts.lists, userList: i18n.ts.lists,
} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>; } as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>;
const importEntityName = {
antenna: i18n.ts.antennas,
blocking: i18n.ts.blockedUsers,
customEmoji: i18n.ts.customEmojis,
following: i18n.ts.following,
muting: i18n.ts.mutedUsers,
userList: i18n.ts.lists,
} as const satisfies Record<ImportCompletedNotification['importedEntity'], string>;
const followRequestDone = ref(true); const followRequestDone = ref(true);
const userDetailed: Ref<Misskey.entities.UserDetailed | null> = ref(null); const userDetailed: Ref<Misskey.entities.UserDetailed | null> = ref(null);
@ -398,7 +411,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none; pointer-events: none;
} }
.t_exportCompleted { .t_exportCompleted, .t_importCompleted {
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }

View file

@ -85,7 +85,7 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
const $i = ensureSignin(); const $i = ensureSignin();
const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted', 'importCompleted'] satisfies (typeof notificationTypes[number])[] as string[];
const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[]; const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNoteFailed', 'scheduledNotePosted'] satisfies (typeof notificationTypes[number])[] as string[];

View file

@ -235,6 +235,22 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
}]; }];
} }
case 'importCompleted': {
const entityName = {
antenna: i18n.ts.antennas,
blocking: i18n.ts.blockedUsers,
customEmoji: i18n.ts.customEmojis,
following: i18n.ts.following,
muting: i18n.ts.mutedUsers,
userList: i18n.ts.lists,
} as const satisfies Record<typeof data.body.importedEntity, string>;
return [i18n.tsx._notification.importOfXCompleted({ x: entityName[data.body.importedEntity] }), {
badge: iconUrl('circle-check'),
data,
}];
}
case 'pollEnded': case 'pollEnded':
return [i18n.ts._notification.pollEnded, { return [i18n.ts._notification.pollEnded, {
body: data.body.note.text ?? '', body: data.body.note.text ?? '',

View file

@ -314,6 +314,7 @@ _notification:
edited: "Note got edited" edited: "Note got edited"
scheduledNoteFailed: "Posting scheduled note failed" scheduledNoteFailed: "Posting scheduled note failed"
scheduledNotePosted: "Scheduled Note was posted" scheduledNotePosted: "Scheduled Note was posted"
importOfXCompleted: "Import of {x} has been completed"
_types: _types:
renote: "Boosts" renote: "Boosts"
edited: "Edits" edited: "Edits"