diff --git a/locales/index.d.ts b/locales/index.d.ts index 8c31fd6303..799781fbf3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -610,6 +610,10 @@ export interface Locale extends ILocale { * あなた宛て */ "mentions": string; + /** + * 新規投稿 + */ + "newNotes": string; /** * ダイレクト投稿 */ @@ -10641,6 +10645,10 @@ export interface Locale extends ILocale { * {n}人がリノートしました */ "renotedBySomeUsers": ParameterizedString<"n">; + /** + * {n}件の新しい投稿 + */ + "notedBySomeUsers": ParameterizedString<"n">; /** * {n}人にフォローされました */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 66fc4d8b38..96d467698f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -147,6 +147,7 @@ receiveFollowRequest: "フォローリクエストされました" followRequestAccepted: "フォローが承認されました" mention: "メンション" mentions: "あなた宛て" +newNotes: "新規投稿" directNotes: "ダイレクト投稿" importAndExport: "インポートとエクスポート" import: "インポート" @@ -2811,6 +2812,7 @@ _notification: reactedBySomeUsers: "{n}人がリアクションしました" likedBySomeUsers: "{n}人がいいねしました" renotedBySomeUsers: "{n}人がリノートしました" + notedBySomeUsers: "{n}件の新しい投稿" followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 625d65f60b..f448a31bee 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -66,7 +66,7 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types + options: { checkValidNotifier?: boolean; }, @@ -143,6 +143,27 @@ export class NotificationEntityService implements OnModuleInit { note: noteIfNeed, users, }); + } else if (notification.type === 'note:grouped') { + const users = (await Promise.all(notification.notifierIds.map(notifier => { + const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(notifier) : null; + if (packedUser) { + return packedUser; + } + + return this.userEntityService.pack(notifier, { id: meId }); + }))).filter(x => x != null); + // if all users have been deleted, don't show this notification + if (users.length === 0) { + return null; + } + + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + noteIds: notification.noteIds, + users, + }); } // #endregion @@ -207,6 +228,7 @@ export class NotificationEntityService implements OnModuleInit { if ('notifierId' in notification) userIds.push(notification.notifierId); if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId)); if (notification.type === 'renote:grouped') userIds.push(...notification.userIds); + if (notification.type === 'note:grouped') userIds.push(...notification.notifierIds); } const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, @@ -239,7 +261,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 68ece6b947..0c966a4adb 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -131,4 +131,10 @@ export type MiGroupedNotification = MiNotification | { createdAt: string; noteId: MiNote['id']; userIds: string[]; +} | { + type: 'note:grouped'; + id: string; + createdAt: string; + noteIds: string[]; + notifierIds: MiUser['id'][]; }; diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index dc6ffd3e02..833a74fe12 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -157,6 +157,24 @@ export default class extends Endpoint { // eslint- prevGroupedNotification.id = notification.id; continue; } + if (prev.type === 'note' && notification.type === 'note') { + if (prevGroupedNotification.type !== 'note:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'note:grouped', + id: '', + createdAt: notification.createdAt, + noteIds: [notification.noteId], + notifierIds: [prev.notifierId!], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + if (!(prevGroupedNotification as FilterUnionByProperty).notifierIds.includes(notification.notifierId)) { + (prevGroupedNotification as FilterUnionByProperty).notifierIds.push(notification.notifierId!); + } + (prevGroupedNotification as FilterUnionByProperty).noteIds.push(notification.noteId!); + prevGroupedNotification.id = notification.id; + continue; + } groupedNotifications.push(notification); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 78fbff1745..7fbd139bee 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -42,6 +42,7 @@ export const groupedNotificationTypes = [ ...notificationTypes, 'reaction:grouped', 'renote:grouped', + 'note:grouped', ] as const; export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index d2db313387..d3e73459d6 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -18794,8 +18794,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'note:grouped' | 'pollVote')[]; }; }; }; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 14f4108962..c2c57e3978 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -62,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} {{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} + {{ i18n.tsx._notification.notedBySomeUsers({ n: notification.noteIds.length }) }} {{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }} {{ notification.header }} @@ -147,6 +149,11 @@ SPDX-License-Identifier: AGPL-3.0-only +
+
+ +
+
@@ -243,6 +250,7 @@ const rejectGroupInvitation = () => { height: 100%; } +.icon_noteGroup, .icon_reactionGroup, .icon_reactionGroupHeart, .icon_renoteGroup { @@ -268,6 +276,11 @@ const rejectGroupInvitation = () => { background: var(--eventRenote); } +.icon_noteGroup { + + background: var(--eventRenote); +} + .icon_app { border-radius: 6px; } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 79907ef9e2..c7e2591f21 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -41,11 +41,18 @@ import { globalEvents } from '@/events.js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; + notUseGrouped?: boolean; }>(); const pagingComponent = shallowRef>(); -const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? { +const pagination = computed(() => props.notUseGrouped ? { + endpoint: 'i/notifications' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), +} : defaultStore.reactiveState.useGroupedNotifications.value ? { endpoint: 'i/notifications-grouped' as const, limit: 20, params: computed(() => ({ diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 262631393c..4dbdb1aee5 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -69,6 +69,8 @@ export const notificationTypes = [ 'roleAssigned', 'achievementEarned', 'app', + 'test', + 'pollVote', ] as const; export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const; diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index a62747e2c5..e4213648c9 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ +
@@ -38,6 +41,7 @@ import { flushNotification } from '@/scripts/check-nortification-delete.js'; const tab = ref('all'); const includeTypes = ref(null); const excludeTypes = computed(() => includeTypes.value ? notificationTypes.filter(t => !includeTypes.value.includes(t)) : undefined); +const newNoteExcludeTypes = computed(() => notificationTypes.filter(t => !['note'].includes(t))); const props = defineProps<{ disableRefreshButton?: boolean; @@ -103,6 +107,10 @@ const headerTabs = computed(() => [{ key: 'all', title: i18n.ts.all, icon: 'ti ti-point', +}, { + key: 'newNote', + title: i18n.ts.newNotes, + icon: 'ti ti-pencil', }, { key: 'mentions', title: i18n.ts.mentions,